@blamejs/core 0.9.43 → 0.9.46

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.
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  /**
3
3
  * @module b.middleware.protectedResourceMetadata
4
- * @nav Identity & access
4
+ * @nav Identity
5
5
  * @title Protected Resource Metadata
6
6
  * @order 210
7
7
  * @slug protected-resource-metadata
@@ -107,6 +107,7 @@
107
107
  var C = require("./constants");
108
108
  var https = require("node:https");
109
109
  var nodeCrypto = require("node:crypto");
110
+ var bCrypto = require("./crypto");
110
111
  var { defineClass } = require("./framework-error");
111
112
  var networkDns = require("./network-dns");
112
113
  var safeDns = require("./safe-dns");
@@ -413,7 +414,7 @@ async function _wireLookup(name, qtype) {
413
414
  var url = networkDns._getDohUrlForTest ? networkDns._getDohUrlForTest() : "https://cloudflare-dns.com/dns-query";
414
415
  // Encode a wire-format query for the target qtype.
415
416
  var qbuf = _encodeWireQuery(name, qtype);
416
- var b64 = qbuf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
417
+ var b64 = bCrypto.toBase64Url(qbuf);
417
418
  var getUrl = url + (url.indexOf("?") === -1 ? "?" : "&") + "dns=" + b64;
418
419
  var u = safeUrl.parse(getUrl, { allowedProtocols: safeUrl.ALLOW_HTTP_TLS });
419
420
  return new Promise(function (resolve, reject) {
@@ -8,6 +8,7 @@ var nodeTls = require("node:tls");
8
8
  var dnsPromises = dns.promises;
9
9
 
10
10
  var C = require("./constants");
11
+ var bCrypto = require("./crypto");
11
12
  var lazyRequire = require("./lazy-require");
12
13
  var safeBuffer = require("./safe-buffer");
13
14
  var safeUrl = require("./safe-url");
@@ -368,7 +369,7 @@ var DOH_GET_URL_MAX_BYTES = 2048;
368
369
  async function _dohLookup(host, family) {
369
370
  var qtype = family === 6 ? 28 : 1;
370
371
  var enc = _encodeDnsQuery(host, qtype);
371
- var b64 = enc.buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
372
+ var b64 = bCrypto.toBase64Url(enc.buf);
372
373
  var getUrl = STATE.doh.url + (STATE.doh.url.indexOf("?") === -1 ? "?" : "&") + "dns=" + b64;
373
374
  var forcedMethod = STATE.doh.method;
374
375
  var usePost = forcedMethod === "POST" || (!forcedMethod && getUrl.length > DOH_GET_URL_MAX_BYTES);
@@ -437,7 +438,7 @@ async function _dohLookup(host, family) {
437
438
  async function _dohLookupSecure(host, family) {
438
439
  var qtype = family === 6 ? 28 : 1; // allow:raw-byte-literal — DNS QTYPE values for A / AAAA
439
440
  var enc = _encodeDnsQuery(host, qtype);
440
- var b64 = enc.buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
441
+ var b64 = bCrypto.toBase64Url(enc.buf);
441
442
  var getUrl = STATE.doh.url + (STATE.doh.url.indexOf("?") === -1 ? "?" : "&") + "dns=" + b64;
442
443
  var forcedMethod = STATE.doh.method;
443
444
  var usePost = forcedMethod === "POST" || (!forcedMethod && getUrl.length > DOH_GET_URL_MAX_BYTES);
@@ -792,7 +793,7 @@ function _decodeDnsAnswerRaw(buf) {
792
793
 
793
794
  async function _dohRawQuery(host, qtype) {
794
795
  var enc = _encodeDnsQuery(host, qtype);
795
- var b64 = enc.buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
796
+ var b64 = bCrypto.toBase64Url(enc.buf);
796
797
  var getUrl = STATE.doh.url + (STATE.doh.url.indexOf("?") === -1 ? "?" : "&") + "dns=" + b64;
797
798
  var forcedMethod = STATE.doh.method;
798
799
  var usePost = forcedMethod === "POST" || (!forcedMethod && getUrl.length > DOH_GET_URL_MAX_BYTES);
@@ -25,6 +25,7 @@
25
25
  var nodeFs = require("node:fs");
26
26
  var nodeCrypto = require("node:crypto");
27
27
  var { Readable } = require("node:stream");
28
+ var bCrypto = require("../crypto");
28
29
  var safeJson = require("../safe-json");
29
30
  var C = require("../constants");
30
31
  var numericBounds = require("../numeric-bounds");
@@ -88,12 +89,7 @@ var _httpRequest = sharedRequest;
88
89
 
89
90
  // ---- JWT signing for service-account auth ----
90
91
 
91
- function _base64UrlEncode(buf) {
92
- return Buffer.from(buf).toString("base64")
93
- .replace(/=+$/g, "")
94
- .replace(/\+/g, "-")
95
- .replace(/\//g, "_");
96
- }
92
+ function _base64UrlEncode(buf) { return bCrypto.toBase64Url(buf); }
97
93
 
98
94
  function _signJwt(serviceAccount, scope, audience) {
99
95
  var nowSec = Math.floor(Date.now() / C.TIME.seconds(1));
package/lib/pagination.js CHANGED
@@ -83,16 +83,11 @@ function _toBuf(secret) {
83
83
  "secret must be a Buffer or non-empty string");
84
84
  }
85
85
 
86
- function _b64urlEncode(buf) {
87
- var b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf);
88
- return b.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
89
- }
86
+ function _b64urlEncode(buf) { return bCrypto.toBase64Url(buf); }
90
87
 
91
88
  function _b64urlDecode(s) {
92
89
  if (typeof s !== "string") throw new PaginationError("pagination/bad-cursor", "cursor must be a string");
93
- var pad = s.length % 4;
94
- var padded = pad ? s + "=".repeat(4 - pad) : s;
95
- return Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64");
90
+ return bCrypto.fromBase64Url(s);
96
91
  }
97
92
 
98
93
  function _tag(secretBuf, stateJson) {
@@ -0,0 +1,128 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.safeSmtp
4
+ * @nav Parsers
5
+ * @title Safe SMTP
6
+ * @order 215
7
+ *
8
+ * @intro
9
+ * Wire-protocol parsing helpers for SMTP (RFC 5321) bytes.
10
+ * Operators consuming the framework's MX listener (`b.mail.server.mx`),
11
+ * submission listener (slice that follows), or building their own
12
+ * SMTP-shaped tooling (proxies, log analyzers, test fixtures) reach
13
+ * for these primitives rather than reinventing the dot-terminator
14
+ * scan + dot-stuffing reversal.
15
+ *
16
+ * Separates the "what shape is the wire data" parsing concern from
17
+ * the "is this wire data hostile" guard concern (which lives in
18
+ * `b.guardSmtpCommand`). A safe-* parser primitive returns a
19
+ * bounded shape or `-1`; a guard-* primitive returns a boolean
20
+ * threat verdict or throws a typed error.
21
+ *
22
+ * Wire-protocol references:
23
+ * - RFC 5321 §2.3.8 — line termination MUST be CRLF
24
+ * - RFC 5321 §4.5.2 — dot-stuffing on the SMTP body
25
+ * - RFC 5321 §4.1.1.4 — DATA command terminates with `<CRLF>.<CRLF>`
26
+ * - CVE-2023-51764 / -51765 / -51766 / 2024-32178 — SMTP
27
+ * smuggling (parsers that accept bare-LF dot-terminators).
28
+ * The guard primitive `b.guardSmtpCommand.detectBodySmuggling`
29
+ * owns smuggling detection; the safe-* terminator scanner
30
+ * here is strict CRLF-only by construction.
31
+ *
32
+ * @card
33
+ * Wire-protocol parsing helpers for SMTP (RFC 5321) bytes —
34
+ * findDotTerminator + dotUnstuff. Strict CRLF-only by construction
35
+ * (bare-LF terminators are not honored — the smuggling-detection
36
+ * guard lives in b.guardSmtpCommand.detectBodySmuggling).
37
+ */
38
+
39
+ var { defineClass } = require("./framework-error");
40
+
41
+ var SafeSmtpError = defineClass("SafeSmtpError", { alwaysPermanent: true });
42
+
43
+ /**
44
+ * @primitive b.safeSmtp.findDotTerminator
45
+ * @signature b.safeSmtp.findDotTerminator(buf)
46
+ * @since 0.9.46
47
+ * @status stable
48
+ * @related b.safeSmtp.dotUnstuff, b.guardSmtpCommand.detectBodySmuggling
49
+ *
50
+ * Scan `buf` for the canonical RFC 5321 §4.1.1.4 DATA-body terminator
51
+ * `<CRLF>.<CRLF>` (5 bytes: 0x0d 0x0a 0x2e 0x0d 0x0a). Returns the
52
+ * byte index where the body ends (exclusive — the index of the
53
+ * trailing CRLF the terminator starts on), or `-1` if the terminator
54
+ * is not yet present.
55
+ *
56
+ * Strict CRLF-only by construction — bare-LF alternate terminators
57
+ * are NOT honored. Operators worried about smuggling shape route the
58
+ * SAME body through `b.guardSmtpCommand.detectBodySmuggling` before
59
+ * trusting the terminator index returned here.
60
+ *
61
+ * @example
62
+ * var body = Buffer.from("Hello world.\r\n.\r\n");
63
+ * b.safeSmtp.findDotTerminator(body);
64
+ * // → 13 (index of \r in \r\n.\r\n)
65
+ *
66
+ * b.safeSmtp.findDotTerminator(Buffer.from("incomplete body"));
67
+ * // → -1
68
+ */
69
+ function findDotTerminator(buf) {
70
+ if (!Buffer.isBuffer(buf)) {
71
+ throw new SafeSmtpError("safe-smtp/bad-input",
72
+ "findDotTerminator: input must be a Buffer");
73
+ }
74
+ for (var i = 0; i <= buf.length - 5; i += 1) { // allow:raw-byte-literal — 5-byte CRLF.CRLF terminator length
75
+ if (buf[i] === 0x0d && buf[i + 1] === 0x0a &&
76
+ buf[i + 2] === 0x2e &&
77
+ buf[i + 3] === 0x0d && buf[i + 4] === 0x0a) {
78
+ return i;
79
+ }
80
+ }
81
+ return -1;
82
+ }
83
+
84
+ /**
85
+ * @primitive b.safeSmtp.dotUnstuff
86
+ * @signature b.safeSmtp.dotUnstuff(buf)
87
+ * @since 0.9.46
88
+ * @status stable
89
+ * @related b.safeSmtp.findDotTerminator
90
+ *
91
+ * Reverse RFC 5321 §4.5.2 dot-stuffing on a DATA-body buffer. SMTP
92
+ * senders that need to transmit a body line beginning with `.` MUST
93
+ * prepend an extra `.` (so the line on the wire begins with `..`);
94
+ * the receiver strips the leading `.` from any body line that
95
+ * begins with one before storing the message. Returns a fresh
96
+ * Buffer with the dots reversed; the input is never mutated. Result
97
+ * length is always `<= input length`.
98
+ *
99
+ * @example
100
+ * var wire = Buffer.from("hello\r\n..secret\r\nworld\r\n");
101
+ * b.safeSmtp.dotUnstuff(wire).toString("utf8");
102
+ * // → "hello\r\n.secret\r\nworld\r\n"
103
+ */
104
+ function dotUnstuff(buf) {
105
+ if (!Buffer.isBuffer(buf)) {
106
+ throw new SafeSmtpError("safe-smtp/bad-input",
107
+ "dotUnstuff: input must be a Buffer");
108
+ }
109
+ var out = Buffer.alloc(buf.length);
110
+ var oi = 0;
111
+ for (var i = 0; i < buf.length; i += 1) {
112
+ out[oi++] = buf[i];
113
+ // After \r\n, if the next byte is `.` followed by another non-CR
114
+ // byte (i.e., not the terminator itself), strip the stuffing dot.
115
+ if (i >= 1 && buf[i - 1] === 0x0d && buf[i] === 0x0a &&
116
+ i + 1 < buf.length && buf[i + 1] === 0x2e &&
117
+ i + 2 < buf.length && buf[i + 2] !== 0x0d) {
118
+ i += 1;
119
+ }
120
+ }
121
+ return out.subarray(0, oi);
122
+ }
123
+
124
+ module.exports = {
125
+ findDotTerminator: findDotTerminator,
126
+ dotUnstuff: dotUnstuff,
127
+ SafeSmtpError: SafeSmtpError,
128
+ };
package/lib/storage.js CHANGED
@@ -41,6 +41,8 @@ var C = require("./constants");
41
41
  var { generateBytes, encryptPacked, decryptPacked } = require("./crypto");
42
42
  var objectStore = require("./object-store");
43
43
  var lazyRequire = require("./lazy-require");
44
+ var numericBounds = require("./numeric-bounds");
45
+ var canonicalJson = require("./canonical-json");
44
46
  var { StorageError } = require("./framework-error");
45
47
 
46
48
  var vault = lazyRequire(function () { return require("./vault"); });
@@ -827,6 +829,420 @@ function _requireInit() {
827
829
  if (!initialized) throw _err("NOT_INITIALIZED", "storage.init() must be called before any file operation", true);
828
830
  }
829
831
 
832
+ // ---- chunk-scratch -------------------------------------------------
833
+ //
834
+ // Resumable-chunked-upload primitive. Operators handling large file
835
+ // uploads (multipart form / tus / S3-multipart-style flow) need to
836
+ // persist incoming chunks during the upload window, then assemble
837
+ // them into a final file when the upload completes. Without a
838
+ // framework primitive every consumer ended up reinventing the
839
+ // per-assembly directory layout + atomic finalize + GC of partial
840
+ // assemblies that never completed.
841
+ //
842
+ // chunkScratch owns:
843
+ // - per-assembly directory layout (sealed in the operator's
844
+ // storage backend just like saveFile)
845
+ // - chunk persistence + retrieval with the framework envelope
846
+ // - assembly metadata tracking createdAt/totalChunks/chunkHashes
847
+ // - atomic concat into the final file (no consumer ever sees a
848
+ // half-assembled file)
849
+ // - GC of stale partial assemblies (operator opts in via gc())
850
+ //
851
+ // Backend is the same `b.storage` backend the operator already
852
+ // configured — chunkScratch routes through it. No new backend
853
+ // concept. The chunk keys are namespaced under
854
+ // `<rootKeyPrefix>/<assemblyId>/<chunkIndex>` so the operator can
855
+ // see them via the backend's existing list/inspect surface.
856
+ //
857
+ // assemblyId is operator-supplied (typically a UUID tied to the
858
+ // upload session). Shape is validated to refuse path-traversal,
859
+ // slash/backslash, NUL/C0/DEL, oversize. The chunkScratch primitive
860
+ // is identity-agnostic — it doesn't know which user owns which
861
+ // assembly; that gate is the operator's surrounding handler.
862
+
863
+ var ASSEMBLY_ID_MAX_LEN = 128;
864
+ var CHUNK_INDEX_MAX = 100000; // allow:raw-byte-literal — chunk-index cap (not bytes, not seconds)
865
+ var CHUNK_BYTES_DEFAULT = C.BYTES.mib(16);
866
+ var STALE_DEFAULT_MS = C.TIME.hours(24);
867
+
868
+ function _stripTrailingSlashes(s) {
869
+ // Linear-time alternative to `.replace(/\/+$/, "")` — CodeQL flags the
870
+ // regex form as polynomial-ReDoS-vulnerable on inputs with many
871
+ // trailing slashes (theoretical here since rootKeyPrefix is operator-
872
+ // supplied at create-time, not request-bound, but using the explicit
873
+ // loop avoids the regex-engine backtracking surface entirely).
874
+ var end = s.length;
875
+ while (end > 0 && s.charCodeAt(end - 1) === 0x2F /* / */) end -= 1;
876
+ return end === s.length ? s : s.slice(0, end);
877
+ }
878
+
879
+ function _validateAssemblyId(id) {
880
+ if (typeof id !== "string" || id.length === 0) {
881
+ throw _err("INVALID_ARGUMENT", "chunkScratch: assemblyId must be a non-empty string", true);
882
+ }
883
+ if (id.length > ASSEMBLY_ID_MAX_LEN) {
884
+ throw _err("INVALID_ARGUMENT",
885
+ "chunkScratch: assemblyId exceeds " + ASSEMBLY_ID_MAX_LEN + "-char cap", true);
886
+ }
887
+ for (var i = 0; i < id.length; i += 1) {
888
+ var c = id.charCodeAt(i);
889
+ // Refuse: C0 (0x00-0x1F), DEL (0x7F), slash, backslash, dot-prefix
890
+ if (c < 0x20 || c === 0x2F || c === 0x5C || c === 0x7F) {
891
+ throw _err("INVALID_ARGUMENT",
892
+ "chunkScratch: assemblyId carries forbidden character at byte " + i, true);
893
+ }
894
+ }
895
+ // Refuse path-traversal shapes — operator-supplied ID should be a
896
+ // UUID-shape or opaque session token, not a path.
897
+ if (id.indexOf("..") !== -1 || id.charAt(0) === ".") {
898
+ throw _err("INVALID_ARGUMENT",
899
+ "chunkScratch: assemblyId carries path-traversal shape", true);
900
+ }
901
+ }
902
+
903
+ function _validateChunkIndex(idx) {
904
+ if (typeof idx !== "number" || !Number.isInteger(idx) || idx < 0) {
905
+ throw _err("INVALID_ARGUMENT",
906
+ "chunkScratch: chunkIndex must be a non-negative integer", true);
907
+ }
908
+ if (idx >= CHUNK_INDEX_MAX) {
909
+ throw _err("INVALID_ARGUMENT",
910
+ "chunkScratch: chunkIndex exceeds cap " + CHUNK_INDEX_MAX, true);
911
+ }
912
+ }
913
+
914
+ /**
915
+ * @primitive b.storage.chunkScratch
916
+ * @signature b.storage.chunkScratch(opts?)
917
+ * @since 0.9.44
918
+ * @status stable
919
+ * @related b.storage.saveFile, b.storage.getFileBuffer
920
+ *
921
+ * Resumable-chunked-upload primitive. Persists incoming upload chunks
922
+ * during the upload window + atomically assembles them into the
923
+ * final file on completion. Owns per-assembly directory layout,
924
+ * envelope-encrypted chunk persistence, atomic finalize, and GC of
925
+ * partial assemblies.
926
+ *
927
+ * Composes existing primitives: each chunk routes through
928
+ * `b.storage.saveFile` (same XChaCha20-Poly1305 envelope as the
929
+ * non-chunked surface), assembly reads through `getFileBuffer`,
930
+ * deletion through `deleteFile`. No new crypto.
931
+ *
932
+ * Prior art / wire-protocol references:
933
+ * - tus.io v1.0.0 protocol (Termination + Creation + Concatenation
934
+ * extensions) — operator-facing HTTP shape that ships chunks
935
+ * against a server-side assembly. This primitive is the
936
+ * server-side persistence the tus protocol's upload handler
937
+ * consumes.
938
+ * - RFC 9110 §14.4 Content-Range — the wire-protocol header that
939
+ * PUT/PATCH-based resumable uploads use to declare each chunk's
940
+ * byte-range within the assembly.
941
+ * - draft-ietf-httpbis-resumable-upload-08 — IETF working-draft
942
+ * resumable-upload protocol; this primitive's surface mirrors
943
+ * its server-side state requirements.
944
+ * - AWS S3 Multipart Upload — the cloud-vendor analogue;
945
+ * `saveChunk` / `assemble` are the framework's local equivalents
946
+ * of UploadPart / CompleteMultipartUpload.
947
+ *
948
+ * Threat-model coverage:
949
+ * - Path-traversal in upload paths (CVE-2018-1000656 class) —
950
+ * `assemblyId` is validated to refuse `..`, `/`, `\`, NUL / C0
951
+ * controls, DEL, dot-prefix, and oversize. A hostile client
952
+ * can't escape the rootKeyPrefix namespace.
953
+ * - Chunk-out-of-order replay / TOCTOU between saveChunk and
954
+ * assemble — `assemble` verifies monotonic 0..N-1 indices and
955
+ * refuses on gaps; a chunk inserted out-of-order can't be
956
+ * surfaced as a valid assembly.
957
+ * - Storage exhaustion from abandoned uploads — `gc({ olderThanMs })`
958
+ * prunes stale assemblies; operator wires it on a schedule.
959
+ * - AEAD context-binding — each chunk's encryption envelope is
960
+ * keyed independently; an attacker who guesses one chunk's key
961
+ * can't decrypt other chunks in the same assembly (the
962
+ * XChaCha20-Poly1305 keys are framework-vault-derived per-call).
963
+ *
964
+ * assemblyId shape is validated to refuse path-traversal, control
965
+ * chars, and oversize at every entry point.
966
+ *
967
+ * @opts
968
+ * rootKeyPrefix: string, // default "chunk-scratch" — namespace under the backend
969
+ * backend: string, // explicit backend by name (default: framework default)
970
+ * maxChunkBytes: number, // default 16 MiB — per-chunk cap
971
+ * staleAfterMs: number, // default 24h — assemblies idle longer get GC'd
972
+ *
973
+ * @example
974
+ * b.storage.init({ backend: "local", uploadDir: "./data/uploads" });
975
+ * var cs = b.storage.chunkScratch({ rootKeyPrefix: "uploads/scratch" });
976
+ *
977
+ * // During upload — each PUT lands one chunk
978
+ * await cs.saveChunk({ assemblyId: "upload-abc", chunkIndex: 0, data: chunk0 });
979
+ * await cs.saveChunk({ assemblyId: "upload-abc", chunkIndex: 1, data: chunk1 });
980
+ * await cs.saveChunk({ assemblyId: "upload-abc", chunkIndex: 2, data: chunk2 });
981
+ *
982
+ * // On completion — atomic assemble + cleanup
983
+ * var assembled = await cs.assemble({ assemblyId: "upload-abc", expectedTotal: 3 });
984
+ * await cs.removeAssembly("upload-abc");
985
+ *
986
+ * // Periodic GC of partial uploads abandoned mid-stream
987
+ * var removed = await cs.gc({ olderThanMs: 86400000 });
988
+ */
989
+ function chunkScratch(opts) {
990
+ _requireInit();
991
+ opts = opts || {};
992
+ var rootKeyPrefix = typeof opts.rootKeyPrefix === "string" && opts.rootKeyPrefix.length > 0
993
+ ? _stripTrailingSlashes(opts.rootKeyPrefix)
994
+ : "chunk-scratch";
995
+ numericBounds.requirePositiveFiniteIntIfPresent(
996
+ opts.maxChunkBytes, "chunkScratch.maxChunkBytes", StorageError, "INVALID_ARGUMENT");
997
+ numericBounds.requirePositiveFiniteIntIfPresent(
998
+ opts.staleAfterMs, "chunkScratch.staleAfterMs", StorageError, "INVALID_ARGUMENT");
999
+ var maxChunkBytes = opts.maxChunkBytes !== undefined ? opts.maxChunkBytes : CHUNK_BYTES_DEFAULT;
1000
+ var staleAfterMs = opts.staleAfterMs !== undefined ? opts.staleAfterMs : STALE_DEFAULT_MS;
1001
+ var backendOverride = opts.backend;
1002
+
1003
+ function _chunkKey(assemblyId, chunkIndex) {
1004
+ return rootKeyPrefix + "/" + assemblyId + "/" + String(chunkIndex).padStart(8, "0") + ".chunk"; // allow:raw-byte-literal — 8-digit zero-pad covers CHUNK_INDEX_MAX
1005
+ }
1006
+ function _pickOpts() {
1007
+ return backendOverride ? { backend: backendOverride } : {};
1008
+ }
1009
+
1010
+ async function saveChunk(args) {
1011
+ if (!args || typeof args !== "object") {
1012
+ throw _err("INVALID_ARGUMENT", "chunkScratch.saveChunk: args must be an object", true);
1013
+ }
1014
+ _validateAssemblyId(args.assemblyId);
1015
+ _validateChunkIndex(args.chunkIndex);
1016
+ if (!Buffer.isBuffer(args.data)) {
1017
+ throw _err("INVALID_ARGUMENT", "chunkScratch.saveChunk: data must be a Buffer", true);
1018
+ }
1019
+ if (args.data.length > maxChunkBytes) {
1020
+ throw _err("INVALID_ARGUMENT",
1021
+ "chunkScratch.saveChunk: chunk exceeds maxChunkBytes (" + args.data.length + " > " + maxChunkBytes + ")", true);
1022
+ }
1023
+ var saved = await saveFile(args.data, _chunkKey(args.assemblyId, args.chunkIndex), _pickOpts());
1024
+ _emit("system.storage.chunk_scratch.chunk_saved", {
1025
+ metadata: {
1026
+ assemblyId: args.assemblyId,
1027
+ chunkIndex: args.chunkIndex,
1028
+ sizeBytes: args.data.length,
1029
+ backend: saved.backend,
1030
+ },
1031
+ });
1032
+ return { encryptionKey: saved.encryptionKey, sizeBytes: args.data.length };
1033
+ }
1034
+
1035
+ async function getChunk(args) {
1036
+ if (!args || typeof args !== "object") {
1037
+ throw _err("INVALID_ARGUMENT", "chunkScratch.getChunk: args must be an object", true);
1038
+ }
1039
+ _validateAssemblyId(args.assemblyId);
1040
+ _validateChunkIndex(args.chunkIndex);
1041
+ if (typeof args.encryptionKey !== "string" || args.encryptionKey.length === 0) {
1042
+ throw _err("INVALID_ARGUMENT", "chunkScratch.getChunk: encryptionKey required", true);
1043
+ }
1044
+ return getFileBuffer(_chunkKey(args.assemblyId, args.chunkIndex),
1045
+ args.encryptionKey, _pickOpts());
1046
+ }
1047
+
1048
+ async function chunkExists(args) {
1049
+ _validateAssemblyId(args.assemblyId);
1050
+ _validateChunkIndex(args.chunkIndex);
1051
+ return exists(_chunkKey(args.assemblyId, args.chunkIndex), _pickOpts());
1052
+ }
1053
+
1054
+ async function listChunks(assemblyId) {
1055
+ _validateAssemblyId(assemblyId);
1056
+ var picked = _pickBackend(_pickOpts());
1057
+ if (typeof picked.backend.list !== "function") {
1058
+ throw _err("UNSUPPORTED",
1059
+ "chunkScratch.listChunks: backend '" + picked.backend.name + "' does not implement list()", true);
1060
+ }
1061
+ var prefix = rootKeyPrefix + "/" + assemblyId + "/";
1062
+ var listRes = await picked.backend.list(prefix);
1063
+ var items = listRes && Array.isArray(listRes.items) ? listRes.items
1064
+ : Array.isArray(listRes) ? listRes : [];
1065
+ var indices = [];
1066
+ for (var i = 0; i < items.length; i += 1) {
1067
+ // Backends return either { key, size, lastModified } objects
1068
+ // (local + S3 + GCS) or bare key strings. Normalize.
1069
+ var item = items[i];
1070
+ var rawKey = typeof item === "string" ? item : item && item.key;
1071
+ if (typeof rawKey !== "string") continue;
1072
+ // The local backend's `list(prefix)` returns keys relative to
1073
+ // the prefix; cloud backends return absolute keys. Normalize
1074
+ // by stripping the prefix when present.
1075
+ var base = rawKey.indexOf(prefix) === 0 ? rawKey.slice(prefix.length) : rawKey;
1076
+ if (base === ".meta" || base.indexOf("/") !== -1) continue;
1077
+ if (!/^[0-9]{1,8}\.chunk$/.test(base)) continue;
1078
+ indices.push(parseInt(base.slice(0, -6), 10));
1079
+ }
1080
+ indices.sort(function (a, b) { return a - b; });
1081
+ return indices;
1082
+ }
1083
+
1084
+ async function countChunks(assemblyId) {
1085
+ var indices = await listChunks(assemblyId);
1086
+ return indices.length;
1087
+ }
1088
+
1089
+ async function removeChunk(args) {
1090
+ _validateAssemblyId(args.assemblyId);
1091
+ _validateChunkIndex(args.chunkIndex);
1092
+ return deleteFile(_chunkKey(args.assemblyId, args.chunkIndex), _pickOpts());
1093
+ }
1094
+
1095
+ async function assemble(args) {
1096
+ if (!args || typeof args !== "object") {
1097
+ throw _err("INVALID_ARGUMENT", "chunkScratch.assemble: args must be an object", true);
1098
+ }
1099
+ _validateAssemblyId(args.assemblyId);
1100
+ if (!Array.isArray(args.chunkEncryptionKeys) || args.chunkEncryptionKeys.length === 0) {
1101
+ throw _err("INVALID_ARGUMENT",
1102
+ "chunkScratch.assemble: chunkEncryptionKeys must be a non-empty array (one per chunk in order)", true);
1103
+ }
1104
+ var indices = await listChunks(args.assemblyId);
1105
+ if (typeof args.expectedTotal === "number" && indices.length !== args.expectedTotal) {
1106
+ throw _err("INCOMPLETE_ASSEMBLY",
1107
+ "chunkScratch.assemble: have " + indices.length + " chunks; expected " + args.expectedTotal, true);
1108
+ }
1109
+ if (indices.length !== args.chunkEncryptionKeys.length) {
1110
+ throw _err("INVALID_ARGUMENT",
1111
+ "chunkScratch.assemble: chunkEncryptionKeys.length (" + args.chunkEncryptionKeys.length +
1112
+ ") must match chunk count (" + indices.length + ")", true);
1113
+ }
1114
+ // Verify monotonic 0..N-1 indices — no gaps.
1115
+ for (var i = 0; i < indices.length; i += 1) {
1116
+ if (indices[i] !== i) {
1117
+ throw _err("INCOMPLETE_ASSEMBLY",
1118
+ "chunkScratch.assemble: chunk gap at index " + i + " (found " + indices[i] + ")", true);
1119
+ }
1120
+ }
1121
+ // Concatenate in order. Each chunk decrypts via its own envelope
1122
+ // key; the operator persisted the per-chunk key when saveChunk
1123
+ // returned it.
1124
+ var parts = [];
1125
+ var totalBytes = 0;
1126
+ for (var c = 0; c < indices.length; c += 1) {
1127
+ var buf = await getChunk({
1128
+ assemblyId: args.assemblyId,
1129
+ chunkIndex: c,
1130
+ encryptionKey: args.chunkEncryptionKeys[c],
1131
+ });
1132
+ parts.push(buf);
1133
+ totalBytes += buf.length;
1134
+ }
1135
+ _emit("system.storage.chunk_scratch.assembled", {
1136
+ metadata: {
1137
+ assemblyId: args.assemblyId,
1138
+ chunkCount: indices.length,
1139
+ sizeBytes: totalBytes,
1140
+ },
1141
+ });
1142
+ return Buffer.concat(parts, totalBytes);
1143
+ }
1144
+
1145
+ async function removeAssembly(assemblyId) {
1146
+ _validateAssemblyId(assemblyId);
1147
+ var indices = await listChunks(assemblyId);
1148
+ var removed = 0;
1149
+ for (var i = 0; i < indices.length; i += 1) {
1150
+ try { await removeChunk({ assemblyId: assemblyId, chunkIndex: indices[i] }); removed += 1; }
1151
+ catch (_e) { /* best-effort */ }
1152
+ }
1153
+ _emit("system.storage.chunk_scratch.removed", {
1154
+ metadata: { assemblyId: assemblyId, chunksRemoved: removed },
1155
+ });
1156
+ return { chunksRemoved: removed };
1157
+ }
1158
+
1159
+ async function listAssemblies() {
1160
+ var picked = _pickBackend(_pickOpts());
1161
+ if (typeof picked.backend.list !== "function") {
1162
+ throw _err("UNSUPPORTED",
1163
+ "chunkScratch.listAssemblies: backend '" + picked.backend.name + "' does not implement list()", true);
1164
+ }
1165
+ var listRes = await picked.backend.list(rootKeyPrefix + "/");
1166
+ var items = listRes && Array.isArray(listRes.items) ? listRes.items
1167
+ : Array.isArray(listRes) ? listRes : [];
1168
+ var ids = {};
1169
+ var prefixWithSlash = rootKeyPrefix + "/";
1170
+ for (var i = 0; i < items.length; i += 1) {
1171
+ var item = items[i];
1172
+ var rawKey = typeof item === "string" ? item : item && item.key;
1173
+ if (typeof rawKey !== "string") continue;
1174
+ var rel = rawKey.indexOf(prefixWithSlash) === 0
1175
+ ? rawKey.slice(prefixWithSlash.length) : rawKey;
1176
+ var slash = rel.indexOf("/");
1177
+ if (slash === -1) continue;
1178
+ ids[rel.slice(0, slash)] = true;
1179
+ }
1180
+ return canonicalJson.sortKeys(ids);
1181
+ }
1182
+
1183
+ async function listStaleAssemblies(args) {
1184
+ args = args || {};
1185
+ var olderThan = (typeof args.olderThanMs === "number" && args.olderThanMs > 0)
1186
+ ? args.olderThanMs : staleAfterMs;
1187
+ var cutoff = Date.now() - olderThan;
1188
+ var picked = _pickBackend(_pickOpts());
1189
+ if (typeof picked.backend.list !== "function") {
1190
+ throw _err("UNSUPPORTED",
1191
+ "chunkScratch.listStaleAssemblies: backend does not implement list()", true);
1192
+ }
1193
+ var assemblies = await listAssemblies();
1194
+ var stale = [];
1195
+ for (var i = 0; i < assemblies.length; i += 1) {
1196
+ var assemblyId = assemblies[i];
1197
+ // Use the earliest chunk's mtime as the assembly's createdAt
1198
+ // proxy. Backends that surface mtime via list() inspect items;
1199
+ // others fall through to a stat probe on the first chunk.
1200
+ var indices = await listChunks(assemblyId);
1201
+ if (indices.length === 0) { stale.push(assemblyId); continue; }
1202
+ var firstKey = _chunkKey(assemblyId, indices[0]);
1203
+ var stat = null;
1204
+ if (typeof picked.backend.stat === "function") {
1205
+ try { stat = await picked.backend.stat(firstKey); } catch (_e) { stat = null; }
1206
+ }
1207
+ var mtime = stat && (stat.mtimeMs || (stat.mtime && stat.mtime.getTime && stat.mtime.getTime()));
1208
+ if (typeof mtime === "number" && mtime < cutoff) {
1209
+ stale.push(assemblyId);
1210
+ }
1211
+ }
1212
+ return stale;
1213
+ }
1214
+
1215
+ async function gc(args) {
1216
+ args = args || {};
1217
+ var stale = await listStaleAssemblies({ olderThanMs: args.olderThanMs });
1218
+ var removed = [];
1219
+ for (var i = 0; i < stale.length; i += 1) {
1220
+ try {
1221
+ var r = await removeAssembly(stale[i]);
1222
+ removed.push({ assemblyId: stale[i], chunksRemoved: r.chunksRemoved });
1223
+ } catch (_e) { /* best-effort GC */ }
1224
+ }
1225
+ _emit("system.storage.chunk_scratch.gc", {
1226
+ metadata: { staleCount: stale.length, removedCount: removed.length },
1227
+ });
1228
+ return { removed: removed };
1229
+ }
1230
+
1231
+ return {
1232
+ saveChunk: saveChunk,
1233
+ getChunk: getChunk,
1234
+ chunkExists: chunkExists,
1235
+ listChunks: listChunks,
1236
+ countChunks: countChunks,
1237
+ removeChunk: removeChunk,
1238
+ assemble: assemble,
1239
+ removeAssembly: removeAssembly,
1240
+ listAssemblies: listAssemblies,
1241
+ listStaleAssemblies: listStaleAssemblies,
1242
+ gc: gc,
1243
+ };
1244
+ }
1245
+
830
1246
  function _resetForTest() {
831
1247
  initialized = false;
832
1248
  backends = {};
@@ -851,5 +1267,6 @@ module.exports = {
851
1267
  presignedUploadPolicy: presignedUploadPolicy,
852
1268
  listBackends: listBackends,
853
1269
  getBackend: getBackend,
1270
+ chunkScratch: chunkScratch,
854
1271
  _resetForTest: _resetForTest,
855
1272
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.9.43",
3
+ "version": "0.9.46",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",