@blamejs/core 0.7.1 → 0.7.4

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.
@@ -0,0 +1,933 @@
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 numericBounds = require("./numeric-bounds");
159
+ var requestHelpers = require("./request-helpers");
160
+ var safeBuffer = require("./safe-buffer");
161
+ var safeJson = require("./safe-json");
162
+ var validateOpts = require("./validate-opts");
163
+ var { FileUploadError } = require("./framework-error");
164
+
165
+ var _err = FileUploadError.factory;
166
+
167
+ var DEFAULTS = Object.freeze({
168
+ maxFileBytes: C.BYTES.gib(2),
169
+ maxChunkBytes: C.BYTES.mib(8),
170
+ maxStreamReassemblyBytes: C.BYTES.mib(64),
171
+ maxStagingBytes: C.BYTES.gib(50),
172
+ maxActiveUploadsPerActor: 0x10,
173
+ maxChunks: 0x4000,
174
+ incompleteTtlMs: C.TIME.hours(24),
175
+ maxIdleMs: C.TIME.minutes(30),
176
+ // Empty array = no MIME allowlist gate (any type accepted).
177
+ allowedFileTypes: Object.freeze([]),
178
+ });
179
+
180
+ // SHA3-512 produces 64 bytes; named here so the chunk + manifest
181
+ // hash-shape checks read intentionally instead of as a raw 128.
182
+ var SHA3_512_HEX_LENGTH = C.BYTES.bytes(128);
183
+ // Cap on the bytes the per-upload sidecar files can grow to.
184
+ var SIDECAR_MAX_BYTES = C.BYTES.kib(256);
185
+ // Metadata cap — operators stash filename / mimeType / app-bag here.
186
+ // 64 KiB is generous for normal use and refuses payloads that look
187
+ // like the operator is trying to use the upload sidecar as a row store.
188
+ var METADATA_MAX_BYTES = C.BYTES.kib(64);
189
+
190
+ var UPLOAD_ID_RE = /^[A-Za-z0-9._-]+$/;
191
+ var UPLOAD_ID_MAX_LENGTH = C.BYTES.bytes(128);
192
+
193
+ function _validateUploadId(id) {
194
+ if (typeof id !== "string" ||
195
+ id.length === 0 ||
196
+ id.length > UPLOAD_ID_MAX_LENGTH ||
197
+ !UPLOAD_ID_RE.test(id)) {
198
+ var ID_PREVIEW_CHARS = C.BYTES.bytes(64);
199
+ throw _err("BAD_UPLOAD_ID",
200
+ "fileUpload: uploadId must be 1-128 chars matching " + UPLOAD_ID_RE +
201
+ " (path-traversal-hostile inputs refused before any filesystem op), got " +
202
+ JSON.stringify(typeof id === "string" ? id.slice(0, ID_PREVIEW_CHARS) : id));
203
+ }
204
+ return id;
205
+ }
206
+
207
+ function _validateCreateOpts(opts) {
208
+ validateOpts.requireObject(opts, "fileUpload.create", FileUploadError);
209
+ validateOpts.requireNonEmptyString(opts.stagingDir, "fileUpload.create: stagingDir", FileUploadError);
210
+ if (!path.isAbsolute(opts.stagingDir)) {
211
+ throw _err("BAD_OPT", "fileUpload.create: stagingDir must be an absolute path, got " +
212
+ JSON.stringify(opts.stagingDir));
213
+ }
214
+ validateOpts.optionalFunction(opts.onFinalize, "fileUpload.create: onFinalize", FileUploadError);
215
+ validateOpts.optionalFunction(opts.onChunk, "fileUpload.create: onChunk", FileUploadError);
216
+ numericBounds.requirePositiveFiniteIntIfPresent(opts.maxFileBytes,
217
+ "fileUpload.create: maxFileBytes", FileUploadError, "BAD_OPT");
218
+ numericBounds.requirePositiveFiniteIntIfPresent(opts.maxChunkBytes,
219
+ "fileUpload.create: maxChunkBytes", FileUploadError, "BAD_OPT");
220
+ numericBounds.requirePositiveFiniteIntIfPresent(opts.maxStreamReassemblyBytes,
221
+ "fileUpload.create: maxStreamReassemblyBytes", FileUploadError, "BAD_OPT");
222
+ numericBounds.requirePositiveFiniteIntIfPresent(opts.maxStagingBytes,
223
+ "fileUpload.create: maxStagingBytes", FileUploadError, "BAD_OPT");
224
+ numericBounds.requirePositiveFiniteIntIfPresent(opts.maxActiveUploadsPerActor,
225
+ "fileUpload.create: maxActiveUploadsPerActor", FileUploadError, "BAD_OPT");
226
+ numericBounds.requireNonNegativeFiniteIntIfPresent(opts.incompleteTtlMs,
227
+ "fileUpload.create: incompleteTtlMs", FileUploadError, "BAD_OPT");
228
+ numericBounds.requireNonNegativeFiniteIntIfPresent(opts.maxIdleMs,
229
+ "fileUpload.create: maxIdleMs", FileUploadError, "BAD_OPT");
230
+ numericBounds.requirePositiveFiniteIntIfPresent(opts.maxChunks,
231
+ "fileUpload.create: maxChunks", FileUploadError, "BAD_OPT");
232
+ validateOpts.auditShape(opts.audit, "fileUpload.create", FileUploadError);
233
+ validateOpts.observabilityShape(opts.observability, "fileUpload.create", FileUploadError);
234
+ validateOpts.optionalFunction(opts.clock, "fileUpload.create: clock", FileUploadError);
235
+ // allowedFileTypes — operator's MIME allowlist. Empty / undefined
236
+ // disables the gate. Setting it without wiring a fileType primitive
237
+ // is a misconfig — the gate would have nothing to enforce against.
238
+ validateOpts.optionalNonEmptyStringArray(opts.allowedFileTypes,
239
+ "fileUpload.create: allowedFileTypes", FileUploadError, "BAD_OPT");
240
+ if (Array.isArray(opts.allowedFileTypes) && opts.allowedFileTypes.length > 0 &&
241
+ (!opts.fileType || typeof opts.fileType.detect !== "function")) {
242
+ throw _err("BAD_OPT",
243
+ "fileUpload.create: allowedFileTypes is set but fileType primitive is not wired " +
244
+ "(pass fileType: b.fileType so the framework can sniff magic bytes at finalize)");
245
+ }
246
+ // permissions — when set, must expose check(actor, scope) → boolean.
247
+ validateOpts.optionalObjectWithMethod(opts.permissions, "check",
248
+ "fileUpload.create: permissions", FileUploadError, "BAD_OPT",
249
+ "must be a b.permissions instance (check fn)");
250
+ }
251
+
252
+ function create(opts) {
253
+ _validateCreateOpts(opts);
254
+ var cfg = validateOpts.applyDefaults(opts, DEFAULTS);
255
+ var stagingDir = opts.stagingDir;
256
+ var onFinalize = opts.onFinalize || null;
257
+ var onChunk = opts.onChunk || null;
258
+ var fileType = opts.fileType || null;
259
+ var permissions = opts.permissions || null;
260
+ var maxFileBytes = cfg.maxFileBytes;
261
+ var maxChunkBytes = cfg.maxChunkBytes;
262
+ var maxStreamReassemblyBytes = cfg.maxStreamReassemblyBytes;
263
+ var maxStagingBytes = cfg.maxStagingBytes;
264
+ var maxActiveUploadsPerActor = cfg.maxActiveUploadsPerActor;
265
+ var maxChunks = cfg.maxChunks;
266
+ var incompleteTtlMs = cfg.incompleteTtlMs;
267
+ var maxIdleMs = cfg.maxIdleMs;
268
+ var allowedFileTypes = cfg.allowedFileTypes;
269
+ var audit = opts.audit || null;
270
+ var clock = opts.clock || function () { return Date.now(); };
271
+
272
+ var _emitAudit = validateOpts.makeAuditEmitter(audit);
273
+ function _emitObs(name, value, labels) {
274
+ if (opts.observability) opts.observability.safeEvent(name, value, labels || {});
275
+ }
276
+
277
+ // Staging dir mode 0o700 — only the framework process reads its own
278
+ // staging files.
279
+ atomicFile.ensureDir(stagingDir, 0o700);
280
+
281
+ function _uploadDir(uploadId) { return path.join(stagingDir, uploadId); }
282
+ function _chunkPath(uploadId, index) { return path.join(_uploadDir(uploadId), String(index)); }
283
+ function _receivedPath(uploadId) { return path.join(_uploadDir(uploadId), "_received.json"); }
284
+ function _metaPath(uploadId) { return path.join(_uploadDir(uploadId), "_meta.json"); }
285
+
286
+ function _checkPermission(action, actor) {
287
+ if (!permissions) return;
288
+ var allowed;
289
+ try { allowed = permissions.check(actor, "fileUpload." + action); }
290
+ catch (_e) { allowed = false; }
291
+ if (!allowed) {
292
+ _emitObs("fileUpload.permission_denied", 1, { action: action });
293
+ throw _err("PERMISSION_DENIED",
294
+ "fileUpload." + action + ": actor lacks permission scope 'fileUpload." + action + "'");
295
+ }
296
+ }
297
+
298
+ function _readReceivedIndices(uploadId) {
299
+ var p = _receivedPath(uploadId);
300
+ if (!fs.existsSync(p)) return [];
301
+ try {
302
+ var raw = atomicFile.readSync(p, { maxBytes: SIDECAR_MAX_BYTES });
303
+ var parsed = safeJson.parse(raw.toString("utf8"));
304
+ return Array.isArray(parsed) ? parsed : [];
305
+ } catch (_e) { return []; }
306
+ }
307
+ function _writeReceivedIndices(uploadId, indices) {
308
+ atomicFile.writeSync(_receivedPath(uploadId), JSON.stringify(indices), { mode: 0o600 });
309
+ }
310
+
311
+ function _readMeta(uploadId) {
312
+ var p = _metaPath(uploadId);
313
+ if (!fs.existsSync(p)) return null;
314
+ try {
315
+ var raw = atomicFile.readSync(p, { maxBytes: SIDECAR_MAX_BYTES });
316
+ return safeJson.parse(raw.toString("utf8"));
317
+ } catch (_e) { return null; }
318
+ }
319
+ function _writeMeta(uploadId, meta) {
320
+ atomicFile.writeSync(_metaPath(uploadId), JSON.stringify(meta), { mode: 0o600 });
321
+ }
322
+
323
+ function _actorKey(actor) {
324
+ // Actor identity for quota grouping. id field if present; otherwise
325
+ // anonymous bucket. Operators with un-id'd actors share quota.
326
+ return (actor && (actor.id || actor.userId)) || "_anonymous";
327
+ }
328
+
329
+ function _enumerateUploads() {
330
+ if (!fs.existsSync(stagingDir)) return [];
331
+ var entries;
332
+ try { entries = atomicFile.listDir(stagingDir, { includeStat: true }); }
333
+ catch (_e) { return []; }
334
+ var uploads = [];
335
+ for (var i = 0; i < entries.length; i++) {
336
+ var e = entries[i];
337
+ if (!e.isDirectory) continue;
338
+ var meta = _readMeta(e.name);
339
+ uploads.push({
340
+ uploadId: e.name,
341
+ meta: meta,
342
+ mtimeMs: e.mtimeMs,
343
+ });
344
+ }
345
+ return uploads;
346
+ }
347
+
348
+ function _stagingTotalBytes() {
349
+ var uploads = _enumerateUploads();
350
+ var total = 0;
351
+ for (var i = 0; i < uploads.length; i++) {
352
+ total += (uploads[i].meta && uploads[i].meta.totalBytesAccepted) || 0;
353
+ }
354
+ return total;
355
+ }
356
+
357
+ function _activeUploadsForActor(actorId) {
358
+ var uploads = _enumerateUploads();
359
+ var count = 0;
360
+ for (var i = 0; i < uploads.length; i++) {
361
+ if (uploads[i].meta && uploads[i].meta.actorId === actorId) count += 1;
362
+ }
363
+ return count;
364
+ }
365
+
366
+ // ---- init ----
367
+
368
+ async function init(callerOpts) {
369
+ validateOpts.requireObject(callerOpts, "fileUpload.init", FileUploadError);
370
+ var uploadId = _validateUploadId(callerOpts.uploadId);
371
+ var actor = callerOpts.actor || null;
372
+ var metadata = callerOpts.metadata !== undefined ? callerOpts.metadata : {};
373
+
374
+ _checkPermission("init", actor);
375
+
376
+ if (typeof metadata !== "object" || metadata === null || Array.isArray(metadata)) {
377
+ throw _err("BAD_METADATA",
378
+ "fileUpload.init: metadata must be a plain object (operator app-bag)");
379
+ }
380
+ var metadataJson = JSON.stringify(metadata);
381
+ if (Buffer.byteLength(metadataJson, "utf8") > METADATA_MAX_BYTES) {
382
+ throw _err("METADATA_TOO_LARGE",
383
+ "fileUpload.init: metadata exceeds " + METADATA_MAX_BYTES + " bytes");
384
+ }
385
+
386
+ // Refuse re-init of an existing upload (caller-side bug).
387
+ if (fs.existsSync(_uploadDir(uploadId))) {
388
+ throw _err("UPLOAD_EXISTS",
389
+ "fileUpload.init: upload '" + uploadId + "' already exists; cancel or finalize first");
390
+ }
391
+
392
+ var actorId = _actorKey(actor);
393
+ if (_activeUploadsForActor(actorId) >= maxActiveUploadsPerActor) {
394
+ _emitObs("fileUpload.actor_quota_exceeded", 1);
395
+ throw _err("ACTOR_QUOTA_EXCEEDED",
396
+ "fileUpload.init: actor '" + actorId + "' has " + maxActiveUploadsPerActor +
397
+ " active uploads (cap maxActiveUploadsPerActor)");
398
+ }
399
+ if (_stagingTotalBytes() >= maxStagingBytes) {
400
+ _emitObs("fileUpload.staging_quota_exceeded", 1);
401
+ throw _err("STAGING_QUOTA_EXCEEDED",
402
+ "fileUpload.init: total staging exceeds " + maxStagingBytes + " bytes (maxStagingBytes)");
403
+ }
404
+
405
+ atomicFile.ensureDir(_uploadDir(uploadId), 0o700);
406
+ var now = clock();
407
+ var meta = {
408
+ uploadId: uploadId,
409
+ actorId: actorId,
410
+ metadata: metadata,
411
+ createdAt: now,
412
+ lastChunkAt: now,
413
+ totalBytesAccepted: 0,
414
+ };
415
+ _writeMeta(uploadId, meta);
416
+ _writeReceivedIndices(uploadId, []);
417
+
418
+ _emitObs("fileUpload.init", 1);
419
+ _emitAudit("fileUpload.init", {
420
+ actor: requestHelpers.extractActorContext(actor),
421
+ resource: { kind: "fileUpload", id: uploadId },
422
+ outcome: "success",
423
+ metadata: { metadata: metadata },
424
+ });
425
+
426
+ return {
427
+ uploadId: uploadId,
428
+ createdAt: now,
429
+ expiresAt: now + incompleteTtlMs,
430
+ };
431
+ }
432
+
433
+ // ---- acceptChunk ----
434
+
435
+ async function acceptChunk(callerOpts) {
436
+ validateOpts.requireObject(callerOpts, "fileUpload.acceptChunk", FileUploadError);
437
+ var uploadId = _validateUploadId(callerOpts.uploadId);
438
+ var index = callerOpts.index;
439
+ var body = callerOpts.body;
440
+ var sha3Hex = callerOpts.sha3;
441
+ var actor = callerOpts.actor;
442
+
443
+ _checkPermission("accept", actor);
444
+
445
+ var meta = _readMeta(uploadId);
446
+ if (!meta) {
447
+ throw _err("UNKNOWN_UPLOAD",
448
+ "fileUpload.acceptChunk: no init() seen for '" + uploadId + "'; call init() first");
449
+ }
450
+ if (clock() - meta.lastChunkAt > maxIdleMs) {
451
+ // Idle-timed-out — too much time since init or last chunk.
452
+ throw _err("UPLOAD_IDLE_EXPIRED",
453
+ "fileUpload.acceptChunk: upload '" + uploadId + "' exceeded maxIdleMs (" + maxIdleMs +
454
+ " ms since last chunk or init)");
455
+ }
456
+
457
+ if (!Number.isInteger(index) || index < 0 || index >= maxChunks) {
458
+ throw _err("BAD_INDEX",
459
+ "fileUpload.acceptChunk: index must be a non-negative integer < " + maxChunks +
460
+ ", got " + numericBounds.shape(index));
461
+ }
462
+ if (!Buffer.isBuffer(body)) {
463
+ throw _err("BAD_BODY",
464
+ "fileUpload.acceptChunk: body must be a Buffer, got " + typeof body);
465
+ }
466
+ if (body.length === 0) {
467
+ throw _err("EMPTY_CHUNK",
468
+ "fileUpload.acceptChunk: body is empty (0 bytes)");
469
+ }
470
+ if (body.length > maxChunkBytes) {
471
+ _emitObs("fileUpload.chunk_too_large", 1);
472
+ throw _err("CHUNK_TOO_LARGE",
473
+ "fileUpload.acceptChunk: chunk body is " + body.length +
474
+ " bytes, exceeds maxChunkBytes (" + maxChunkBytes + ")");
475
+ }
476
+ if (!safeBuffer.isHex(sha3Hex) || sha3Hex.length !== SHA3_512_HEX_LENGTH) {
477
+ throw _err("BAD_CHUNK_HASH",
478
+ "fileUpload.acceptChunk: sha3 must be a SHA3-512 hex string (" +
479
+ SHA3_512_HEX_LENGTH + " chars); got " +
480
+ (typeof sha3Hex === "string" ? sha3Hex.length + " chars" : typeof sha3Hex));
481
+ }
482
+
483
+ // Verify chunk hash matches the supplied header.
484
+ var actualHex = crypto.sha3Hash(body);
485
+ if (!crypto.timingSafeEqual(actualHex, sha3Hex)) {
486
+ _emitObs("fileUpload.chunk_hash_mismatch", 1);
487
+ _emitAudit("fileUpload.chunk_received", {
488
+ actor: requestHelpers.extractActorContext(actor),
489
+ resource: { kind: "fileUpload", id: uploadId },
490
+ outcome: "failure",
491
+ reason: "chunk-hash-mismatch",
492
+ metadata: { index: index, size: body.length },
493
+ });
494
+ throw _err("CHUNK_HASH_MISMATCH",
495
+ "fileUpload.acceptChunk: chunk SHA3-512 mismatch — supplied does not equal computed");
496
+ }
497
+
498
+ // Per-chunk operator hook (e.g. virus scan, schema check). May
499
+ // throw to refuse the chunk.
500
+ if (onChunk) {
501
+ try {
502
+ await onChunk({
503
+ uploadId: uploadId,
504
+ index: index,
505
+ body: body,
506
+ sha3: actualHex,
507
+ actor: actor,
508
+ metadata: meta.metadata,
509
+ });
510
+ } catch (e) {
511
+ _emitObs("fileUpload.onchunk_rejected", 1);
512
+ _emitAudit("fileUpload.chunk_received", {
513
+ actor: requestHelpers.extractActorContext(actor),
514
+ resource: { kind: "fileUpload", id: uploadId },
515
+ outcome: "failure",
516
+ reason: "onchunk-rejected",
517
+ metadata: { index: index, size: body.length,
518
+ error: (e && e.message) || String(e) },
519
+ });
520
+ throw e;
521
+ }
522
+ }
523
+
524
+ // Idempotent re-PUT: if this index is already received with a
525
+ // matching body, no-op. Different body = caller bug.
526
+ var p = _chunkPath(uploadId, index);
527
+ if (fs.existsSync(p)) {
528
+ var existing = atomicFile.readSync(p, { maxBytes: maxChunkBytes });
529
+ if (crypto.timingSafeEqual(crypto.sha3Hash(existing), sha3Hex)) {
530
+ return {
531
+ received: _readReceivedIndices(uploadId).length,
532
+ totalBytesAccepted: meta.totalBytesAccepted,
533
+ status: "in-progress",
534
+ duplicate: true,
535
+ };
536
+ }
537
+ throw _err("CHUNK_REUSE_MISMATCH",
538
+ "fileUpload.acceptChunk: chunk " + index +
539
+ " already received with a different body (caller-side bug; refusing overwrite)");
540
+ }
541
+
542
+ atomicFile.writeSync(p, body, { mode: 0o600 });
543
+ var receivedIndices = _readReceivedIndices(uploadId);
544
+ if (receivedIndices.indexOf(index) === -1) {
545
+ receivedIndices.push(index);
546
+ _writeReceivedIndices(uploadId, receivedIndices);
547
+ }
548
+
549
+ // Update meta.
550
+ meta.lastChunkAt = clock();
551
+ meta.totalBytesAccepted = (meta.totalBytesAccepted || 0) + body.length;
552
+ if (meta.totalBytesAccepted > maxFileBytes) {
553
+ // Reclaim staging — the upload exceeded the cap mid-stream.
554
+ try { fs.rmSync(_uploadDir(uploadId), { recursive: true, force: true }); }
555
+ catch (_e) { /* purgeIncomplete will reclaim */ }
556
+ _emitObs("fileUpload.file_too_large", 1);
557
+ throw _err("FILE_TOO_LARGE",
558
+ "fileUpload.acceptChunk: cumulative upload exceeded maxFileBytes (" + maxFileBytes +
559
+ "); upload reclaimed");
560
+ }
561
+ _writeMeta(uploadId, meta);
562
+
563
+ _emitObs("fileUpload.chunks_received", 1);
564
+ _emitObs("fileUpload.bytes_received", body.length);
565
+ _emitAudit("fileUpload.chunk_received", {
566
+ actor: requestHelpers.extractActorContext(actor),
567
+ resource: { kind: "fileUpload", id: uploadId },
568
+ outcome: "success",
569
+ metadata: { index: index, size: body.length },
570
+ });
571
+
572
+ return {
573
+ received: receivedIndices.length,
574
+ totalBytesAccepted: meta.totalBytesAccepted,
575
+ status: "in-progress",
576
+ };
577
+ }
578
+
579
+ // ---- finalize ----
580
+
581
+ function _validateManifest(manifest) {
582
+ validateOpts.requireObject(manifest, "fileUpload.finalize: manifest", FileUploadError);
583
+ if (!Array.isArray(manifest.chunks) || manifest.chunks.length === 0) {
584
+ throw _err("BAD_MANIFEST",
585
+ "fileUpload.finalize: manifest.chunks must be a non-empty array");
586
+ }
587
+ if (manifest.chunks.length > maxChunks) {
588
+ throw _err("TOO_MANY_CHUNKS",
589
+ "fileUpload.finalize: manifest declares " + manifest.chunks.length +
590
+ " chunks, exceeds maxChunks (" + maxChunks + ")");
591
+ }
592
+ if (!Number.isInteger(manifest.totalBytes) || manifest.totalBytes <= 0) {
593
+ throw _err("BAD_MANIFEST",
594
+ "fileUpload.finalize: manifest.totalBytes must be a positive integer");
595
+ }
596
+ if (manifest.totalBytes > maxFileBytes) {
597
+ throw _err("FILE_TOO_LARGE",
598
+ "fileUpload.finalize: manifest.totalBytes (" + manifest.totalBytes +
599
+ ") exceeds maxFileBytes (" + maxFileBytes + ")");
600
+ }
601
+ if (!safeBuffer.isHex(manifest.sha3) || manifest.sha3.length !== SHA3_512_HEX_LENGTH) {
602
+ throw _err("BAD_MANIFEST",
603
+ "fileUpload.finalize: manifest.sha3 must be a SHA3-512 hex string (" +
604
+ SHA3_512_HEX_LENGTH + " chars)");
605
+ }
606
+ }
607
+
608
+ function _verifyChunksOnDisk(uploadId, manifest) {
609
+ // Returns sorted chunk paths + verifies per-chunk + total hash.
610
+ // For small uploads we walk + concat; for large we just walk and
611
+ // verify, returning paths so the streaming path can read on
612
+ // demand.
613
+ var sortedChunks = manifest.chunks.slice().sort(function (a, b) {
614
+ return a.index - b.index;
615
+ });
616
+ var paths = [];
617
+ var hasher = require("node:crypto").createHash("sha3-512");
618
+ var totalBytes = 0;
619
+
620
+ for (var i = 0; i < sortedChunks.length; i++) {
621
+ var ck = sortedChunks[i];
622
+ if (!Number.isInteger(ck.index) || ck.index !== i) {
623
+ throw _err("MANIFEST_INDEX_GAP",
624
+ "fileUpload.finalize: chunk " + i + " in manifest has index " + ck.index +
625
+ " (expected " + i + " — chunk indices must be 0..N-1 contiguous)");
626
+ }
627
+ if (!safeBuffer.isHex(ck.sha3) || ck.sha3.length !== SHA3_512_HEX_LENGTH) {
628
+ throw _err("BAD_MANIFEST",
629
+ "fileUpload.finalize: chunk " + i + ".sha3 must be a SHA3-512 hex string (" +
630
+ SHA3_512_HEX_LENGTH + " chars)");
631
+ }
632
+ var chunkPath = _chunkPath(uploadId, ck.index);
633
+ if (!fs.existsSync(chunkPath)) {
634
+ throw _err("MISSING_CHUNK",
635
+ "fileUpload.finalize: chunk " + ck.index + " missing from staging");
636
+ }
637
+ var chunkBody = atomicFile.readSync(chunkPath, { maxBytes: maxChunkBytes });
638
+ var actualChunkHex = crypto.sha3Hash(chunkBody);
639
+ if (!crypto.timingSafeEqual(actualChunkHex, ck.sha3)) {
640
+ throw _err("CHUNK_HASH_MISMATCH",
641
+ "fileUpload.finalize: chunk " + ck.index +
642
+ " on-disk SHA3-512 doesn't match manifest");
643
+ }
644
+ paths.push(chunkPath);
645
+ totalBytes += chunkBody.length;
646
+ if (totalBytes > maxFileBytes) {
647
+ throw _err("FILE_TOO_LARGE",
648
+ "fileUpload.finalize: reassembly exceeds maxFileBytes mid-walk");
649
+ }
650
+ hasher.update(chunkBody);
651
+ }
652
+ if (totalBytes !== manifest.totalBytes) {
653
+ throw _err("MANIFEST_SIZE_MISMATCH",
654
+ "fileUpload.finalize: reassembled " + totalBytes +
655
+ " bytes, manifest declares " + manifest.totalBytes);
656
+ }
657
+ var totalHashHex = hasher.digest("hex");
658
+ if (!crypto.timingSafeEqual(totalHashHex, manifest.sha3)) {
659
+ throw _err("MANIFEST_HASH_MISMATCH",
660
+ "fileUpload.finalize: reassembled SHA3-512 doesn't match manifest.sha3");
661
+ }
662
+ return { paths: paths, totalBytes: totalBytes, totalHashHex: totalHashHex };
663
+ }
664
+
665
+ function _checkAllowedFileType(firstChunkBody) {
666
+ if (!allowedFileTypes || allowedFileTypes.length === 0) return;
667
+ if (!fileType) return; // create() guards this; defensive
668
+ var detected = fileType.detect(firstChunkBody);
669
+ var detectedMime = detected && detected.mime;
670
+ if (!detectedMime) {
671
+ throw _err("MIME_NOT_DETECTED",
672
+ "fileUpload.finalize: could not classify magic bytes against allowedFileTypes");
673
+ }
674
+ var ok = false;
675
+ for (var i = 0; i < allowedFileTypes.length; i++) {
676
+ var allowed = allowedFileTypes[i];
677
+ if (allowed === detectedMime) { ok = true; break; }
678
+ // Wildcard support: "image/*" matches "image/png".
679
+ if (allowed.endsWith("/*")) {
680
+ var prefix = allowed.slice(0, -1); // "image/"
681
+ if (detectedMime.indexOf(prefix) === 0) { ok = true; break; }
682
+ }
683
+ }
684
+ if (!ok) {
685
+ throw _err("MIME_NOT_ALLOWED",
686
+ "fileUpload.finalize: detected MIME '" + detectedMime +
687
+ "' not in allowedFileTypes (" + allowedFileTypes.join(", ") + ")");
688
+ }
689
+ }
690
+
691
+ function _streamFromChunkPaths(paths /* totalBytes */) {
692
+ // Sequential ReadableStream over chunk files. Operator's
693
+ // onFinalize reads through to wherever they're piping.
694
+ async function* generate() {
695
+ for (var i = 0; i < paths.length; i += 1) {
696
+ var fh = fs.createReadStream(paths[i]);
697
+ for await (var chunk of fh) {
698
+ yield chunk;
699
+ }
700
+ }
701
+ }
702
+ return stream.Readable.from(generate(), { objectMode: false });
703
+ }
704
+
705
+ async function finalize(callerOpts) {
706
+ validateOpts.requireObject(callerOpts, "fileUpload.finalize", FileUploadError);
707
+ var uploadId = _validateUploadId(callerOpts.uploadId);
708
+ var manifest = callerOpts.manifest;
709
+ var actor = callerOpts.actor;
710
+
711
+ _checkPermission("finalize", actor);
712
+
713
+ var meta = _readMeta(uploadId);
714
+ if (!meta) {
715
+ throw _err("UNKNOWN_UPLOAD",
716
+ "fileUpload.finalize: no init() seen for '" + uploadId + "'");
717
+ }
718
+
719
+ _validateManifest(manifest);
720
+
721
+ var verified = _verifyChunksOnDisk(uploadId, manifest);
722
+
723
+ // Decide buffer-vs-stream based on size.
724
+ var useStream = verified.totalBytes > maxStreamReassemblyBytes;
725
+ var bodyBuffer = null;
726
+ var bodyStream = null;
727
+ var firstChunk = null;
728
+
729
+ if (useStream) {
730
+ // Read just the first chunk for the MIME sniff; the operator
731
+ // gets the stream for the actual data.
732
+ firstChunk = atomicFile.readSync(verified.paths[0], { maxBytes: maxChunkBytes });
733
+ bodyStream = _streamFromChunkPaths(verified.paths, verified.totalBytes);
734
+ } else {
735
+ // Small enough to assemble in memory. Buffer.concat.
736
+ var pieces = [];
737
+ for (var i = 0; i < verified.paths.length; i++) {
738
+ pieces.push(atomicFile.readSync(verified.paths[i], { maxBytes: maxChunkBytes }));
739
+ }
740
+ bodyBuffer = Buffer.concat(pieces, verified.totalBytes);
741
+ firstChunk = pieces[0];
742
+ }
743
+
744
+ // MIME allowlist gate (if configured).
745
+ try { _checkAllowedFileType(firstChunk); }
746
+ catch (e) {
747
+ _emitObs("fileUpload.mime_rejected", 1);
748
+ _emitAudit("fileUpload.finalize", {
749
+ actor: requestHelpers.extractActorContext(actor),
750
+ resource: { kind: "fileUpload", id: uploadId },
751
+ outcome: "failure",
752
+ reason: "mime-not-allowed",
753
+ metadata: { size: verified.totalBytes,
754
+ error: (e && e.message) || String(e) },
755
+ });
756
+ throw e;
757
+ }
758
+
759
+ // Hand to operator's onFinalize.
760
+ var rv;
761
+ try {
762
+ if (onFinalize) {
763
+ rv = await onFinalize({
764
+ uploadId: uploadId,
765
+ body: bodyBuffer,
766
+ stream: bodyStream,
767
+ sha3: verified.totalHashHex,
768
+ size: verified.totalBytes,
769
+ actor: actor,
770
+ metadata: meta.metadata,
771
+ });
772
+ } else {
773
+ rv = { ok: true, sha3: verified.totalHashHex, size: verified.totalBytes };
774
+ }
775
+ } catch (e) {
776
+ _emitObs("fileUpload.finalize_failure", 1);
777
+ _emitAudit("fileUpload.finalize", {
778
+ actor: requestHelpers.extractActorContext(actor),
779
+ resource: { kind: "fileUpload", id: uploadId },
780
+ outcome: "failure",
781
+ reason: "onfinalize-threw",
782
+ metadata: { size: verified.totalBytes, sha3: verified.totalHashHex,
783
+ error: (e && e.message) || String(e) },
784
+ });
785
+ throw e;
786
+ }
787
+
788
+ // Cleanup staging on success.
789
+ try { fs.rmSync(_uploadDir(uploadId), { recursive: true, force: true }); }
790
+ catch (_e) { /* best-effort */ }
791
+
792
+ _emitObs("fileUpload.finalize_success", 1);
793
+ _emitObs("fileUpload.finalize_bytes", verified.totalBytes);
794
+ _emitAudit("fileUpload.finalize", {
795
+ actor: requestHelpers.extractActorContext(actor),
796
+ resource: { kind: "fileUpload", id: uploadId },
797
+ outcome: "success",
798
+ metadata: { size: verified.totalBytes, sha3: verified.totalHashHex,
799
+ mode: useStream ? "stream" : "buffer" },
800
+ });
801
+
802
+ return rv;
803
+ }
804
+
805
+ // ---- status / list / cancel ----
806
+
807
+ function status(uploadId, callerOpts) {
808
+ callerOpts = callerOpts || {};
809
+ _validateUploadId(uploadId);
810
+ _checkPermission("status", callerOpts.actor);
811
+ var meta = _readMeta(uploadId);
812
+ if (!meta) return null;
813
+ var indices = _readReceivedIndices(uploadId).slice().sort(function (a, b) { return a - b; });
814
+ return {
815
+ uploadId: uploadId,
816
+ received: indices,
817
+ totalBytesAccepted: meta.totalBytesAccepted || 0,
818
+ createdAt: meta.createdAt,
819
+ lastChunkAt: meta.lastChunkAt,
820
+ metadata: meta.metadata || {},
821
+ expiresAt: meta.createdAt + incompleteTtlMs,
822
+ };
823
+ }
824
+
825
+ function list(callerOpts) {
826
+ callerOpts = callerOpts || {};
827
+ _checkPermission("list", callerOpts.actor);
828
+ var actorFilter = callerOpts.actor && (callerOpts.actor.id || callerOpts.actor.userId);
829
+ var sinceMs = (typeof callerOpts.since === "number") ? callerOpts.since : 0;
830
+ var uploads = _enumerateUploads();
831
+ var out = [];
832
+ for (var i = 0; i < uploads.length; i++) {
833
+ var u = uploads[i];
834
+ if (!u.meta) continue;
835
+ if (sinceMs && u.meta.createdAt < sinceMs) continue;
836
+ if (actorFilter && callerOpts.scopeToActor !== false && u.meta.actorId !== actorFilter) continue;
837
+ out.push({
838
+ uploadId: u.meta.uploadId,
839
+ actorId: u.meta.actorId,
840
+ metadata: u.meta.metadata || {},
841
+ createdAt: u.meta.createdAt,
842
+ lastChunkAt: u.meta.lastChunkAt,
843
+ totalBytesAccepted: u.meta.totalBytesAccepted || 0,
844
+ });
845
+ }
846
+ return out;
847
+ }
848
+
849
+ async function cancelUpload(uploadId, callerOpts) {
850
+ callerOpts = callerOpts || {};
851
+ _validateUploadId(uploadId);
852
+ _checkPermission("cancel", callerOpts.actor);
853
+ var meta = _readMeta(uploadId);
854
+ if (!meta) return { ok: false, uploadId: uploadId, reason: "not-found" };
855
+ try { fs.rmSync(_uploadDir(uploadId), { recursive: true, force: true }); }
856
+ catch (_e) { /* best-effort */ }
857
+ _emitObs("fileUpload.cancelled", 1);
858
+ _emitAudit("fileUpload.cancelled", {
859
+ actor: requestHelpers.extractActorContext(callerOpts.actor),
860
+ resource: { kind: "fileUpload", id: uploadId },
861
+ outcome: "success",
862
+ metadata: { totalBytesAccepted: meta.totalBytesAccepted || 0 },
863
+ });
864
+ return { ok: true, uploadId: uploadId };
865
+ }
866
+
867
+ // ---- purgeIncomplete ----
868
+
869
+ function purgeIncomplete() {
870
+ if (!fs.existsSync(stagingDir)) return { purged: 0, ids: [] };
871
+ var now = clock();
872
+ var entries;
873
+ try { entries = atomicFile.listDir(stagingDir, { includeStat: true }); }
874
+ catch (_e) { return { purged: 0, ids: [] }; }
875
+ var purged = [];
876
+ for (var i = 0; i < entries.length; i++) {
877
+ var e = entries[i];
878
+ if (!e.isDirectory) continue;
879
+ var meta = _readMeta(e.name);
880
+ var purgeReason = null;
881
+ if (meta) {
882
+ if (now - meta.createdAt > incompleteTtlMs) purgeReason = "ttl-exceeded";
883
+ else if (now - meta.lastChunkAt > maxIdleMs) purgeReason = "idle-exceeded";
884
+ } else {
885
+ // No meta sidecar → orphaned dir from a prior version or
886
+ // failed init. Reclaim by mtime.
887
+ if (now - e.mtimeMs > incompleteTtlMs) purgeReason = "orphan";
888
+ }
889
+ if (!purgeReason) continue;
890
+ try {
891
+ fs.rmSync(e.fullPath, { recursive: true, force: true });
892
+ purged.push({ id: e.name, reason: purgeReason });
893
+ } catch (_e2) { /* best-effort; will retry */ }
894
+ }
895
+ if (purged.length > 0) {
896
+ _emitObs("fileUpload.purged_incomplete", purged.length);
897
+ _emitAudit("fileUpload.purged", {
898
+ actor: { kind: "framework" },
899
+ resource: { kind: "fileUpload", id: stagingDir },
900
+ outcome: "success",
901
+ metadata: { purgedIds: purged.map(function (p) { return p.id; }),
902
+ count: purged.length },
903
+ });
904
+ }
905
+ return {
906
+ purged: purged.length,
907
+ ids: purged.map(function (p) { return p.id; }),
908
+ reasons: purged,
909
+ };
910
+ }
911
+
912
+ function close() {
913
+ // Lifecycle parity. No timers / connections to release.
914
+ }
915
+
916
+ return {
917
+ init: init,
918
+ acceptChunk: acceptChunk,
919
+ finalize: finalize,
920
+ status: status,
921
+ list: list,
922
+ cancelUpload: cancelUpload,
923
+ purgeIncomplete: purgeIncomplete,
924
+ close: close,
925
+ };
926
+ }
927
+
928
+ module.exports = {
929
+ create: create,
930
+ FileUploadError: FileUploadError,
931
+ DEFAULTS: DEFAULTS,
932
+ UPLOAD_ID_RE: UPLOAD_ID_RE,
933
+ };