@blamejs/core 0.13.17 → 0.13.19

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,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.13.x
10
10
 
11
+ - v0.13.19 (2026-05-27) — **`auditTools` export / archive / forensic-snapshot can return the bundle as bytes — no output directory for serverless / read-only filesystems.** b.auditTools.exportSlice, b.auditTools.archive, and b.auditTools.forensicSnapshot required an `out` directory to write the encrypted bundle (rows.enc + optional checkpoint.enc + manifest.json), which is unusable on a read-only or ephemeral serverless filesystem. Each now accepts `returnBytes: true` instead of `out` and returns the bundle as an in-memory `{ filename: Buffer }` map — ready to stream to object storage or over the wire with no filesystem access. `out` and `returnBytes` are mutually exclusive. The on-disk path is unchanged. The bundle's encryption (XChaCha20-Poly1305 + Argon2id), chain-proof material, and manifest checksums are identical to the written bundle, so an in-memory bundle written to disk verifies exactly as one produced by the `out` path. **Added:** *`returnBytes` on `auditTools.exportSlice` / `archive` / `forensicSnapshot` — in-memory bundles* — Pass `returnBytes: true` (and omit `out`) to get the encrypted audit bundle as an in-memory `{ filename: Buffer }` map instead of a directory write — the read-only / serverless path. `exportSlice` / `archive` return `{ manifest, files, rowCount, range }`; `forensicSnapshot` returns `{ ...manifest, files }` where `files` carries the slice's `rows.enc` + `manifest.json` plus the `forensic-snapshot.json` incident wrapper. The encryption, chain proof, and manifest checksums match the on-disk bundle byte-for-byte, so the bytes verify with `verifyBundle` once written out. `out` and `returnBytes` are mutually exclusive (passing both throws). **Fixed:** *`auditTools.forensicSnapshot` now honors the `since` window instead of capturing the entire audit history* — `forensicSnapshot` passed its `since` bound to the slice exporter under the wrong option name, so the time filter was silently dropped and the snapshot bundled every audit row regardless of `since`. The window is now applied — a snapshot scoped to an incident window contains only that window's rows. The snapshot manifest's `auditSliceFile` field, previously always undefined, now records the slice location.
12
+
13
+ - v0.13.18 (2026-05-27) — **`bodyParser` multipart can buffer uploads in memory — no tmp directory for serverless / read-only filesystems.** The multipart/form-data sub-parser previously streamed every file part to a tmp directory on disk (os.tmpdir() by default), which fails on a read-only or ephemeral serverless filesystem. A new multipart.storage option selects where file parts land: "disk" (default, unchanged — req.files[].path points at a tmp file cleaned up on response end) or "memory" (req.files[].buffer holds the assembled bytes, with no filesystem access at all). Both modes enforce the same per-file (fileSize), per-field, and total-request (totalSize) caps, so memory mode adds no new memory-exhaustion surface. The file object shape is stable across both modes — disk sets path with buffer null, memory sets buffer with path null — so a handler branches on whichever is non-null. An invalid storage value is rejected when the middleware is constructed. **Added:** *`bodyParser` multipart `storage: "memory"` — buffer uploads in RAM instead of a tmp directory* — `b.middleware.bodyParser({ multipart: { storage: "memory" } })` buffers each uploaded file part in memory and exposes it as `req.files[].buffer` (a Buffer), with no `os.tmpdir()` write and no tmp-file cleanup — the read-only / serverless path. The default `storage: "disk"` is unchanged: file parts stream to a tmp file, `req.files[].path` points at it, and it is removed when the response finishes. Both modes apply the existing `fileSize` / per-field `maxBytes` / `totalSize` caps and SHA3-512 hash each part during streaming, so memory mode is bounded by the same limits and adds no new DoS surface. The `req.files[]` shape is stable across modes (disk: `path` set, `buffer` null; memory: `buffer` set, `path` null). A `storage` value other than `"disk"` or `"memory"` throws a `TypeError` at construction.
14
+
11
15
  - v0.13.17 (2026-05-27) — **Template engine can render from a string with no views directory — for serverless / read-only filesystems.** b.template.create previously required a viewsDir that exists on disk, and rendering always read the template (and its layout/partials) from that directory — unusable on a read-only or ephemeral serverless filesystem where the templates aren't on disk. The engine now accepts a source string directly: viewsDir is optional, and the returned engine exposes renderString(source, data?, opts?) and compileString(source, opts?) that compile and render from a string with no disk read. {% extends %} and {{> partial}} in a string source resolve through an operator-supplied opts.resolve(name) -> string callback (without it, an extends throws a clear error and a missing partial inlines empty, matching the file path). The same HTML-escaping, expression grammar, and extends/partial-depth caps apply. The file-backed render / compile / precompileAll still work exactly as before when a viewsDir is configured, and now refuse with a clear error when one isn't. **Added:** *`engine.renderString` / `engine.compileString` — render templates from a string, no viewsDir* — `b.template.create({})` (no `viewsDir`) returns a string-only engine; `renderString(source, data?, { resolve })` and `compileString(source, { resolve })` compile and render from a source string with zero filesystem access — the read-only / serverless path. `{% extends %}` and `{{> partial}}` resolve through `opts.resolve(name) -> string`. The HTML escaping, grammar, and depth caps are identical to the file path. When a `viewsDir` IS configured, `render`/`compile`/`precompileAll` behave exactly as before; without one they refuse with `viewsDir not configured`. `renderString(source, { resolve })` may omit the data argument — an opts object carrying a function `resolve` is recognized as opts, not data. **Security:** *Vendored `@simplewebauthn/server` refreshed 13.3.0 → 13.3.1* — The vendored WebAuthn server bundle (`b.auth.passkey`'s registration/authentication verification) is refreshed to the latest upstream patch, with the MANIFEST version, CPE, and SHA-256 integrity hashes updated and the bundle re-verified.
12
16
 
13
17
  - v0.13.16 (2026-05-27) — **`b.mail.agent` docs now describe the facade accurately, and not-yet-wired verbs point to the primitive to use.** b.mail.agent's module documentation claimed it was "the standardization contract for every mail protocol" that JMAP / IMAP / POP3 all route through — but no protocol server actually dispatches through the agent (the framework's own JMAP EmailSubmission handler composes b.mail.send.deliver directly), and the compose / send / reply / forward, sieve.list / sieve.activate, identity / vacation / mdn.* and export / job / import verbs throw mail-agent/not-implemented. The docs are corrected to describe what the agent is: a mailbox-access facade (RBAC + posture + audit + dispatch around a mail store) whose read surface plus the mailbox-mutation and Sieve-upload methods are wired, with the remaining verbs not yet routed through it. Those verbs' error message now names the underlying primitive to compose directly (b.mail.send.deliver, b.mail.sieve, b.mailMdn, …) instead of citing a version tag that had long passed. The public WIRED_AT export (a method→version map that no longer reflected reality) is replaced by COMPOSE_HINT (a method→primitive-to-compose map). No behaviour change: the same methods are wired or throw exactly as before. **Changed:** *`b.mail.agent` documentation corrected; not-implemented errors point to the primitive to compose* — The `@module` / `@card` no longer claim the agent is the universal protocol-dispatch contract — it's documented as a mailbox-access facade with a wired read + mutation + Sieve-upload surface, and the compose/send/identity/vacation/MDN/export verbs documented as not yet routed through it (compose the underlying primitive directly until a protocol server adopts the agent). The `mail-agent/not-implemented` error now names that primitive (e.g. `b.mail.send.deliver`) rather than a passed version tag. **Removed:** *`b.mail.agent.WIRED_AT` export replaced by `COMPOSE_HINT`* — The `WIRED_AT` export mapped each method to a framework version that was supposed to "light it up" — versions that have all shipped without the wiring, so the map was misleading. It is replaced by `COMPOSE_HINT`, mapping each not-yet-wired method to the primitive an operator composes directly. Operators reading `b.mail.agent.WIRED_AT` should read `b.mail.agent.COMPOSE_HINT` instead (pre-1.0: no compatibility shim).
package/README.md CHANGED
@@ -125,7 +125,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
125
125
  - Rate-limit
126
126
  - Security headers with `Permissions-Policy` defaults denying storage-access / browsing-topics / private-aggregation / controlled-frame
127
127
  - CSP nonce
128
- - Body parser
128
+ - Body parser — JSON / urlencoded / text / multipart; multipart file parts stream to a tmp dir or buffer in memory (`storage: "memory"`) for read-only / serverless filesystems
129
129
  - Compression
130
130
  - SSE
131
131
  - Request log
@@ -221,7 +221,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
221
221
  - **PII redaction** — `b.redact`
222
222
  - **Decoy detection** — canary-credential / decoy-record framework auditing every positive lookup as `honeytoken.tripped` (`b.honeytoken`)
223
223
  - **Boot assertions** — operator-callable security policy assertions (`b.security.assertProduction`); tamper-evident config-baseline drift detection signed with audit-signing key + at-boot vendor-bundle SHA-256 integrity verification across `lib/vendor/*` (`b.configDrift`, `b.configDrift.verifyVendorIntegrity`)
224
- - **CSP reports + forensic export** — `b.middleware.cspReport`; post-incident audit-bundle composer (`b.auditTools.forensicSnapshot`)
224
+ - **CSP reports + forensic export** — `b.middleware.cspReport`; post-incident audit-bundle composer (`b.auditTools.forensicSnapshot`); audit export / archive / forensic snapshot write to disk or return the encrypted bundle in memory (`returnBytes`) for read-only / serverless filesystems
225
225
 
226
226
  ### i18n + format helpers
227
227
 
@@ -146,14 +146,18 @@ function fsync(fd) {
146
146
  * b.atomicFile.fsyncDir("/var/lib/blamejs/data");
147
147
  */
148
148
  function fsyncDir(dirPath) {
149
- // CodeQL js/insecure-temporary-file: dirPath is an operator-supplied
150
- // framework data directory (e.g. /var/lib/blamejs/data)never an
151
- // os.tmpdir-reachable path. The fd is used solely for fsync and is
152
- // closed immediately; no read or write occurs through it, so the
153
- // tmp-file heuristic does not apply. Owner-only 0o700 dataDir
154
- // perms are set by ensureDir.
149
+ // CodeQL js/insecure-temporary-file: this is a read-only open of an
150
+ // EXISTING directory to fsync its inode no file is created, so the
151
+ // predictable-temp-name / symlink-race the query targets does not
152
+ // apply. The fd is opened "r", fsynced, and closed immediately; no
153
+ // write goes through it. The directory itself is created 0o700 by
154
+ // ensureDir. dirPath is normally an operator data dir (e.g.
155
+ // /var/lib/blamejs/data); when a caller fsyncs a dir under os.tmpdir
156
+ // (test fixtures via fs.mkdtempSync, or an audit bundle written to a
157
+ // tmp `out`), mkdtempSync already guarantees a unique 0o700 dir, so
158
+ // there is still no race surface.
155
159
  try {
156
- var fd = nodeFs.openSync(dirPath, "r");
160
+ var fd = nodeFs.openSync(dirPath, "r"); // lgtm[js/insecure-temporary-file] — read-only fsync of an existing dir; no temp file created
157
161
  try { nodeFs.fsyncSync(fd); } catch (_e) { /* Windows rejects directory fsync */ }
158
162
  finally { nodeFs.closeSync(fd); }
159
163
  } catch (_e) { /* dir fsync is best-effort across filesystems */ }
@@ -291,36 +291,43 @@ async function _defaultReadPredecessorRowHash(firstCounter) {
291
291
 
292
292
  // ---- Bundle writer ----
293
293
 
294
- async function _writeBundle(args) {
295
- var outDir = args.outDir;
294
+ // Assemble the encrypted bundle entirely in memory: returns the
295
+ // manifest plus an ordered { filename: Buffer } map. Pure — no
296
+ // filesystem touch — so it backs both the on-disk writer and the
297
+ // returnBytes / serverless path. The bundle is always the same 2-3
298
+ // files (rows.enc, optional checkpoint.enc, manifest.json) whether it
299
+ // lands on disk or ships as bytes.
300
+ async function _buildBundle(args) {
296
301
  var kind = args.kind;
297
302
  var rows = args.rows;
298
303
  var checkpoint = args.checkpoint || null;
299
304
  var passphrase = args.passphrase;
300
305
  var predecessorRowHash = args.predecessorRowHash;
301
306
 
302
- atomicFile.ensureDir(outDir);
303
-
304
307
  var firstRow = rows[0];
305
308
  var lastRow = rows[rows.length - 1];
309
+ var files = {};
306
310
 
307
311
  // 1. Encrypt the rows JSONL
308
312
  var jsonl = rows.map(function (r) {
309
313
  return JSON.stringify(_rowToWireForm(r));
310
314
  }).join("\n") + "\n";
311
315
  var rowsEnc = await backupCrypto.encryptWithFreshSalt(jsonl, passphrase);
312
- atomicFile.writeSync(nodePath.join(outDir, "rows.enc"), rowsEnc.encrypted, { fileMode: 0o600 });
316
+ files["rows.enc"] = rowsEnc.encrypted;
313
317
 
314
318
  // 2. (archive) Encrypt the checkpoint JSON
315
319
  var checkpointSalt = null;
320
+ var checkpointEncrypted = null;
316
321
  if (checkpoint) {
317
322
  var ckptJson = _canonicalize(_rowToWireForm(checkpoint));
318
323
  var ckptEnc = await backupCrypto.encryptWithFreshSalt(ckptJson, passphrase);
319
- atomicFile.writeSync(nodePath.join(outDir, "checkpoint.enc"), ckptEnc.encrypted, { fileMode: 0o600 });
324
+ files["checkpoint.enc"] = ckptEnc.encrypted;
320
325
  checkpointSalt = ckptEnc.salt;
326
+ checkpointEncrypted = ckptEnc.encrypted;
321
327
  }
322
328
 
323
- // 3. Build manifest
329
+ // 3. Build manifest — checksums computed from the in-memory buffers
330
+ // (no read-back of what we just wrote).
324
331
  var manifest = {
325
332
  format: BUNDLE_FORMAT,
326
333
  kind: kind,
@@ -342,8 +349,8 @@ async function _writeBundle(args) {
342
349
  },
343
350
  checksum: {
344
351
  rowsSha3_512: backupCrypto.checksum(rowsEnc.encrypted),
345
- checkpointSha3_512: checkpointSalt
346
- ? backupCrypto.checksum(nodeFs.readFileSync(nodePath.join(outDir, "checkpoint.enc")))
352
+ checkpointSha3_512: checkpointEncrypted
353
+ ? backupCrypto.checksum(checkpointEncrypted)
347
354
  : null,
348
355
  },
349
356
  };
@@ -355,9 +362,22 @@ async function _writeBundle(args) {
355
362
  checkpointId: String(checkpoint._id),
356
363
  };
357
364
  }
365
+ files["manifest.json"] = Buffer.from(_canonicalize(manifest), "utf8");
366
+ return { manifest: manifest, files: files };
367
+ }
368
+
369
+ async function _writeBundle(args) {
370
+ var outDir = args.outDir;
371
+ var built = await _buildBundle(args);
372
+
373
+ atomicFile.ensureDir(outDir);
374
+ atomicFile.writeSync(nodePath.join(outDir, "rows.enc"), built.files["rows.enc"], { fileMode: 0o600 });
375
+ if (built.files["checkpoint.enc"]) {
376
+ atomicFile.writeSync(nodePath.join(outDir, "checkpoint.enc"), built.files["checkpoint.enc"], { fileMode: 0o600 });
377
+ }
358
378
  var manifestPath = nodePath.join(outDir, "manifest.json");
359
- atomicFile.writeSync(manifestPath, _canonicalize(manifest), { fileMode: 0o600 });
360
- return { manifest: manifest, manifestPath: manifestPath };
379
+ atomicFile.writeSync(manifestPath, built.files["manifest.json"], { fileMode: 0o600 });
380
+ return { manifest: built.manifest, manifestPath: manifestPath };
361
381
  }
362
382
 
363
383
  // ---- Bundle reader ----
@@ -437,8 +457,14 @@ async function _readBundle(inDir, passphrase) {
437
457
  * Refuses if `opts.out` exists, no rows match, or no signed
438
458
  * checkpoint covers the slice (run `b.audit.checkpoint()` first).
439
459
  *
460
+ * Pass `returnBytes: true` instead of `out` for the bundle as an
461
+ * in-memory `{ filename: Buffer }` map (`rows.enc` + `checkpoint.enc`
462
+ * + `manifest.json`) — the read-only / serverless path. `out` and
463
+ * `returnBytes` are mutually exclusive.
464
+ *
440
465
  * @opts
441
- * out: string, // fresh directory path for the bundle
466
+ * out: string, // fresh directory path (omit when returnBytes)
467
+ * returnBytes:boolean, // true → return { manifest, files } in memory, no disk
442
468
  * before: number|Date|string, // archive rows recordedAt < this
443
469
  * passphrase: Buffer|string, // bundle-encryption passphrase
444
470
  *
@@ -455,7 +481,12 @@ async function _readBundle(inDir, passphrase) {
455
481
  async function archive(opts) {
456
482
  opts = opts || {};
457
483
  _requirePassphrase(opts.passphrase);
458
- _requireOutDir(opts.out, "archive");
484
+ var returnBytes = opts.returnBytes === true;
485
+ if (returnBytes && opts.out !== undefined) {
486
+ throw new AuditToolsError("audit-tools/out-and-return-bytes",
487
+ "archive: specify either opts.out (write to disk) or opts.returnBytes (in-memory bytes), not both");
488
+ }
489
+ if (!returnBytes) _requireOutDir(opts.out, "archive");
459
490
  var beforeMs = _toMs(opts.before);
460
491
  if (beforeMs == null) {
461
492
  throw new AuditToolsError("audit-tools/no-before",
@@ -482,6 +513,22 @@ async function archive(opts) {
482
513
 
483
514
  var predecessorRowHash = await readPredecessorHash(firstCounter);
484
515
 
516
+ if (returnBytes) {
517
+ var built = await _buildBundle({
518
+ kind: KIND_ARCHIVE,
519
+ rows: rows,
520
+ checkpoint: checkpoint,
521
+ passphrase: opts.passphrase,
522
+ predecessorRowHash: predecessorRowHash,
523
+ });
524
+ return {
525
+ manifest: built.manifest,
526
+ files: built.files,
527
+ rowCount: rows.length,
528
+ range: built.manifest.range,
529
+ };
530
+ }
531
+
485
532
  var written = await _writeBundle({
486
533
  outDir: opts.out,
487
534
  kind: KIND_ARCHIVE,
@@ -517,8 +564,15 @@ async function archive(opts) {
517
564
  * action filter that drops intermediate counters is rejected with
518
565
  * `audit-tools/non-contiguous`.
519
566
  *
567
+ * Pass `returnBytes: true` instead of `out` to get the bundle as an
568
+ * in-memory `{ filename: Buffer }` map (`rows.enc` + `manifest.json`)
569
+ * with no filesystem touch — the read-only / serverless path; ship it
570
+ * to object storage or over the wire. `out` and `returnBytes` are
571
+ * mutually exclusive.
572
+ *
520
573
  * @opts
521
- * out: string, // fresh directory path
574
+ * out: string, // fresh directory path (omit when returnBytes)
575
+ * returnBytes:boolean, // true → return { manifest, files } in memory, no disk
522
576
  * from: number|Date|string, // recordedAt >= this (inclusive)
523
577
  * to: number|Date|string, // recordedAt <= this (inclusive)
524
578
  * action: string, // exact action match (optional)
@@ -536,7 +590,12 @@ async function archive(opts) {
536
590
  async function exportSlice(opts) {
537
591
  opts = opts || {};
538
592
  _requirePassphrase(opts.passphrase);
539
- _requireOutDir(opts.out, "export");
593
+ var returnBytes = opts.returnBytes === true;
594
+ if (returnBytes && opts.out !== undefined) {
595
+ throw new AuditToolsError("audit-tools/out-and-return-bytes",
596
+ "export: specify either opts.out (write to disk) or opts.returnBytes (in-memory bytes), not both");
597
+ }
598
+ if (!returnBytes) _requireOutDir(opts.out, "export");
540
599
  var fromMs = _toMs(opts.from);
541
600
  var toMs = _toMs(opts.to);
542
601
  var readRows = opts.readRows || _defaultReadRows;
@@ -567,6 +626,22 @@ async function exportSlice(opts) {
567
626
  var firstCounter = Number(rows[0].monotonicCounter);
568
627
  var predecessorRowHash = await readPredecessorHash(firstCounter);
569
628
 
629
+ if (returnBytes) {
630
+ var built = await _buildBundle({
631
+ kind: KIND_EXPORT,
632
+ rows: rows,
633
+ checkpoint: null,
634
+ passphrase: opts.passphrase,
635
+ predecessorRowHash: predecessorRowHash,
636
+ });
637
+ return {
638
+ manifest: built.manifest,
639
+ files: built.files,
640
+ rowCount: rows.length,
641
+ range: built.manifest.range,
642
+ };
643
+ }
644
+
570
645
  var written = await _writeBundle({
571
646
  outDir: opts.out,
572
647
  kind: KIND_EXPORT,
@@ -852,9 +927,15 @@ async function _defaultApplyPurge(args) {
852
927
  * `audit.forensic_snapshot.composed` audit event so the act of
853
928
  * composing the snapshot is itself on-chain.
854
929
  *
930
+ * Pass `returnBytes: true` instead of `out` for the snapshot as an
931
+ * in-memory `{ filename: Buffer }` map (the slice's `rows.enc` +
932
+ * `manifest.json` plus `forensic-snapshot.json`) — the read-only /
933
+ * serverless path. `out` and `returnBytes` are mutually exclusive.
934
+ *
855
935
  * @opts
856
- * out: string, // fresh directory path
857
- * since: number|Date|string, // include rows recordedAt >= this
936
+ * out: string, // fresh directory path (omit when returnBytes)
937
+ * returnBytes:boolean, // true return { ...manifest, files } in memory, no disk
938
+ * since: number|Date|string, // include rows recordedAt >= this (windowed since → now)
858
939
  * passphrase: Buffer|string, // bundle-encryption passphrase
859
940
  * reason: string, // required incident-context reason
860
941
  * incidentId: string, // optional ticket / incident id
@@ -874,29 +955,39 @@ async function _defaultApplyPurge(args) {
874
955
  async function forensicSnapshot(opts) {
875
956
  opts = opts || {};
876
957
  _requirePassphrase(opts.passphrase);
877
- _requireOutDir(opts.out, "forensicSnapshot");
958
+ var returnBytes = opts.returnBytes === true;
959
+ if (returnBytes && opts.out !== undefined) {
960
+ throw new AuditToolsError("audit-tools/out-and-return-bytes",
961
+ "forensicSnapshot: specify either opts.out (write to disk) or opts.returnBytes (in-memory bytes), not both");
962
+ }
963
+ if (!returnBytes) _requireOutDir(opts.out, "forensicSnapshot");
878
964
  var sinceMs = _toMs(opts.since);
879
965
  if (sinceMs == null) {
880
966
  throw new AuditToolsError("audit-tools/no-since",
881
967
  "forensicSnapshot: opts.since is required");
882
968
  }
883
969
  validateOpts.requireNonEmptyString(opts.reason, "reason", AuditToolsError, "audit-tools/no-reason");
970
+ // exportSlice windows by from/to — pass the requested `since` as `from`
971
+ // and now as `to` so the snapshot captures only the incident window
972
+ // rather than the entire audit history.
884
973
  var sliceResult = await exportSlice({
885
- out: opts.out,
886
- since: sinceMs,
887
- until: Date.now(),
888
- passphrase: opts.passphrase,
889
- readRows: opts.readRows,
974
+ out: returnBytes ? undefined : opts.out,
975
+ returnBytes: returnBytes,
976
+ from: sinceMs,
977
+ to: Date.now(),
978
+ passphrase: opts.passphrase,
979
+ readRows: opts.readRows,
890
980
  readCoveringCheckpoint: opts.readCoveringCheckpoint,
891
981
  });
892
- // Compose snapshot manifest with operator-supplied IR context.
982
+ // Compose snapshot manifest with operator-supplied IR context. The
983
+ // audit slice lands as rows.enc inside the bundle either way.
893
984
  var manifest = {
894
985
  snapshotKind: "forensic",
895
986
  incidentId: opts.incidentId || null,
896
987
  reason: opts.reason,
897
988
  actor: opts.actor || null,
898
989
  composedAt: new Date().toISOString(),
899
- auditSliceFile: sliceResult && sliceResult.path,
990
+ auditSliceFile: returnBytes ? "rows.enc" : (sliceResult && sliceResult.manifestPath),
900
991
  auditSliceCount: sliceResult && sliceResult.rowCount,
901
992
  runtime: {
902
993
  nodeVersion: process.version,
@@ -906,14 +997,18 @@ async function forensicSnapshot(opts) {
906
997
  uptimeSec: Math.round(process.uptime()),
907
998
  },
908
999
  };
909
- var manifestPath = require("node:path").join(opts.out, "forensic-snapshot.json");
910
- require("node:fs").writeFileSync(manifestPath, _canonicalize(manifest), "utf8");
1000
+ var manifestBytes = Buffer.from(_canonicalize(manifest), "utf8");
1001
+ var manifestPath = null;
1002
+ if (!returnBytes) {
1003
+ manifestPath = nodePath.join(opts.out, "forensic-snapshot.json");
1004
+ atomicFile.writeSync(manifestPath, manifestBytes, { fileMode: 0o600 });
1005
+ }
911
1006
  try {
912
1007
  require("./audit").safeEmit({
913
1008
  action: "audit.forensic_snapshot.composed",
914
1009
  outcome: "success",
915
1010
  metadata: {
916
- out: opts.out,
1011
+ out: returnBytes ? null : opts.out,
917
1012
  incidentId: manifest.incidentId,
918
1013
  reason: opts.reason,
919
1014
  actor: opts.actor || null,
@@ -921,6 +1016,12 @@ async function forensicSnapshot(opts) {
921
1016
  },
922
1017
  });
923
1018
  } catch (_e) { /* audit best-effort */ }
1019
+ if (returnBytes) {
1020
+ // Mirror the on-disk layout: the slice's files plus the IR wrapper.
1021
+ var files = Object.assign({}, sliceResult.files);
1022
+ files["forensic-snapshot.json"] = manifestBytes;
1023
+ return Object.assign({}, manifest, { files: files });
1024
+ }
924
1025
  return Object.assign({}, manifest, { manifestPath: manifestPath });
925
1026
  }
926
1027
 
@@ -37,7 +37,8 @@
37
37
  * text: { limit: b.constants.BYTES.mib(1), charset: "utf-8" },
38
38
  * raw: { limit: b.constants.BYTES.mib(10), contentTypes: ["application/octet-stream"] },
39
39
  * multipart: {
40
- * tmpDir: os.tmpdir(),
40
+ * storage: "disk", // "disk" → req.files[].path; "memory" → req.files[].buffer (serverless / read-only fs)
41
+ * tmpDir: os.tmpdir(), // disk mode only
41
42
  * fileSize: b.constants.BYTES.mib(10),
42
43
  * totalSize: b.constants.BYTES.mib(50),
43
44
  * fileCount: 20,
@@ -184,7 +185,8 @@ var DEFAULTS = Object.freeze({
184
185
  contentTypes: ["application/octet-stream"],
185
186
  },
186
187
  multipart: {
187
- tmpDir: null, // resolved per-instance from os.tmpdir()
188
+ storage: "disk", // "disk" (tmp files) | "memory" (req.files[].buffer)
189
+ tmpDir: null, // resolved per-instance from os.tmpdir() (disk mode only)
188
190
  fileSize: C.BYTES.mib(10),
189
191
  totalSize: C.BYTES.mib(50),
190
192
  fileCount: 20,
@@ -680,16 +682,23 @@ async function _parseMultipart(req, opts, ctParams) {
680
682
  true, HTTP_STATUS.BAD_REQUEST
681
683
  );
682
684
  }
685
+ // storage: "memory" buffers file parts in RAM (capped by fileSize ×
686
+ // fileCount, the same DoS bound as disk mode) and exposes each file as
687
+ // req.files[].buffer with no filesystem touch — the read-only /
688
+ // serverless path. "disk" (default) streams to tmp files as before.
689
+ var useMemory = opts.storage === "memory";
683
690
  // Resolve tmpDir per-request so directory-creation failure surfaces as a
684
- // structured error rather than a deferred fs throw.
685
- var tmpDir = opts.tmpDir || nodePath.join(os.tmpdir(), "blamejs-uploads");
686
- try { atomicFile.ensureDir(tmpDir, 0o700); }
687
- catch (e) {
688
- throw new BodyParserError(
689
- "body-parser/multipart-tmpdir",
690
- "could not create multipart tmp dir '" + tmpDir + "': " + ((e && e.message) || String(e)),
691
- true, 500
692
- );
691
+ // structured error rather than a deferred fs throw (disk mode only).
692
+ var tmpDir = useMemory ? null : (opts.tmpDir || nodePath.join(os.tmpdir(), "blamejs-uploads"));
693
+ if (!useMemory) {
694
+ try { atomicFile.ensureDir(tmpDir, 0o700); }
695
+ catch (e) {
696
+ throw new BodyParserError(
697
+ "body-parser/multipart-tmpdir",
698
+ "could not create multipart tmp dir '" + tmpDir + "': " + ((e && e.message) || String(e)),
699
+ true, 500
700
+ );
701
+ }
693
702
  }
694
703
 
695
704
  var boundaryBuf = Buffer.from("--" + boundary);
@@ -721,7 +730,8 @@ async function _parseMultipart(req, opts, ctParams) {
721
730
  var currentFd = null;
722
731
  var currentSize = 0;
723
732
  var currentHash = null;
724
- var currentBuf = null; // for fields (in-memory accumulator)
733
+ var currentBuf = null; // in-memory accumulator (text fields always; file parts when useMemory)
734
+ var currentIsFile = false; // file part (has a filename) vs text field — drives finalize shape
725
735
  var currentDiscarded = false; // true when fileFilter rejected the part — body bytes are
726
736
  // still consumed (we have to read past them to find the next
727
737
  // boundary) but never written to disk.
@@ -737,6 +747,7 @@ async function _parseMultipart(req, opts, ctParams) {
737
747
  currentSize = 0;
738
748
  currentHash = null;
739
749
  currentBuf = null;
750
+ currentIsFile = false;
740
751
  currentDiscarded = false;
741
752
  currentEffectiveLimit = 0;
742
753
  }
@@ -765,7 +776,8 @@ async function _parseMultipart(req, opts, ctParams) {
765
776
  if (currentFd !== null) { try { nodeFs.closeSync(currentFd); } catch (_e) { /* fd already closed */ } currentFd = null; }
766
777
  if (currentTmpPath) { try { nodeFs.unlinkSync(currentTmpPath); } catch (_e) { /* tmp file already removed */ } }
767
778
  for (var i = 0; i < files.length; i++) {
768
- try { nodeFs.unlinkSync(files[i].path); } catch (_e) { /* tmp file already removed */ }
779
+ // Memory-mode files have no path (buffer only) nothing to unlink.
780
+ if (files[i].path) { try { nodeFs.unlinkSync(files[i].path); } catch (_e) { /* tmp file already removed */ } }
769
781
  }
770
782
  }
771
783
 
@@ -943,17 +955,24 @@ async function _parseMultipart(req, opts, ctParams) {
943
955
  }
944
956
  }
945
957
 
946
- // Generate the tmp path — never derived from the
947
- // operator-supplied filename.
948
- var unique = bCrypto.generateToken(C.BYTES.bytes(16));
949
- currentTmpPath = nodePath.join(tmpDir, "blamejs-up-" + unique);
950
- try {
951
- currentFd = nodeFs.openSync(currentTmpPath, "wx", 0o600);
952
- } catch (e) {
953
- done(new BodyParserError("body-parser/multipart-tmp-open",
954
- "could not open multipart tmp file: " + ((e && e.message) || String(e)),
955
- true, 500));
956
- return;
958
+ currentIsFile = true;
959
+ if (useMemory) {
960
+ // Buffer the file in RAM — no filesystem touch. Bounded by
961
+ // currentEffectiveLimit (per-file) and totalSize (per-request).
962
+ currentBuf = [];
963
+ } else {
964
+ // Generate the tmp path — never derived from the
965
+ // operator-supplied filename.
966
+ var unique = bCrypto.generateToken(C.BYTES.bytes(16));
967
+ currentTmpPath = nodePath.join(tmpDir, "blamejs-up-" + unique);
968
+ try {
969
+ currentFd = nodeFs.openSync(currentTmpPath, "wx", 0o600);
970
+ } catch (e) {
971
+ done(new BodyParserError("body-parser/multipart-tmp-open",
972
+ "could not open multipart tmp file: " + ((e && e.message) || String(e)),
973
+ true, 500));
974
+ return;
975
+ }
957
976
  }
958
977
  currentHash = nodeCrypto.createHash("sha3-512");
959
978
  currentSize = 0;
@@ -1004,8 +1023,10 @@ async function _parseMultipart(req, opts, ctParams) {
1004
1023
  true, 413));
1005
1024
  return;
1006
1025
  }
1007
- } else if (currentFd !== null) {
1008
- // File part — write to disk.
1026
+ } else if (currentIsFile) {
1027
+ // File part — write to disk (currentFd) or accumulate in
1028
+ // memory (useMemory). Same per-file + total-request caps
1029
+ // either way, so memory mode adds no new DoS surface.
1009
1030
  currentSize += bodyChunk.length;
1010
1031
  if (currentSize > currentEffectiveLimit) {
1011
1032
  var perFieldFile = (perField && perField[currentField] &&
@@ -1024,16 +1045,20 @@ async function _parseMultipart(req, opts, ctParams) {
1024
1045
  true, 413));
1025
1046
  return;
1026
1047
  }
1027
- try {
1028
- var written = 0;
1029
- while (written < bodyChunk.length) {
1030
- written += nodeFs.writeSync(currentFd, bodyChunk, written, bodyChunk.length - written);
1048
+ if (currentFd !== null) {
1049
+ try {
1050
+ var written = 0;
1051
+ while (written < bodyChunk.length) {
1052
+ written += nodeFs.writeSync(currentFd, bodyChunk, written, bodyChunk.length - written);
1053
+ }
1054
+ } catch (e) {
1055
+ done(new BodyParserError("body-parser/multipart-tmp-write",
1056
+ "multipart tmp write failed: " + ((e && e.message) || String(e)),
1057
+ true, 500));
1058
+ return;
1031
1059
  }
1032
- } catch (e) {
1033
- done(new BodyParserError("body-parser/multipart-tmp-write",
1034
- "multipart tmp write failed: " + ((e && e.message) || String(e)),
1035
- true, 500));
1036
- return;
1060
+ } else {
1061
+ currentBuf.push(bodyChunk);
1037
1062
  }
1038
1063
  currentHash.update(bodyChunk);
1039
1064
  } else {
@@ -1067,17 +1092,27 @@ async function _parseMultipart(req, opts, ctParams) {
1067
1092
  if (currentDiscarded) {
1068
1093
  // fileFilter rejected — already recorded in filesRejected; no
1069
1094
  // tmp file was opened, nothing to clean up here.
1070
- } else if (currentFd !== null) {
1071
- try { nodeFs.closeSync(currentFd); } catch (_e) { /* fd already closed */ }
1072
- currentFd = null;
1073
- files.push({
1095
+ } else if (currentIsFile) {
1096
+ // Stable shape across both modes: disk gets path (buffer null),
1097
+ // memory gets buffer (path null). Operators branch on whichever
1098
+ // is non-null.
1099
+ var fileEntry = {
1074
1100
  field: currentField,
1075
1101
  filename: currentFilename,
1076
1102
  mimeType: currentMime,
1077
- path: currentTmpPath,
1103
+ path: null,
1104
+ buffer: null,
1078
1105
  size: currentSize,
1079
1106
  hash: currentHash.digest("hex"),
1080
- });
1107
+ };
1108
+ if (currentFd !== null) {
1109
+ try { nodeFs.closeSync(currentFd); } catch (_e) { /* fd already closed */ }
1110
+ currentFd = null;
1111
+ fileEntry.path = currentTmpPath;
1112
+ } else {
1113
+ fileEntry.buffer = Buffer.concat(currentBuf);
1114
+ }
1115
+ files.push(fileEntry);
1081
1116
  } else {
1082
1117
  // Field part — flatten + decode UTF-8.
1083
1118
  var fbuf = Buffer.concat(currentBuf);
@@ -1106,6 +1141,7 @@ async function _parseMultipart(req, opts, ctParams) {
1106
1141
  currentSize = 0;
1107
1142
  currentHash = null;
1108
1143
  currentBuf = null;
1144
+ currentIsFile = false;
1109
1145
  currentDiscarded = false;
1110
1146
  currentEffectiveLimit = 0;
1111
1147
  state = MP_AFTER_BD;
@@ -1159,11 +1195,13 @@ async function _parseMultipart(req, opts, ctParams) {
1159
1195
  * sub-parsers ship: JSON (via `safe-json` — POISONED_KEYS stripped,
1160
1196
  * depth + size caps), urlencoded, text, raw octet-stream, and
1161
1197
  * multipart/form-data. Multipart streams file parts to a tmp dir
1162
- * with per-file + total-request size caps, filename sanitization,
1163
- * SHA3-512 hashing during streaming, and tmp-file cleanup on
1164
- * response end. Defends against RFC 9112 §6.1 request smuggling
1165
- * before any body bytes are read. Each sub-parser can be disabled
1166
- * by passing `false` in its slot.
1198
+ * (`storage: "disk"`, default) or buffers them in RAM
1199
+ * (`storage: "memory"` for read-only / serverless filesystems,
1200
+ * exposing `req.files[].buffer` instead of `.path`), with per-file +
1201
+ * total-request size caps, filename sanitization, SHA3-512 hashing
1202
+ * during streaming, and tmp-file cleanup on response end. Defends
1203
+ * against RFC 9112 §6.1 request smuggling before any body bytes are
1204
+ * read. Each sub-parser can be disabled by passing `false` in its slot.
1167
1205
  *
1168
1206
  * @opts
1169
1207
  * {
@@ -1172,8 +1210,8 @@ async function _parseMultipart(req, opts, ctParams) {
1172
1210
  * text: false | { limit, charset, contentTypes },
1173
1211
  * raw: false | { limit, contentTypes },
1174
1212
  * multipart: false | {
1175
- * tmpDir, fileSize, totalSize, fileCount, fieldCount, fieldSize,
1176
- * mimeAllowlist, fileFilter, fields, audit, contentTypes,
1213
+ * storage, tmpDir, fileSize, totalSize, fileCount, fieldCount,
1214
+ * fieldSize, mimeAllowlist, fileFilter, fields, audit, contentTypes,
1177
1215
  * },
1178
1216
  * keepRawBody: boolean, // expose req.bodyRaw for webhook signing
1179
1217
  * }
@@ -1202,6 +1240,11 @@ function create(opts) {
1202
1240
  var textOpts = _resolve("text");
1203
1241
  var rawOpts = _resolve("raw");
1204
1242
  var multipartOpts = _resolve("multipart");
1243
+ if (multipartOpts && multipartOpts.storage !== "disk" && multipartOpts.storage !== "memory") {
1244
+ throw new TypeError(
1245
+ "middleware.bodyParser: multipart.storage must be \"disk\" or \"memory\" (got " +
1246
+ JSON.stringify(multipartOpts.storage) + ")");
1247
+ }
1205
1248
  var keepRawBody = !!opts.keepRawBody;
1206
1249
 
1207
1250
  return async function bodyParser(req, res, next) {
@@ -1255,7 +1298,10 @@ function create(opts) {
1255
1298
  if (cleanedUp) return;
1256
1299
  cleanedUp = true;
1257
1300
  for (var i = 0; i < mpResult.files.length; i++) {
1258
- try { nodeFs.unlinkSync(mpResult.files[i].path); } catch (_e) { /* tmp file already removed */ }
1301
+ // Memory-mode files (storage: "memory") have no path nothing to unlink.
1302
+ if (mpResult.files[i].path) {
1303
+ try { nodeFs.unlinkSync(mpResult.files[i].path); } catch (_e) { /* tmp file already removed */ }
1304
+ }
1259
1305
  }
1260
1306
  }
1261
1307
  res.on("finish", cleanup);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.13.17",
3
+ "version": "0.13.19",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:2a708a4e-31ac-4ac7-abff-7e496d5700db",
5
+ "serialNumber": "urn:uuid:bcf724d0-38ac-437e-86b3-47da5e8b72dc",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-27T19:07:43.710Z",
8
+ "timestamp": "2026-05-27T21:23:48.501Z",
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.13.17",
22
+ "bom-ref": "@blamejs/core@0.13.19",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.13.17",
25
+ "version": "0.13.19",
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.13.17",
29
+ "purl": "pkg:npm/%40blamejs/core@0.13.19",
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.13.17",
57
+ "ref": "@blamejs/core@0.13.19",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]