@blamejs/core 0.7.1 → 0.7.18

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.
Files changed (180) hide show
  1. package/CHANGELOG.md +423 -389
  2. package/README.md +150 -149
  3. package/bin/blamejs.js +0 -0
  4. package/index.js +308 -282
  5. package/lib/api-key.js +660 -672
  6. package/lib/api-snapshot.js +338 -338
  7. package/lib/app-shutdown.js +385 -385
  8. package/lib/app.js +365 -365
  9. package/lib/archive.js +250 -250
  10. package/lib/atomic-file.js +544 -544
  11. package/lib/audit-chain.js +177 -177
  12. package/lib/audit-sign.js +344 -344
  13. package/lib/audit-tools.js +677 -677
  14. package/lib/audit.js +766 -766
  15. package/lib/auth/jwt.js +311 -311
  16. package/lib/auth/lockout.js +436 -436
  17. package/lib/auth/oauth.js +721 -721
  18. package/lib/auth/passkey.js +181 -181
  19. package/lib/auth/password.js +594 -594
  20. package/lib/backup/bundle.js +217 -217
  21. package/lib/backup/crypto.js +176 -176
  22. package/lib/backup/index.js +515 -515
  23. package/lib/backup/manifest.js +282 -282
  24. package/lib/break-glass.js +1338 -1338
  25. package/lib/bundler.js +441 -441
  26. package/lib/cache-redis.js +256 -266
  27. package/lib/cache.js +1206 -1211
  28. package/lib/canonical-json.js +115 -115
  29. package/lib/chain-writer.js +234 -234
  30. package/lib/cli-helpers.js +206 -206
  31. package/lib/cli.js +2334 -2334
  32. package/lib/cluster-provider-db.js +317 -317
  33. package/lib/cluster-storage.js +226 -226
  34. package/lib/cluster.js +703 -703
  35. package/lib/codepoint-class.js +196 -0
  36. package/lib/config-drift.js +301 -301
  37. package/lib/consent.js +222 -222
  38. package/lib/constants.js +191 -191
  39. package/lib/cookies.js +315 -315
  40. package/lib/credential-hash.js +322 -322
  41. package/lib/crypto.js +266 -266
  42. package/lib/csv.js +275 -286
  43. package/lib/db-declare-row-policy.js +267 -267
  44. package/lib/db-declare-view.js +420 -421
  45. package/lib/db-query.js +406 -406
  46. package/lib/db-schema.js +319 -319
  47. package/lib/db.js +1288 -1288
  48. package/lib/deprecate.js +222 -222
  49. package/lib/dev.js +335 -335
  50. package/lib/dual-control.js +473 -473
  51. package/lib/error-page.js +420 -420
  52. package/lib/external-db-migrate.js +441 -441
  53. package/lib/external-db.js +1061 -1061
  54. package/lib/file-type.js +273 -273
  55. package/lib/file-upload.js +1136 -0
  56. package/lib/forms.js +422 -422
  57. package/lib/framework-error.js +293 -202
  58. package/lib/framework-schema.js +717 -717
  59. package/lib/gate-contract.js +971 -0
  60. package/lib/guard-all.js +405 -0
  61. package/lib/guard-archive.js +739 -0
  62. package/lib/guard-csv.js +816 -0
  63. package/lib/guard-email.js +744 -0
  64. package/lib/guard-filename.js +724 -0
  65. package/lib/guard-html.js +976 -0
  66. package/lib/guard-json.js +729 -0
  67. package/lib/guard-markdown.js +586 -0
  68. package/lib/guard-svg.js +976 -0
  69. package/lib/guard-xml.js +405 -0
  70. package/lib/guard-yaml.js +529 -0
  71. package/lib/handlers.js +350 -350
  72. package/lib/http-client-cookie-jar.js +508 -508
  73. package/lib/http-client.js +1195 -1195
  74. package/lib/i18n.js +878 -878
  75. package/lib/jobs.js +185 -185
  76. package/lib/log-stream-cloudwatch.js +369 -369
  77. package/lib/log-stream-local.js +146 -146
  78. package/lib/log-stream-otlp-grpc.js +410 -410
  79. package/lib/log-stream-otlp.js +286 -286
  80. package/lib/log-stream-syslog.js +302 -302
  81. package/lib/log-stream-webhook.js +199 -199
  82. package/lib/log-stream.js +330 -330
  83. package/lib/log.js +500 -500
  84. package/lib/mail-bounce.js +528 -528
  85. package/lib/mail-dkim.js +369 -362
  86. package/lib/mail.js +981 -962
  87. package/lib/metrics.js +683 -683
  88. package/lib/middleware/api-encrypt.js +936 -573
  89. package/lib/middleware/attach-user.js +157 -157
  90. package/lib/middleware/body-parser.js +1170 -1091
  91. package/lib/middleware/bot-guard.js +178 -178
  92. package/lib/middleware/compression.js +452 -452
  93. package/lib/middleware/cors.js +314 -314
  94. package/lib/middleware/csp-nonce.js +348 -348
  95. package/lib/middleware/csrf-protect.js +316 -316
  96. package/lib/middleware/db-role-for.js +264 -269
  97. package/lib/middleware/health.js +392 -392
  98. package/lib/middleware/index.js +79 -79
  99. package/lib/middleware/rate-limit.js +358 -358
  100. package/lib/middleware/request-id.js +61 -61
  101. package/lib/middleware/request-log.js +168 -168
  102. package/lib/middleware/require-auth.js +104 -104
  103. package/lib/middleware/security-headers.js +116 -116
  104. package/lib/middleware/sse.js +166 -166
  105. package/lib/migrations.js +383 -383
  106. package/lib/mtls-ca.js +518 -518
  107. package/lib/mtls-engine-default.js +481 -481
  108. package/lib/network-dns.js +632 -632
  109. package/lib/network-heartbeat.js +290 -290
  110. package/lib/network-nts.js +574 -574
  111. package/lib/network-proxy.js +265 -265
  112. package/lib/network-tls.js +328 -328
  113. package/lib/network.js +233 -233
  114. package/lib/notify.js +612 -614
  115. package/lib/ntp-check.js +229 -229
  116. package/lib/numeric-bounds.js +111 -91
  117. package/lib/object-store/azure-blob-bucket-ops.js +349 -349
  118. package/lib/object-store/azure-blob.js +488 -451
  119. package/lib/object-store/gcs-bucket-ops.js +351 -351
  120. package/lib/object-store/gcs.js +519 -479
  121. package/lib/object-store/http-put.js +153 -153
  122. package/lib/object-store/index.js +197 -197
  123. package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
  124. package/lib/object-store/sigv4.js +903 -855
  125. package/lib/observability.js +151 -151
  126. package/lib/otel-export.js +269 -269
  127. package/lib/pagination.js +464 -464
  128. package/lib/parsers/index.js +80 -80
  129. package/lib/parsers/safe-env.js +642 -642
  130. package/lib/parsers/safe-ini.js +292 -292
  131. package/lib/parsers/safe-toml.js +784 -784
  132. package/lib/parsers/safe-xml.js +390 -390
  133. package/lib/parsers/safe-yaml.js +1015 -1015
  134. package/lib/permissions.js +708 -708
  135. package/lib/pqc-agent.js +87 -87
  136. package/lib/pqc-gate.js +279 -279
  137. package/lib/protobuf-encoder.js +190 -190
  138. package/lib/protocol-dispatcher.js +161 -161
  139. package/lib/pubsub-redis.js +167 -177
  140. package/lib/pubsub.js +429 -429
  141. package/lib/queue-local.js +476 -476
  142. package/lib/queue-redis.js +745 -752
  143. package/lib/queue-sqs.js +319 -319
  144. package/lib/queue.js +695 -695
  145. package/lib/redis-client.js +519 -489
  146. package/lib/request-helpers.js +340 -336
  147. package/lib/restore-bundle.js +237 -237
  148. package/lib/restore-rollback.js +259 -259
  149. package/lib/restore.js +409 -409
  150. package/lib/retry.js +376 -376
  151. package/lib/router.js +748 -748
  152. package/lib/safe-async.js +735 -735
  153. package/lib/safe-buffer.js +237 -237
  154. package/lib/safe-json.js +541 -541
  155. package/lib/safe-schema.js +1266 -1266
  156. package/lib/safe-url.js +159 -159
  157. package/lib/scheduler.js +706 -706
  158. package/lib/security-assert.js +373 -373
  159. package/lib/seeders.js +618 -630
  160. package/lib/session.js +478 -478
  161. package/lib/slug.js +269 -269
  162. package/lib/ssrf-guard.js +401 -401
  163. package/lib/static.js +879 -114
  164. package/lib/storage.js +471 -471
  165. package/lib/subject.js +281 -281
  166. package/lib/template.js +791 -791
  167. package/lib/testing.js +798 -798
  168. package/lib/time.js +310 -310
  169. package/lib/totp.js +302 -302
  170. package/lib/tracing.js +494 -494
  171. package/lib/uuid.js +132 -132
  172. package/lib/validate-opts.js +340 -270
  173. package/lib/vault/index.js +308 -308
  174. package/lib/vault/rotate.js +784 -784
  175. package/lib/vault/wrap.js +296 -296
  176. package/lib/vendor/noble-ciphers.cjs +9 -9
  177. package/lib/webhook.js +595 -598
  178. package/lib/websocket.js +1048 -1048
  179. package/package.json +77 -77
  180. package/sbom.cyclonedx.json +7 -7
@@ -0,0 +1,1136 @@
1
+ "use strict";
2
+ /**
3
+ * b.fileUpload — chunked file upload primitive.
4
+ *
5
+ * var uploads = b.fileUpload.create({
6
+ * stagingDir: "/var/lib/myapp/uploads",
7
+ * maxFileBytes: C.BYTES.gib(2),
8
+ * maxChunkBytes: C.BYTES.mib(8),
9
+ * maxStreamReassemblyBytes: C.BYTES.mib(64), // > this → stream onFinalize
10
+ * maxStagingBytes: C.BYTES.gib(50),
11
+ * maxActiveUploadsPerActor: 5,
12
+ * maxChunks: 16384,
13
+ * incompleteTtlMs: C.TIME.hours(24),
14
+ * maxIdleMs: C.TIME.minutes(30),
15
+ * allowedFileTypes: ["image/jpeg", "image/png", "application/pdf"],
16
+ * audit: b.audit,
17
+ * observability: b.observability,
18
+ * permissions: b.permissions, // optional
19
+ * fileType: b.fileType, // optional — needed for allowedFileTypes
20
+ * onChunk: async function (info) { ... }, // optional per-chunk hook
21
+ * onFinalize: async function (info) { ... }, // operator decides final storage
22
+ * });
23
+ *
24
+ * // Lifecycle:
25
+ * var initRv = await uploads.init({ uploadId, metadata, actor });
26
+ * await uploads.acceptChunk({ uploadId, index, body, sha3, actor });
27
+ * var rv = await uploads.finalize({ uploadId, manifest, actor });
28
+ *
29
+ * // Operator dashboards:
30
+ * var st = uploads.status(uploadId, { actor }); // { received, totalBytesAccepted, createdAt, ... }
31
+ * var active = uploads.list({ actor }); // active uploads for this actor
32
+ * await uploads.cancelUpload(uploadId, { actor }); // operator-cancel
33
+ *
34
+ * // Periodic cleanup (wire to b.scheduler):
35
+ * await uploads.purgeIncomplete(); // → { purged, ids }
36
+ *
37
+ * Surface (returned by create):
38
+ *
39
+ * init(opts) → { uploadId, expiresAt, ... }
40
+ * Allocates staging dir, stores metadata + actor +
41
+ * createdAt. Required before acceptChunk. Permission-
42
+ * checked: action "fileUpload.init".
43
+ *
44
+ * acceptChunk(opts) → { received, totalBytesAccepted, status }
45
+ * Validates body length + per-chunk SHA3-512.
46
+ * Per-chunk hook (onChunk) runs before write.
47
+ * Permission-checked: action "fileUpload.accept".
48
+ * Idempotent on re-PUT of same (uploadId, index)
49
+ * with matching body.
50
+ *
51
+ * finalize(opts) → result of onFinalize (or framework default)
52
+ * Walks chunks in manifest order, verifies per-chunk
53
+ * + total SHA3-512, sniffs MIME (when fileType
54
+ * wired) and gates against allowedFileTypes, hands
55
+ * assembled buffer (or readable stream when size >
56
+ * maxStreamReassemblyBytes) to onFinalize. Removes
57
+ * staging dir on success. Permission-checked:
58
+ * action "fileUpload.finalize".
59
+ *
60
+ * status(uploadId, opts) → { received, totalBytesAccepted, createdAt,
61
+ * lastChunkAt, metadata, expiresAt } | null
62
+ * Permission-checked: action "fileUpload.status".
63
+ * Returns null if upload not found.
64
+ *
65
+ * list(opts) → [{ uploadId, metadata, createdAt, lastChunkAt,
66
+ * totalBytesAccepted, actor }]
67
+ * Operator dashboards. Permission-checked: action
68
+ * "fileUpload.list". Filter by actor + since.
69
+ *
70
+ * cancelUpload(id,opts) → { ok, uploadId }
71
+ * Force-removes staging. Permission-checked:
72
+ * action "fileUpload.cancel".
73
+ *
74
+ * purgeIncomplete() → { purged: N, ids: [string] }
75
+ * Reclaims staging dirs that exceeded
76
+ * incompleteTtlMs (since createdAt) OR maxIdleMs
77
+ * (since lastChunkAt). Operator wires to
78
+ * b.scheduler or triggers on-demand.
79
+ *
80
+ * close() → void
81
+ * Lifecycle parity with other framework primitives.
82
+ *
83
+ * Design posture:
84
+ *
85
+ * - **init() before any chunk**: explicit lifecycle. Init records
86
+ * createdAt + actor + metadata + signing key in a per-upload sidecar
87
+ * so subsequent acceptChunk / finalize / status calls can authenticate
88
+ * and audit consistently.
89
+ *
90
+ * - **Framework owns chunk lifecycle**, not final storage. Operator
91
+ * decides via `onFinalize` what to do with the assembled buffer
92
+ * OR streamed chunks. Framework doesn't dictate the storage layer.
93
+ *
94
+ * - **SHA3-512** is the hash. PQC-first; SHA-256 is not offered.
95
+ * Per-chunk hash + total hash both verified.
96
+ *
97
+ * - **Stream reassembly above maxStreamReassemblyBytes**: in-memory
98
+ * Buffer.concat of a 2 GiB upload would OOM the process. When the
99
+ * upload exceeds the threshold, finalize calls onFinalize with a
100
+ * readable stream reading the chunk files in order; the body
101
+ * parameter is null. Operator pipes to disk / S3 / etc.
102
+ *
103
+ * - **MIME / file-type gate**: when allowedFileTypes is set and the
104
+ * fileType primitive is wired, finalize sniffs the assembled bytes
105
+ * (or first chunk for streamed uploads) and rejects if the magic
106
+ * bytes don't classify into one of the allowed types. Defense
107
+ * against `.exe disguised as .jpg` and similar mismatches.
108
+ *
109
+ * - **Per-actor + total staging quotas**: maxActiveUploadsPerActor
110
+ * prevents one actor from holding open dozens of uploads;
111
+ * maxStagingBytes prevents the staging dir from filling the disk
112
+ * across all actors. Both checked at init() time before any
113
+ * filesystem allocation.
114
+ *
115
+ * - **Permissions integration**: when `permissions` opt is wired,
116
+ * every operator-facing call checks the action via
117
+ * `permissions.check(actor, "fileUpload.<op>")` before acting.
118
+ * Action names: init / accept / finalize / status / list / cancel.
119
+ *
120
+ * - **Tombstone cleanup**: purgeIncomplete() walks staging entries
121
+ * and reclaims those exceeding incompleteTtlMs (since createdAt)
122
+ * OR maxIdleMs (since lastChunkAt — for in-flight uploads
123
+ * abandoned mid-stream). Audit emission on every purge.
124
+ *
125
+ * - **Validation errors are permanent**: chunk-hash mismatch,
126
+ * oversized chunk, oversized total file, manifest verification
127
+ * failure, MIME-type rejection, quota exhaustion all throw
128
+ * `FileUploadError` with `permanent: true` — no retry will
129
+ * succeed.
130
+ *
131
+ * Security defaults:
132
+ *
133
+ * - Per-chunk SHA3-512 mandatory.
134
+ * - Upload ID format: 1-128 chars from [A-Za-z0-9._-]; hostile
135
+ * values (`..`, `/`, `\`, `\0`, glob chars) refused.
136
+ * - Staging dir mode 0o700.
137
+ * - allowedFileTypes default empty (no whitelist; operator opts in).
138
+ * When set without fileType primitive wired, finalize throws at
139
+ * create() — fail-fast on misconfig.
140
+ *
141
+ * What this primitive intentionally does NOT do:
142
+ *
143
+ * - Resumable uploads via Range header — operator builds on top by
144
+ * reading status()'s `received` indices and resuming at index N+1
145
+ * client-side.
146
+ * - Direct browser → S3 presigned-PUT bypass — operators with that
147
+ * requirement use b.objectStore.presignedUploadUrl directly.
148
+ * - Background virus scanning — onChunk hook is the integration
149
+ * point. Operator wires their scanner of choice.
150
+ */
151
+
152
+ var fs = require("node:fs");
153
+ var path = require("node:path");
154
+ var stream = require("node:stream");
155
+ var atomicFile = require("./atomic-file");
156
+ var C = require("./constants");
157
+ var crypto = require("./crypto");
158
+ var gateContract = require("./gate-contract");
159
+ var lazyRequire = require("./lazy-require");
160
+ var numericBounds = require("./numeric-bounds");
161
+ var requestHelpers = require("./request-helpers");
162
+ var safeBuffer = require("./safe-buffer");
163
+ var safeJson = require("./safe-json");
164
+ var validateOpts = require("./validate-opts");
165
+ var { FileUploadError } = require("./framework-error");
166
+
167
+ // guard-* family is wired on by default; lazy-loaded to avoid eager
168
+ // import cycles (guards consume framework primitives that may not be
169
+ // resolved at file-upload load-time).
170
+ var guardAll = lazyRequire(function () { return require("./guard-all"); });
171
+ var guardFilename = lazyRequire(function () { return require("./guard-filename"); });
172
+
173
+ var _err = FileUploadError.factory;
174
+
175
+ var DEFAULTS = Object.freeze({
176
+ maxFileBytes: C.BYTES.gib(2),
177
+ maxChunkBytes: C.BYTES.mib(8),
178
+ maxStreamReassemblyBytes: C.BYTES.mib(64),
179
+ maxStagingBytes: C.BYTES.gib(50),
180
+ maxActiveUploadsPerActor: 0x10,
181
+ maxChunks: 0x4000,
182
+ incompleteTtlMs: C.TIME.hours(24),
183
+ maxIdleMs: C.TIME.minutes(30),
184
+ // Empty array = no MIME allowlist gate (any type accepted).
185
+ allowedFileTypes: Object.freeze([]),
186
+ });
187
+
188
+ // SHA3-512 produces 64 bytes; named here so the chunk + manifest
189
+ // hash-shape checks read intentionally instead of as a raw 128.
190
+ var SHA3_512_HEX_LENGTH = C.BYTES.bytes(128);
191
+ // Cap on the bytes the per-upload sidecar files can grow to.
192
+ var SIDECAR_MAX_BYTES = C.BYTES.kib(256);
193
+ // Metadata cap — operators stash filename / mimeType / app-bag here.
194
+ // 64 KiB is generous for normal use and refuses payloads that look
195
+ // like the operator is trying to use the upload sidecar as a row store.
196
+ var METADATA_MAX_BYTES = C.BYTES.kib(64);
197
+
198
+ var UPLOAD_ID_RE = /^[A-Za-z0-9._-]+$/;
199
+ var UPLOAD_ID_MAX_LENGTH = C.BYTES.bytes(128);
200
+
201
+ function _validateUploadId(id) {
202
+ if (typeof id !== "string" ||
203
+ id.length === 0 ||
204
+ id.length > UPLOAD_ID_MAX_LENGTH ||
205
+ !UPLOAD_ID_RE.test(id)) {
206
+ var ID_PREVIEW_CHARS = C.BYTES.bytes(64);
207
+ throw _err("BAD_UPLOAD_ID",
208
+ "fileUpload: uploadId must be 1-128 chars matching " + UPLOAD_ID_RE +
209
+ " (path-traversal-hostile inputs refused before any filesystem op), got " +
210
+ JSON.stringify(typeof id === "string" ? id.slice(0, ID_PREVIEW_CHARS) : id));
211
+ }
212
+ return id;
213
+ }
214
+
215
+ function _validateCreateOpts(opts) {
216
+ validateOpts.requireObject(opts, "fileUpload.create", FileUploadError);
217
+ validateOpts.requireNonEmptyString(opts.stagingDir, "fileUpload.create: stagingDir", FileUploadError);
218
+ if (!path.isAbsolute(opts.stagingDir)) {
219
+ throw _err("BAD_OPT", "fileUpload.create: stagingDir must be an absolute path, got " +
220
+ JSON.stringify(opts.stagingDir));
221
+ }
222
+ validateOpts.optionalFunction(opts.onFinalize, "fileUpload.create: onFinalize", FileUploadError);
223
+ validateOpts.optionalFunction(opts.onChunk, "fileUpload.create: onChunk", FileUploadError);
224
+ numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
225
+ ["maxFileBytes", "maxChunkBytes", "maxStreamReassemblyBytes",
226
+ "maxStagingBytes", "maxActiveUploadsPerActor"],
227
+ "fileUpload.create", FileUploadError, "BAD_OPT");
228
+ numericBounds.requireNonNegativeFiniteIntIfPresent(opts.incompleteTtlMs,
229
+ "fileUpload.create: incompleteTtlMs", FileUploadError, "BAD_OPT");
230
+ numericBounds.requireNonNegativeFiniteIntIfPresent(opts.maxIdleMs,
231
+ "fileUpload.create: maxIdleMs", FileUploadError, "BAD_OPT");
232
+ numericBounds.requirePositiveFiniteIntIfPresent(opts.maxChunks,
233
+ "fileUpload.create: maxChunks", FileUploadError, "BAD_OPT");
234
+ validateOpts.auditShape(opts.audit, "fileUpload.create", FileUploadError);
235
+ validateOpts.observabilityShape(opts.observability, "fileUpload.create", FileUploadError);
236
+ validateOpts.optionalFunction(opts.clock, "fileUpload.create: clock", FileUploadError);
237
+ // allowedFileTypes — operator's MIME allowlist. Empty / undefined
238
+ // disables the gate. Setting it without wiring a fileType primitive
239
+ // is a misconfig — the gate would have nothing to enforce against.
240
+ validateOpts.optionalNonEmptyStringArray(opts.allowedFileTypes,
241
+ "fileUpload.create: allowedFileTypes", FileUploadError, "BAD_OPT");
242
+ if (Array.isArray(opts.allowedFileTypes) && opts.allowedFileTypes.length > 0 &&
243
+ (!opts.fileType || typeof opts.fileType.detect !== "function")) {
244
+ throw _err("BAD_OPT",
245
+ "fileUpload.create: allowedFileTypes is set but fileType primitive is not wired " +
246
+ "(pass fileType: b.fileType so the framework can sniff magic bytes at finalize)");
247
+ }
248
+ // permissions — when set, must expose check(actor, scope) → boolean.
249
+ validateOpts.optionalObjectWithMethod(opts.permissions, "check",
250
+ "fileUpload.create: permissions", FileUploadError, "BAD_OPT",
251
+ "must be a b.permissions instance (check fn)");
252
+ // contentSafety — extension-keyed gate map for per-extension content
253
+ // validation. Default behaviour: when undefined, the framework wires
254
+ // b.guardAll.byExtension({ profile: "strict" }) automatically so every
255
+ // shipped guard is ON by default. Explicit opt-out: contentSafety:
256
+ // null (audited at create() time so a security review can reconstruct
257
+ // which deploys disabled the default-on protection).
258
+ // Example: contentSafety: { ".csv": b.guardCsv.gate({ profile: "strict" }) }
259
+ if (opts.contentSafety !== undefined && opts.contentSafety !== null) {
260
+ validateOpts.optionalPlainObject(opts.contentSafety,
261
+ "fileUpload.create: contentSafety", FileUploadError, "BAD_OPT",
262
+ "must be a plain { ext: gate } object, null to opt out, or " +
263
+ "undefined for the default-on b.guardAll wiring");
264
+ var safetyKeys = Object.keys(opts.contentSafety);
265
+ for (var sk = 0; sk < safetyKeys.length; sk++) {
266
+ var ext = safetyKeys[sk];
267
+ var g = opts.contentSafety[ext];
268
+ if (!g || typeof g.check !== "function") {
269
+ throw _err("BAD_OPT",
270
+ "fileUpload.create: contentSafety[" + JSON.stringify(ext) +
271
+ "] must be a gate (b.guardCsv.gate / b.guardHtml.gate / etc.)");
272
+ }
273
+ }
274
+ }
275
+ // filenameSafety — single gate for filename validation. Default: on.
276
+ // Operator opts out with filenameSafety: null (audited).
277
+ if (opts.filenameSafety !== undefined && opts.filenameSafety !== null) {
278
+ validateOpts.optionalObjectWithMethod(opts.filenameSafety, "check",
279
+ "fileUpload.create: filenameSafety", FileUploadError, "BAD_OPT",
280
+ "must be a gate (b.guardFilename.gate(...)), null to opt out, or " +
281
+ "undefined for the default-on wiring");
282
+ }
283
+ }
284
+
285
+ function create(opts) {
286
+ _validateCreateOpts(opts);
287
+ var cfg = validateOpts.applyDefaults(opts, DEFAULTS);
288
+ var stagingDir = opts.stagingDir;
289
+ var onFinalize = opts.onFinalize || null;
290
+ var onChunk = opts.onChunk || null;
291
+ var fileType = opts.fileType || null;
292
+ var permissions = opts.permissions || null;
293
+ // ---- Default-on safety wiring ----
294
+ // contentSafety: undefined → wire b.guardAll.byExtension({ profile: "strict" })
295
+ // contentSafety: null → explicit opt-out, audit row emitted
296
+ // contentSafety: { ... } → use operator-supplied map
297
+ var contentSafety;
298
+ if (opts.contentSafety === undefined) {
299
+ // Strict profile is the security-correct default — every shipped
300
+ // guard's full threat catalog refused, including dangerous tags
301
+ // (script / style / iframe), event handlers, dangerous URL
302
+ // schemes, formula injection, DOCTYPE / SVGZ / animation-href
303
+ // hijack. Operators who need a broader content vocabulary opt up
304
+ // explicitly via contentSafety: b.guardAll.byExtension({
305
+ // profile: "balanced" | "permissive" }).
306
+ contentSafety = guardAll().byExtension({
307
+ profile: "strict",
308
+ audit: opts.audit,
309
+ observability: opts.observability,
310
+ });
311
+ } else if (opts.contentSafety === null) {
312
+ if (opts.audit && typeof opts.audit.safeEmit === "function") {
313
+ try {
314
+ opts.audit.safeEmit({
315
+ action: "fileUpload.contentSafety.disabled",
316
+ actor: {},
317
+ outcome: "success",
318
+ metadata: {
319
+ reason: opts.contentSafetyDisabledReason || "operator-explicit-opt-out",
320
+ },
321
+ });
322
+ } catch (_e) { /* audit best-effort */ }
323
+ }
324
+ contentSafety = null;
325
+ } else {
326
+ contentSafety = opts.contentSafety;
327
+ }
328
+ // filenameSafety: undefined → b.guardFilename.gate({ profile: "strict" })
329
+ // filenameSafety: null → explicit opt-out, audit row emitted
330
+ // filenameSafety: gate → use operator-supplied gate
331
+ var filenameSafety;
332
+ if (opts.filenameSafety === undefined) {
333
+ // Strict filename profile: ASCII-only, single-dot, 64-byte leaf
334
+ // cap, refuses every shell-exec extension (.exe / .bat / .vbs /
335
+ // .ps1 / .lnk / .scr / .dll / .so / .dmg / .msi / etc.). Operators
336
+ // accepting Unicode filenames or executable-extension artifacts
337
+ // opt up explicitly via filenameSafety: b.guardFilename.gate({
338
+ // profile: "balanced" | "permissive" }).
339
+ filenameSafety = guardFilename().gate({
340
+ profile: "strict",
341
+ audit: opts.audit,
342
+ observability: opts.observability,
343
+ });
344
+ } else if (opts.filenameSafety === null) {
345
+ if (opts.audit && typeof opts.audit.safeEmit === "function") {
346
+ try {
347
+ opts.audit.safeEmit({
348
+ action: "fileUpload.filenameSafety.disabled",
349
+ actor: {},
350
+ outcome: "success",
351
+ metadata: {
352
+ reason: opts.filenameSafetyDisabledReason || "operator-explicit-opt-out",
353
+ },
354
+ });
355
+ } catch (_e) { /* audit best-effort */ }
356
+ }
357
+ filenameSafety = null;
358
+ } else {
359
+ filenameSafety = opts.filenameSafety;
360
+ }
361
+ var maxFileBytes = cfg.maxFileBytes;
362
+ var maxChunkBytes = cfg.maxChunkBytes;
363
+ var maxStreamReassemblyBytes = cfg.maxStreamReassemblyBytes;
364
+ var maxStagingBytes = cfg.maxStagingBytes;
365
+ var maxActiveUploadsPerActor = cfg.maxActiveUploadsPerActor;
366
+ var maxChunks = cfg.maxChunks;
367
+ var incompleteTtlMs = cfg.incompleteTtlMs;
368
+ var maxIdleMs = cfg.maxIdleMs;
369
+ var allowedFileTypes = cfg.allowedFileTypes;
370
+ var audit = opts.audit || null;
371
+ var clock = opts.clock || function () { return Date.now(); };
372
+
373
+ var _emitAudit = validateOpts.makeAuditEmitter(audit);
374
+ function _emitObs(name, value, labels) {
375
+ if (opts.observability) opts.observability.safeEvent(name, value, labels || {});
376
+ }
377
+
378
+ // Staging dir mode 0o700 — only the framework process reads its own
379
+ // staging files.
380
+ atomicFile.ensureDir(stagingDir, 0o700);
381
+
382
+ function _uploadDir(uploadId) { return path.join(stagingDir, uploadId); }
383
+ function _chunkPath(uploadId, index) { return path.join(_uploadDir(uploadId), String(index)); }
384
+ function _receivedPath(uploadId) { return path.join(_uploadDir(uploadId), "_received.json"); }
385
+ function _metaPath(uploadId) { return path.join(_uploadDir(uploadId), "_meta.json"); }
386
+
387
+ function _checkPermission(action, actor) {
388
+ if (!permissions) return;
389
+ var allowed;
390
+ try { allowed = permissions.check(actor, "fileUpload." + action); }
391
+ catch (_e) { allowed = false; }
392
+ if (!allowed) {
393
+ _emitObs("fileUpload.permission_denied", 1, { action: action });
394
+ throw _err("PERMISSION_DENIED",
395
+ "fileUpload." + action + ": actor lacks permission scope 'fileUpload." + action + "'");
396
+ }
397
+ }
398
+
399
+ function _readReceivedIndices(uploadId) {
400
+ var p = _receivedPath(uploadId);
401
+ if (!fs.existsSync(p)) return [];
402
+ try {
403
+ var raw = atomicFile.readSync(p, { maxBytes: SIDECAR_MAX_BYTES });
404
+ var parsed = safeJson.parse(raw.toString("utf8"));
405
+ return Array.isArray(parsed) ? parsed : [];
406
+ } catch (_e) { return []; }
407
+ }
408
+ function _writeReceivedIndices(uploadId, indices) {
409
+ atomicFile.writeSync(_receivedPath(uploadId), JSON.stringify(indices), { mode: 0o600 });
410
+ }
411
+
412
+ function _readMeta(uploadId) {
413
+ var p = _metaPath(uploadId);
414
+ if (!fs.existsSync(p)) return null;
415
+ try {
416
+ var raw = atomicFile.readSync(p, { maxBytes: SIDECAR_MAX_BYTES });
417
+ return safeJson.parse(raw.toString("utf8"));
418
+ } catch (_e) { return null; }
419
+ }
420
+ function _writeMeta(uploadId, meta) {
421
+ atomicFile.writeSync(_metaPath(uploadId), JSON.stringify(meta), { mode: 0o600 });
422
+ }
423
+
424
+ function _actorKey(actor) {
425
+ // Actor identity for quota grouping. id field if present; otherwise
426
+ // anonymous bucket. Operators with un-id'd actors share quota.
427
+ return (actor && (actor.id || actor.userId)) || "_anonymous";
428
+ }
429
+
430
+ function _enumerateUploads() {
431
+ if (!fs.existsSync(stagingDir)) return [];
432
+ var entries;
433
+ try { entries = atomicFile.listDir(stagingDir, { includeStat: true }); }
434
+ catch (_e) { return []; }
435
+ var uploads = [];
436
+ for (var i = 0; i < entries.length; i++) {
437
+ var e = entries[i];
438
+ if (!e.isDirectory) continue;
439
+ var meta = _readMeta(e.name);
440
+ uploads.push({
441
+ uploadId: e.name,
442
+ meta: meta,
443
+ mtimeMs: e.mtimeMs,
444
+ });
445
+ }
446
+ return uploads;
447
+ }
448
+
449
+ function _stagingTotalBytes() {
450
+ var uploads = _enumerateUploads();
451
+ var total = 0;
452
+ for (var i = 0; i < uploads.length; i++) {
453
+ total += (uploads[i].meta && uploads[i].meta.totalBytesAccepted) || 0;
454
+ }
455
+ return total;
456
+ }
457
+
458
+ function _activeUploadsForActor(actorId) {
459
+ var uploads = _enumerateUploads();
460
+ var count = 0;
461
+ for (var i = 0; i < uploads.length; i++) {
462
+ if (uploads[i].meta && uploads[i].meta.actorId === actorId) count += 1;
463
+ }
464
+ return count;
465
+ }
466
+
467
+ // ---- init ----
468
+
469
+ async function init(callerOpts) {
470
+ validateOpts.requireObject(callerOpts, "fileUpload.init", FileUploadError);
471
+ var uploadId = _validateUploadId(callerOpts.uploadId);
472
+ var actor = callerOpts.actor || null;
473
+ var metadata = callerOpts.metadata !== undefined ? callerOpts.metadata : {};
474
+
475
+ _checkPermission("init", actor);
476
+
477
+ if (typeof metadata !== "object" || metadata === null || Array.isArray(metadata)) {
478
+ throw _err("BAD_METADATA",
479
+ "fileUpload.init: metadata must be a plain object (operator app-bag)");
480
+ }
481
+ var metadataJson = JSON.stringify(metadata);
482
+ if (Buffer.byteLength(metadataJson, "utf8") > METADATA_MAX_BYTES) {
483
+ throw _err("METADATA_TOO_LARGE",
484
+ "fileUpload.init: metadata exceeds " + METADATA_MAX_BYTES + " bytes");
485
+ }
486
+
487
+ // Refuse re-init of an existing upload (caller-side bug).
488
+ if (fs.existsSync(_uploadDir(uploadId))) {
489
+ throw _err("UPLOAD_EXISTS",
490
+ "fileUpload.init: upload '" + uploadId + "' already exists; cancel or finalize first");
491
+ }
492
+
493
+ var actorId = _actorKey(actor);
494
+ if (_activeUploadsForActor(actorId) >= maxActiveUploadsPerActor) {
495
+ _emitObs("fileUpload.actor_quota_exceeded", 1);
496
+ throw _err("ACTOR_QUOTA_EXCEEDED",
497
+ "fileUpload.init: actor '" + actorId + "' has " + maxActiveUploadsPerActor +
498
+ " active uploads (cap maxActiveUploadsPerActor)");
499
+ }
500
+ if (_stagingTotalBytes() >= maxStagingBytes) {
501
+ _emitObs("fileUpload.staging_quota_exceeded", 1);
502
+ throw _err("STAGING_QUOTA_EXCEEDED",
503
+ "fileUpload.init: total staging exceeds " + maxStagingBytes + " bytes (maxStagingBytes)");
504
+ }
505
+
506
+ atomicFile.ensureDir(_uploadDir(uploadId), 0o700);
507
+ var now = clock();
508
+ var meta = {
509
+ uploadId: uploadId,
510
+ actorId: actorId,
511
+ metadata: metadata,
512
+ createdAt: now,
513
+ lastChunkAt: now,
514
+ totalBytesAccepted: 0,
515
+ };
516
+ _writeMeta(uploadId, meta);
517
+ _writeReceivedIndices(uploadId, []);
518
+
519
+ _emitObs("fileUpload.init", 1);
520
+ _emitAudit("fileUpload.init", {
521
+ actor: requestHelpers.extractActorContext(actor),
522
+ resource: { kind: "fileUpload", id: uploadId },
523
+ outcome: "success",
524
+ metadata: { metadata: metadata },
525
+ });
526
+
527
+ return {
528
+ uploadId: uploadId,
529
+ createdAt: now,
530
+ expiresAt: now + incompleteTtlMs,
531
+ };
532
+ }
533
+
534
+ // ---- acceptChunk ----
535
+
536
+ async function acceptChunk(callerOpts) {
537
+ validateOpts.requireObject(callerOpts, "fileUpload.acceptChunk", FileUploadError);
538
+ var uploadId = _validateUploadId(callerOpts.uploadId);
539
+ var index = callerOpts.index;
540
+ var body = callerOpts.body;
541
+ var sha3Hex = callerOpts.sha3;
542
+ var actor = callerOpts.actor;
543
+
544
+ _checkPermission("accept", actor);
545
+
546
+ var meta = _readMeta(uploadId);
547
+ if (!meta) {
548
+ throw _err("UNKNOWN_UPLOAD",
549
+ "fileUpload.acceptChunk: no init() seen for '" + uploadId + "'; call init() first");
550
+ }
551
+ if (clock() - meta.lastChunkAt > maxIdleMs) {
552
+ // Idle-timed-out — too much time since init or last chunk.
553
+ throw _err("UPLOAD_IDLE_EXPIRED",
554
+ "fileUpload.acceptChunk: upload '" + uploadId + "' exceeded maxIdleMs (" + maxIdleMs +
555
+ " ms since last chunk or init)");
556
+ }
557
+
558
+ if (!Number.isInteger(index) || index < 0 || index >= maxChunks) {
559
+ throw _err("BAD_INDEX",
560
+ "fileUpload.acceptChunk: index must be a non-negative integer < " + maxChunks +
561
+ ", got " + numericBounds.shape(index));
562
+ }
563
+ if (!Buffer.isBuffer(body)) {
564
+ throw _err("BAD_BODY",
565
+ "fileUpload.acceptChunk: body must be a Buffer, got " + typeof body);
566
+ }
567
+ if (body.length === 0) {
568
+ throw _err("EMPTY_CHUNK",
569
+ "fileUpload.acceptChunk: body is empty (0 bytes)");
570
+ }
571
+ if (body.length > maxChunkBytes) {
572
+ _emitObs("fileUpload.chunk_too_large", 1);
573
+ throw _err("CHUNK_TOO_LARGE",
574
+ "fileUpload.acceptChunk: chunk body is " + body.length +
575
+ " bytes, exceeds maxChunkBytes (" + maxChunkBytes + ")");
576
+ }
577
+ if (!safeBuffer.isHex(sha3Hex) || sha3Hex.length !== SHA3_512_HEX_LENGTH) {
578
+ throw _err("BAD_CHUNK_HASH",
579
+ "fileUpload.acceptChunk: sha3 must be a SHA3-512 hex string (" +
580
+ SHA3_512_HEX_LENGTH + " chars); got " +
581
+ (typeof sha3Hex === "string" ? sha3Hex.length + " chars" : typeof sha3Hex));
582
+ }
583
+
584
+ // Verify chunk hash matches the supplied header.
585
+ var actualHex = crypto.sha3Hash(body);
586
+ if (!crypto.timingSafeEqual(actualHex, sha3Hex)) {
587
+ _emitObs("fileUpload.chunk_hash_mismatch", 1);
588
+ _emitAudit("fileUpload.chunk_received", {
589
+ actor: requestHelpers.extractActorContext(actor),
590
+ resource: { kind: "fileUpload", id: uploadId },
591
+ outcome: "failure",
592
+ reason: "chunk-hash-mismatch",
593
+ metadata: { index: index, size: body.length },
594
+ });
595
+ throw _err("CHUNK_HASH_MISMATCH",
596
+ "fileUpload.acceptChunk: chunk SHA3-512 mismatch — supplied does not equal computed");
597
+ }
598
+
599
+ // Per-chunk operator hook (e.g. virus scan, schema check). May
600
+ // throw to refuse the chunk.
601
+ if (onChunk) {
602
+ try {
603
+ await onChunk({
604
+ uploadId: uploadId,
605
+ index: index,
606
+ body: body,
607
+ sha3: actualHex,
608
+ actor: actor,
609
+ metadata: meta.metadata,
610
+ });
611
+ } catch (e) {
612
+ _emitObs("fileUpload.onchunk_rejected", 1);
613
+ _emitAudit("fileUpload.chunk_received", {
614
+ actor: requestHelpers.extractActorContext(actor),
615
+ resource: { kind: "fileUpload", id: uploadId },
616
+ outcome: "failure",
617
+ reason: "onchunk-rejected",
618
+ metadata: { index: index, size: body.length,
619
+ error: (e && e.message) || String(e) },
620
+ });
621
+ throw e;
622
+ }
623
+ }
624
+
625
+ // Idempotent re-PUT: if this index is already received with a
626
+ // matching body, no-op. Different body = caller bug.
627
+ var p = _chunkPath(uploadId, index);
628
+ if (fs.existsSync(p)) {
629
+ var existing = atomicFile.readSync(p, { maxBytes: maxChunkBytes });
630
+ if (crypto.timingSafeEqual(crypto.sha3Hash(existing), sha3Hex)) {
631
+ return {
632
+ received: _readReceivedIndices(uploadId).length,
633
+ totalBytesAccepted: meta.totalBytesAccepted,
634
+ status: "in-progress",
635
+ duplicate: true,
636
+ };
637
+ }
638
+ throw _err("CHUNK_REUSE_MISMATCH",
639
+ "fileUpload.acceptChunk: chunk " + index +
640
+ " already received with a different body (caller-side bug; refusing overwrite)");
641
+ }
642
+
643
+ atomicFile.writeSync(p, body, { mode: 0o600 });
644
+ var receivedIndices = _readReceivedIndices(uploadId);
645
+ if (receivedIndices.indexOf(index) === -1) {
646
+ receivedIndices.push(index);
647
+ _writeReceivedIndices(uploadId, receivedIndices);
648
+ }
649
+
650
+ // Update meta.
651
+ meta.lastChunkAt = clock();
652
+ meta.totalBytesAccepted = (meta.totalBytesAccepted || 0) + body.length;
653
+ if (meta.totalBytesAccepted > maxFileBytes) {
654
+ // Reclaim staging — the upload exceeded the cap mid-stream.
655
+ try { fs.rmSync(_uploadDir(uploadId), { recursive: true, force: true }); }
656
+ catch (_e) { /* purgeIncomplete will reclaim */ }
657
+ _emitObs("fileUpload.file_too_large", 1);
658
+ throw _err("FILE_TOO_LARGE",
659
+ "fileUpload.acceptChunk: cumulative upload exceeded maxFileBytes (" + maxFileBytes +
660
+ "); upload reclaimed");
661
+ }
662
+ _writeMeta(uploadId, meta);
663
+
664
+ _emitObs("fileUpload.chunks_received", 1);
665
+ _emitObs("fileUpload.bytes_received", body.length);
666
+ _emitAudit("fileUpload.chunk_received", {
667
+ actor: requestHelpers.extractActorContext(actor),
668
+ resource: { kind: "fileUpload", id: uploadId },
669
+ outcome: "success",
670
+ metadata: { index: index, size: body.length },
671
+ });
672
+
673
+ return {
674
+ received: receivedIndices.length,
675
+ totalBytesAccepted: meta.totalBytesAccepted,
676
+ status: "in-progress",
677
+ };
678
+ }
679
+
680
+ // ---- finalize ----
681
+
682
+ function _validateManifest(manifest) {
683
+ validateOpts.requireObject(manifest, "fileUpload.finalize: manifest", FileUploadError);
684
+ if (!Array.isArray(manifest.chunks) || manifest.chunks.length === 0) {
685
+ throw _err("BAD_MANIFEST",
686
+ "fileUpload.finalize: manifest.chunks must be a non-empty array");
687
+ }
688
+ if (manifest.chunks.length > maxChunks) {
689
+ throw _err("TOO_MANY_CHUNKS",
690
+ "fileUpload.finalize: manifest declares " + manifest.chunks.length +
691
+ " chunks, exceeds maxChunks (" + maxChunks + ")");
692
+ }
693
+ if (!Number.isInteger(manifest.totalBytes) || manifest.totalBytes <= 0) {
694
+ throw _err("BAD_MANIFEST",
695
+ "fileUpload.finalize: manifest.totalBytes must be a positive integer");
696
+ }
697
+ if (manifest.totalBytes > maxFileBytes) {
698
+ throw _err("FILE_TOO_LARGE",
699
+ "fileUpload.finalize: manifest.totalBytes (" + manifest.totalBytes +
700
+ ") exceeds maxFileBytes (" + maxFileBytes + ")");
701
+ }
702
+ if (!safeBuffer.isHex(manifest.sha3) || manifest.sha3.length !== SHA3_512_HEX_LENGTH) {
703
+ throw _err("BAD_MANIFEST",
704
+ "fileUpload.finalize: manifest.sha3 must be a SHA3-512 hex string (" +
705
+ SHA3_512_HEX_LENGTH + " chars)");
706
+ }
707
+ }
708
+
709
+ function _verifyChunksOnDisk(uploadId, manifest) {
710
+ // Returns sorted chunk paths + verifies per-chunk + total hash.
711
+ // For small uploads we walk + concat; for large we just walk and
712
+ // verify, returning paths so the streaming path can read on
713
+ // demand.
714
+ var sortedChunks = manifest.chunks.slice().sort(function (a, b) {
715
+ return a.index - b.index;
716
+ });
717
+ var paths = [];
718
+ var hasher = require("node:crypto").createHash("sha3-512");
719
+ var totalBytes = 0;
720
+
721
+ for (var i = 0; i < sortedChunks.length; i++) {
722
+ var ck = sortedChunks[i];
723
+ if (!Number.isInteger(ck.index) || ck.index !== i) {
724
+ throw _err("MANIFEST_INDEX_GAP",
725
+ "fileUpload.finalize: chunk " + i + " in manifest has index " + ck.index +
726
+ " (expected " + i + " — chunk indices must be 0..N-1 contiguous)");
727
+ }
728
+ if (!safeBuffer.isHex(ck.sha3) || ck.sha3.length !== SHA3_512_HEX_LENGTH) {
729
+ throw _err("BAD_MANIFEST",
730
+ "fileUpload.finalize: chunk " + i + ".sha3 must be a SHA3-512 hex string (" +
731
+ SHA3_512_HEX_LENGTH + " chars)");
732
+ }
733
+ var chunkPath = _chunkPath(uploadId, ck.index);
734
+ if (!fs.existsSync(chunkPath)) {
735
+ throw _err("MISSING_CHUNK",
736
+ "fileUpload.finalize: chunk " + ck.index + " missing from staging");
737
+ }
738
+ var chunkBody = atomicFile.readSync(chunkPath, { maxBytes: maxChunkBytes });
739
+ var actualChunkHex = crypto.sha3Hash(chunkBody);
740
+ if (!crypto.timingSafeEqual(actualChunkHex, ck.sha3)) {
741
+ throw _err("CHUNK_HASH_MISMATCH",
742
+ "fileUpload.finalize: chunk " + ck.index +
743
+ " on-disk SHA3-512 doesn't match manifest");
744
+ }
745
+ paths.push(chunkPath);
746
+ totalBytes += chunkBody.length;
747
+ if (totalBytes > maxFileBytes) {
748
+ throw _err("FILE_TOO_LARGE",
749
+ "fileUpload.finalize: reassembly exceeds maxFileBytes mid-walk");
750
+ }
751
+ hasher.update(chunkBody);
752
+ }
753
+ if (totalBytes !== manifest.totalBytes) {
754
+ throw _err("MANIFEST_SIZE_MISMATCH",
755
+ "fileUpload.finalize: reassembled " + totalBytes +
756
+ " bytes, manifest declares " + manifest.totalBytes);
757
+ }
758
+ var totalHashHex = hasher.digest("hex");
759
+ if (!crypto.timingSafeEqual(totalHashHex, manifest.sha3)) {
760
+ throw _err("MANIFEST_HASH_MISMATCH",
761
+ "fileUpload.finalize: reassembled SHA3-512 doesn't match manifest.sha3");
762
+ }
763
+ return { paths: paths, totalBytes: totalBytes, totalHashHex: totalHashHex };
764
+ }
765
+
766
+ function _checkAllowedFileType(firstChunkBody) {
767
+ if (!allowedFileTypes || allowedFileTypes.length === 0) return;
768
+ if (!fileType) return; // create() guards this; defensive
769
+ var detected = fileType.detect(firstChunkBody);
770
+ var detectedMime = detected && detected.mime;
771
+ if (!detectedMime) {
772
+ throw _err("MIME_NOT_DETECTED",
773
+ "fileUpload.finalize: could not classify magic bytes against allowedFileTypes");
774
+ }
775
+ var ok = false;
776
+ for (var i = 0; i < allowedFileTypes.length; i++) {
777
+ var allowed = allowedFileTypes[i];
778
+ if (allowed === detectedMime) { ok = true; break; }
779
+ // Wildcard support: "image/*" matches "image/png".
780
+ if (allowed.endsWith("/*")) {
781
+ var prefix = allowed.slice(0, -1); // "image/"
782
+ if (detectedMime.indexOf(prefix) === 0) { ok = true; break; }
783
+ }
784
+ }
785
+ if (!ok) {
786
+ throw _err("MIME_NOT_ALLOWED",
787
+ "fileUpload.finalize: detected MIME '" + detectedMime +
788
+ "' not in allowedFileTypes (" + allowedFileTypes.join(", ") + ")");
789
+ }
790
+ }
791
+
792
+ function _streamFromChunkPaths(paths /* totalBytes */) {
793
+ // Sequential ReadableStream over chunk files. Operator's
794
+ // onFinalize reads through to wherever they're piping.
795
+ async function* generate() {
796
+ for (var i = 0; i < paths.length; i += 1) {
797
+ var fh = fs.createReadStream(paths[i]);
798
+ for await (var chunk of fh) {
799
+ yield chunk;
800
+ }
801
+ }
802
+ }
803
+ return stream.Readable.from(generate(), { objectMode: false });
804
+ }
805
+
806
+ async function finalize(callerOpts) {
807
+ validateOpts.requireObject(callerOpts, "fileUpload.finalize", FileUploadError);
808
+ var uploadId = _validateUploadId(callerOpts.uploadId);
809
+ var manifest = callerOpts.manifest;
810
+ var actor = callerOpts.actor;
811
+
812
+ _checkPermission("finalize", actor);
813
+
814
+ var meta = _readMeta(uploadId);
815
+ if (!meta) {
816
+ throw _err("UNKNOWN_UPLOAD",
817
+ "fileUpload.finalize: no init() seen for '" + uploadId + "'");
818
+ }
819
+
820
+ _validateManifest(manifest);
821
+
822
+ var verified = _verifyChunksOnDisk(uploadId, manifest);
823
+
824
+ // Decide buffer-vs-stream based on size.
825
+ var useStream = verified.totalBytes > maxStreamReassemblyBytes;
826
+ var bodyBuffer = null;
827
+ var bodyStream = null;
828
+ var firstChunk = null;
829
+
830
+ if (useStream) {
831
+ // Read just the first chunk for the MIME sniff; the operator
832
+ // gets the stream for the actual data.
833
+ firstChunk = atomicFile.readSync(verified.paths[0], { maxBytes: maxChunkBytes });
834
+ bodyStream = _streamFromChunkPaths(verified.paths, verified.totalBytes);
835
+ } else {
836
+ // Small enough to assemble in memory. Buffer.concat.
837
+ var pieces = [];
838
+ for (var i = 0; i < verified.paths.length; i++) {
839
+ pieces.push(atomicFile.readSync(verified.paths[i], { maxBytes: maxChunkBytes }));
840
+ }
841
+ bodyBuffer = Buffer.concat(pieces, verified.totalBytes);
842
+ firstChunk = pieces[0];
843
+ }
844
+
845
+ // MIME allowlist gate (if configured).
846
+ try { _checkAllowedFileType(firstChunk); }
847
+ catch (e) {
848
+ _emitObs("fileUpload.mime_rejected", 1);
849
+ _emitAudit("fileUpload.finalize", {
850
+ actor: requestHelpers.extractActorContext(actor),
851
+ resource: { kind: "fileUpload", id: uploadId },
852
+ outcome: "failure",
853
+ reason: "mime-not-allowed",
854
+ metadata: { size: verified.totalBytes,
855
+ error: (e && e.message) || String(e) },
856
+ });
857
+ throw e;
858
+ }
859
+
860
+ // Content-safety gate — operator-supplied per-extension gate
861
+ // (b.guardCsv.gate / b.guardHtml.gate / etc.). Routes the assembled
862
+ // body through the gate's check() before handing to onFinalize. The
863
+ // decision is honored:
864
+ // - serve → continue with the original buffer
865
+ // - sanitize → continue with decision.sanitized (operator's
866
+ // onFinalize sees the cleaned bytes)
867
+ // - refuse → throw FileUploadError; operator route surfaces the
868
+ // rejection to the client
869
+ // filenameSafety — single gate that validates the filename string
870
+ // (path traversal / null-byte / Windows reserved names / NTFS ADS /
871
+ // RTLO bidi / overlong UTF-8 / shell-exec / double-extension).
872
+ // Runs BEFORE contentSafety because a refused filename obviates
873
+ // the need to validate the body.
874
+ var filename = (meta.metadata && meta.metadata.filename) || uploadId;
875
+ if (filenameSafety && typeof filenameSafety.check === "function") {
876
+ var fnDecision;
877
+ try {
878
+ fnDecision = await filenameSafety.check({
879
+ filename: filename,
880
+ actor: actor,
881
+ direction: "inbound",
882
+ metadata: meta.metadata,
883
+ });
884
+ } catch (fnErr) {
885
+ _emitObs("fileUpload.filename_safety_threw", 1);
886
+ _emitAudit("fileUpload.finalize_failure", {
887
+ actor: requestHelpers.extractActorContext(actor),
888
+ outcome: "failure", reason: "filename-safety-threw",
889
+ metadata: { uploadId: uploadId, error: fnErr && fnErr.message },
890
+ });
891
+ throw _err("FILENAME_SAFETY_THREW",
892
+ "fileUpload.finalize: filenameSafety gate threw: " + (fnErr && fnErr.message));
893
+ }
894
+ if (!fnDecision.ok || fnDecision.action === "refuse") {
895
+ _emitObs("fileUpload.filename_safety_refused", 1);
896
+ _emitAudit("fileUpload.finalize_failure", {
897
+ actor: requestHelpers.extractActorContext(actor),
898
+ outcome: "failure", reason: "filename-safety-refused",
899
+ metadata: {
900
+ uploadId: uploadId, filename: filename,
901
+ issues: gateContract.summarizeIssues(fnDecision.issues),
902
+ },
903
+ });
904
+ throw _err("FILENAME_SAFETY_REFUSED",
905
+ "fileUpload.finalize: filenameSafety refused " + JSON.stringify(filename) +
906
+ ": " + gateContract.summarizeIssues(fnDecision.issues));
907
+ }
908
+ // sanitize: replace metadata.filename with the sanitized form so
909
+ // downstream code sees the cleaned name.
910
+ if (fnDecision.action === "sanitize" && fnDecision.sanitizedFilename) {
911
+ meta.metadata = Object.assign({}, meta.metadata || {},
912
+ { filename: fnDecision.sanitizedFilename });
913
+ filename = fnDecision.sanitizedFilename;
914
+ }
915
+ }
916
+ if (contentSafety) {
917
+ var safetyExt = path.extname(filename).toLowerCase();
918
+ var safetyGate = contentSafety[safetyExt];
919
+ if (safetyGate && typeof safetyGate.check === "function" && bodyBuffer) {
920
+ var safetyDecision;
921
+ try {
922
+ safetyDecision = await safetyGate.check({
923
+ bytes: bodyBuffer,
924
+ filename: filename,
925
+ actor: actor,
926
+ direction: "inbound",
927
+ metadata: meta.metadata,
928
+ });
929
+ } catch (gateErr) {
930
+ _emitObs("fileUpload.content_safety_threw", 1);
931
+ _emitAudit("fileUpload.finalize_failure", {
932
+ actor: requestHelpers.extractActorContext(actor),
933
+ outcome: "failure", reason: "content-safety-threw",
934
+ metadata: { uploadId: uploadId, error: gateErr && gateErr.message },
935
+ });
936
+ throw _err("CONTENT_SAFETY_THREW",
937
+ "fileUpload.finalize: contentSafety gate threw: " + (gateErr && gateErr.message));
938
+ }
939
+ if (!safetyDecision.ok || safetyDecision.action === "refuse") {
940
+ _emitObs("fileUpload.content_safety_refused", 1, { ext: safetyExt });
941
+ _emitAudit("fileUpload.finalize_failure", {
942
+ actor: requestHelpers.extractActorContext(actor),
943
+ outcome: "failure", reason: "content-safety-refused",
944
+ metadata: {
945
+ uploadId: uploadId, ext: safetyExt,
946
+ issues: gateContract.summarizeIssues(safetyDecision.issues),
947
+ },
948
+ });
949
+ throw _err("CONTENT_SAFETY_REFUSED",
950
+ "fileUpload.finalize: contentSafety gate refused upload (" +
951
+ (safetyDecision.issues || []).map(function (i) { return i.kind; }).join(", ") + ")");
952
+ }
953
+ if (safetyDecision.action === "sanitize" && safetyDecision.sanitized) {
954
+ // Replace the body buffer with the sanitized variant.
955
+ bodyBuffer = safetyDecision.sanitized;
956
+ // Clear the streaming alias if present — sanitized fits in memory.
957
+ bodyStream = null;
958
+ }
959
+ }
960
+ }
961
+
962
+ // Hand to operator's onFinalize.
963
+ var rv;
964
+ try {
965
+ if (onFinalize) {
966
+ rv = await onFinalize({
967
+ uploadId: uploadId,
968
+ body: bodyBuffer,
969
+ stream: bodyStream,
970
+ sha3: verified.totalHashHex,
971
+ size: verified.totalBytes,
972
+ actor: actor,
973
+ metadata: meta.metadata,
974
+ });
975
+ } else {
976
+ rv = { ok: true, sha3: verified.totalHashHex, size: verified.totalBytes };
977
+ }
978
+ } catch (e) {
979
+ _emitObs("fileUpload.finalize_failure", 1);
980
+ _emitAudit("fileUpload.finalize", {
981
+ actor: requestHelpers.extractActorContext(actor),
982
+ resource: { kind: "fileUpload", id: uploadId },
983
+ outcome: "failure",
984
+ reason: "onfinalize-threw",
985
+ metadata: { size: verified.totalBytes, sha3: verified.totalHashHex,
986
+ error: (e && e.message) || String(e) },
987
+ });
988
+ throw e;
989
+ }
990
+
991
+ // Cleanup staging on success.
992
+ try { fs.rmSync(_uploadDir(uploadId), { recursive: true, force: true }); }
993
+ catch (_e) { /* best-effort */ }
994
+
995
+ _emitObs("fileUpload.finalize_success", 1);
996
+ _emitObs("fileUpload.finalize_bytes", verified.totalBytes);
997
+ _emitAudit("fileUpload.finalize", {
998
+ actor: requestHelpers.extractActorContext(actor),
999
+ resource: { kind: "fileUpload", id: uploadId },
1000
+ outcome: "success",
1001
+ metadata: { size: verified.totalBytes, sha3: verified.totalHashHex,
1002
+ mode: useStream ? "stream" : "buffer" },
1003
+ });
1004
+
1005
+ return rv;
1006
+ }
1007
+
1008
+ // ---- status / list / cancel ----
1009
+
1010
+ function status(uploadId, callerOpts) {
1011
+ callerOpts = callerOpts || {};
1012
+ _validateUploadId(uploadId);
1013
+ _checkPermission("status", callerOpts.actor);
1014
+ var meta = _readMeta(uploadId);
1015
+ if (!meta) return null;
1016
+ var indices = _readReceivedIndices(uploadId).slice().sort(function (a, b) { return a - b; });
1017
+ return {
1018
+ uploadId: uploadId,
1019
+ received: indices,
1020
+ totalBytesAccepted: meta.totalBytesAccepted || 0,
1021
+ createdAt: meta.createdAt,
1022
+ lastChunkAt: meta.lastChunkAt,
1023
+ metadata: meta.metadata || {},
1024
+ expiresAt: meta.createdAt + incompleteTtlMs,
1025
+ };
1026
+ }
1027
+
1028
+ function list(callerOpts) {
1029
+ callerOpts = callerOpts || {};
1030
+ _checkPermission("list", callerOpts.actor);
1031
+ var actorFilter = callerOpts.actor && (callerOpts.actor.id || callerOpts.actor.userId);
1032
+ var sinceMs = (typeof callerOpts.since === "number") ? callerOpts.since : 0;
1033
+ var uploads = _enumerateUploads();
1034
+ var out = [];
1035
+ for (var i = 0; i < uploads.length; i++) {
1036
+ var u = uploads[i];
1037
+ if (!u.meta) continue;
1038
+ if (sinceMs && u.meta.createdAt < sinceMs) continue;
1039
+ if (actorFilter && callerOpts.scopeToActor !== false && u.meta.actorId !== actorFilter) continue;
1040
+ out.push({
1041
+ uploadId: u.meta.uploadId,
1042
+ actorId: u.meta.actorId,
1043
+ metadata: u.meta.metadata || {},
1044
+ createdAt: u.meta.createdAt,
1045
+ lastChunkAt: u.meta.lastChunkAt,
1046
+ totalBytesAccepted: u.meta.totalBytesAccepted || 0,
1047
+ });
1048
+ }
1049
+ return out;
1050
+ }
1051
+
1052
+ async function cancelUpload(uploadId, callerOpts) {
1053
+ callerOpts = callerOpts || {};
1054
+ _validateUploadId(uploadId);
1055
+ _checkPermission("cancel", callerOpts.actor);
1056
+ var meta = _readMeta(uploadId);
1057
+ if (!meta) return { ok: false, uploadId: uploadId, reason: "not-found" };
1058
+ try { fs.rmSync(_uploadDir(uploadId), { recursive: true, force: true }); }
1059
+ catch (_e) { /* best-effort */ }
1060
+ _emitObs("fileUpload.cancelled", 1);
1061
+ _emitAudit("fileUpload.cancelled", {
1062
+ actor: requestHelpers.extractActorContext(callerOpts.actor),
1063
+ resource: { kind: "fileUpload", id: uploadId },
1064
+ outcome: "success",
1065
+ metadata: { totalBytesAccepted: meta.totalBytesAccepted || 0 },
1066
+ });
1067
+ return { ok: true, uploadId: uploadId };
1068
+ }
1069
+
1070
+ // ---- purgeIncomplete ----
1071
+
1072
+ function purgeIncomplete() {
1073
+ if (!fs.existsSync(stagingDir)) return { purged: 0, ids: [] };
1074
+ var now = clock();
1075
+ var entries;
1076
+ try { entries = atomicFile.listDir(stagingDir, { includeStat: true }); }
1077
+ catch (_e) { return { purged: 0, ids: [] }; }
1078
+ var purged = [];
1079
+ for (var i = 0; i < entries.length; i++) {
1080
+ var e = entries[i];
1081
+ if (!e.isDirectory) continue;
1082
+ var meta = _readMeta(e.name);
1083
+ var purgeReason = null;
1084
+ if (meta) {
1085
+ if (now - meta.createdAt > incompleteTtlMs) purgeReason = "ttl-exceeded";
1086
+ else if (now - meta.lastChunkAt > maxIdleMs) purgeReason = "idle-exceeded";
1087
+ } else {
1088
+ // No meta sidecar → orphaned dir from a prior version or
1089
+ // failed init. Reclaim by mtime.
1090
+ if (now - e.mtimeMs > incompleteTtlMs) purgeReason = "orphan";
1091
+ }
1092
+ if (!purgeReason) continue;
1093
+ try {
1094
+ fs.rmSync(e.fullPath, { recursive: true, force: true });
1095
+ purged.push({ id: e.name, reason: purgeReason });
1096
+ } catch (_e2) { /* best-effort; will retry */ }
1097
+ }
1098
+ if (purged.length > 0) {
1099
+ _emitObs("fileUpload.purged_incomplete", purged.length);
1100
+ _emitAudit("fileUpload.purged", {
1101
+ actor: { kind: "framework" },
1102
+ resource: { kind: "fileUpload", id: stagingDir },
1103
+ outcome: "success",
1104
+ metadata: { purgedIds: purged.map(function (p) { return p.id; }),
1105
+ count: purged.length },
1106
+ });
1107
+ }
1108
+ return {
1109
+ purged: purged.length,
1110
+ ids: purged.map(function (p) { return p.id; }),
1111
+ reasons: purged,
1112
+ };
1113
+ }
1114
+
1115
+ function close() {
1116
+ // Lifecycle parity. No timers / connections to release.
1117
+ }
1118
+
1119
+ return {
1120
+ init: init,
1121
+ acceptChunk: acceptChunk,
1122
+ finalize: finalize,
1123
+ status: status,
1124
+ list: list,
1125
+ cancelUpload: cancelUpload,
1126
+ purgeIncomplete: purgeIncomplete,
1127
+ close: close,
1128
+ };
1129
+ }
1130
+
1131
+ module.exports = {
1132
+ create: create,
1133
+ FileUploadError: FileUploadError,
1134
+ DEFAULTS: DEFAULTS,
1135
+ UPLOAD_ID_RE: UPLOAD_ID_RE,
1136
+ };