@blamejs/core 0.7.88 → 0.7.89

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.7.x
10
10
 
11
+ - **0.7.89** (2026-05-06) — Three additive primitives bundled: TUS resumable uploads, WebAuthn Signal API, DPoP server-issued nonce challenge. **`b.middleware.tusUpload({ mountPath, store, ... })`** implements the [tus.io](https://tus.io) v1.0.0 resumable-upload protocol — POST creates uploads, HEAD reports offsets, PATCH appends chunks, DELETE terminates. Supported extensions: `creation`, `creation-with-upload`, `expiration`, `checksum`, `termination`. The `checksum` extension defaults to PQC-first algorithms (`sha3-512`, `shake256`) — operators add classical algorithms explicitly via `checksumAlgorithms`. A built-in `b.middleware.tusUpload.memoryStore({ maxSize })` ships for development; production operators implement the `{ create, head, append, setLength, terminate, purgeExpired, getBuffer }` shape against their object-store backend. Bounded chunk collection routes through `safeBuffer.boundedChunkCollector` (cap-enforced at push time, no 10-GiB pre-collect). Concatenation extension (parallel-chunk assembly) deferred — operators that need it compose against their store layer; re-open if a store-layer-only solution proves insufficient. **`b.auth.passkey.signalUnknownCredential` / `signalAllAcceptedCredentials` / `signalCurrentUserDetails`** add the W3C WebAuthn Signal API descriptor builders — when the browser implements `PublicKeyCredential.signal*`, operators emit the matching JSON descriptor to clean up stale passkeys, refresh user details, and surface revocations without forcing a re-registration. All three validate `rpId` / `userId` / `credentialId` shape (base64url) and refuse `name`/`displayName` longer than 256 chars. **`b.middleware.dpop({ requireNonce: true, nonceRotateSec? })`** implements RFC 9449 §8 server-issued DPoP-Nonce challenge — the middleware emits `DPoP-Nonce: <fresh>` on every 401 response, refuses proofs whose `nonce` claim isn't in the rolling current+previous pair, and refreshes the nonce on every successful response. The rolling-pair manager rotates without timers (lazy maybe-rotate on access); no operator nonce store needed. The `getNonce` callback path stays intact for operator-managed nonce flows.
12
+
11
13
  - **0.7.88** (2026-05-06) — `b.middleware.webAppManifest` + `b.middleware.assetlinks` — two static-content middlewares for PWA + Trusted Web Activity support. **`b.middleware.webAppManifest({ name, start_url, icons, ... })`** serves the W3C Web App Manifest at `/manifest.webmanifest` (and `/manifest.json` when `alsoAtJsonPath: true`). The framework JSON-serializes once at create() and serves with `Content-Type: application/manifest+json` per the W3C spec + `Cache-Control: public, max-age=86400` + `X-Content-Type-Options: nosniff`. The W3C-spec attribute set is allowlisted (name / short_name / description / start_url / scope / display / display_override / orientation / theme_color / background_color / icons / screenshots / shortcuts / categories / lang / dir / id / prefer_related_applications / related_applications) — typos throw at create. `name`, `start_url`, and at least one icon are required (W3C — installability minimum). HEAD + GET only. **`b.middleware.assetlinks({ statements })`** serves Digital Asset Links at `/.well-known/assetlinks.json` per Google's spec — used by Trusted Web Activity, Android App Links, Smart Lock for Passwords, WebAuthn for Android. Validates each statement carries `relation` (non-empty array) and `target` (object). Same Content-Type / Cache-Control / X-Content-Type-Options posture as the security.txt + manifest emitters.
12
14
 
13
15
  - **0.7.87** (2026-05-06) — Two route-guard middlewares for API hardening: `b.middleware.requireMethods` + `b.middleware.requireContentType`. **`b.middleware.requireMethods(["GET", "POST"])`** refuses any HTTP method outside the allowlist with `405 Method Not Allowed` + `Allow:` header listing the allowed methods (per RFC 9110 §15.5.6). Defends against unexpected verb routing — many CVE-class bugs trace to a route handler wired for GET that accidentally accepts arbitrary verbs (PROPFIND, OPTIONS, custom). **`b.middleware.requireContentType(["application/json"])`** refuses requests with a body (POST/PUT/PATCH by default) whose `Content-Type` isn't in the allowlist with `415 Unsupported Media Type` + `Accept:` header listing the allowed types (RFC 9110 §15.5.16). Defends against MIME-type confusion — a route that processes JSON shouldn't accept `application/x-www-form-urlencoded` even if the body parses. Both middlewares emit observability events (`middleware.requireMethods.denied` / `middleware.requireContentType.denied`) on every refusal for triage. Operators wanting to enforce content-type on idempotent verbs that DO carry bodies (rare DELETE-with-body shapes) override the default body-method list via `requireContentType(types, { methods })`.
@@ -57,9 +57,15 @@
57
57
  * sessions, audit, or DB. Routes integrate that themselves; the
58
58
  * primitive stays the smallest correct surface.
59
59
  */
60
+ var safeBuffer = require("../safe-buffer");
60
61
  var _wa = require("../vendor/simplewebauthn-server.cjs");
61
62
  var { AuthError } = require("../framework-error");
62
63
 
64
+ // W3C WebAuthn name field cap — same as the rpName/userName ceiling in
65
+ // the spec's CredentialUserEntity / PublicKeyCredentialEntity dictionaries
66
+ // (no normative limit but RPs broadly cap at 256 to defeat DOM cost).
67
+ var MAX_NAME_LEN = 256; // allow:raw-byte-literal — UTF-16 codepoint count, not bytes
68
+
63
69
  function _vendor() {
64
70
  return _wa;
65
71
  }
@@ -173,9 +179,93 @@ async function verifyAuthentication(opts) {
173
179
  });
174
180
  }
175
181
 
182
+ // ---- WebAuthn Signal API (W3C draft, 2024) ----
183
+ //
184
+ // The signal* methods build the JSON descriptor that the operator
185
+ // returns to the client; the browser then calls the matching
186
+ // `PublicKeyCredential.signal*` method to clean up stale passkeys
187
+ // and refresh user details. These are pure builders — no I/O — so
188
+ // validation throws at the boundary and the descriptor shape is the
189
+ // W3C draft schema verbatim.
190
+
191
+ function _b64urlValid(s) {
192
+ return typeof s === "string" && s.length > 0 && safeBuffer.BASE64URL_RE.test(s);
193
+ }
194
+
195
+ function signalUnknownCredential(opts) {
196
+ if (!opts) throw new AuthError("auth-passkey/missing-opts", "opts is required");
197
+ _requireString(opts.rpId, "rpId");
198
+ _requireString(opts.credentialId, "credentialId");
199
+ if (!_b64urlValid(opts.credentialId)) {
200
+ throw new AuthError("auth-passkey/bad-credential-id",
201
+ "credentialId must be base64url (no padding)");
202
+ }
203
+ return {
204
+ rpId: opts.rpId,
205
+ credentialId: opts.credentialId,
206
+ };
207
+ }
208
+
209
+ function signalAllAcceptedCredentials(opts) {
210
+ if (!opts) throw new AuthError("auth-passkey/missing-opts", "opts is required");
211
+ _requireString(opts.rpId, "rpId");
212
+ _requireString(opts.userId, "userId");
213
+ if (!_b64urlValid(opts.userId)) {
214
+ throw new AuthError("auth-passkey/bad-user-id",
215
+ "userId must be base64url (no padding)");
216
+ }
217
+ if (!Array.isArray(opts.allAcceptedCredentialIds)) {
218
+ throw new AuthError("auth-passkey/bad-accepted-list",
219
+ "allAcceptedCredentialIds must be an array");
220
+ }
221
+ for (var i = 0; i < opts.allAcceptedCredentialIds.length; i++) {
222
+ if (!_b64urlValid(opts.allAcceptedCredentialIds[i])) {
223
+ throw new AuthError("auth-passkey/bad-accepted-list",
224
+ "allAcceptedCredentialIds[" + i + "] must be base64url");
225
+ }
226
+ }
227
+ return {
228
+ rpId: opts.rpId,
229
+ userId: opts.userId,
230
+ allAcceptedCredentialIds: opts.allAcceptedCredentialIds.slice(),
231
+ };
232
+ }
233
+
234
+ function signalCurrentUserDetails(opts) {
235
+ if (!opts) throw new AuthError("auth-passkey/missing-opts", "opts is required");
236
+ _requireString(opts.rpId, "rpId");
237
+ _requireString(opts.userId, "userId");
238
+ if (!_b64urlValid(opts.userId)) {
239
+ throw new AuthError("auth-passkey/bad-user-id",
240
+ "userId must be base64url (no padding)");
241
+ }
242
+ _requireString(opts.name, "name");
243
+ _requireString(opts.displayName, "displayName");
244
+ // RP-relevant length cap — the descriptor is a hint to the browser,
245
+ // not a stored value, but absurdly long names indicate a misuse and
246
+ // we refuse rather than truncate silently.
247
+ if (opts.name.length > MAX_NAME_LEN) {
248
+ throw new AuthError("auth-passkey/name-too-long",
249
+ "name must be <= " + MAX_NAME_LEN + " characters");
250
+ }
251
+ if (opts.displayName.length > MAX_NAME_LEN) {
252
+ throw new AuthError("auth-passkey/displayname-too-long",
253
+ "displayName must be <= " + MAX_NAME_LEN + " characters");
254
+ }
255
+ return {
256
+ rpId: opts.rpId,
257
+ userId: opts.userId,
258
+ name: opts.name,
259
+ displayName: opts.displayName,
260
+ };
261
+ }
262
+
176
263
  module.exports = {
177
- startRegistration: startRegistration,
178
- verifyRegistration: verifyRegistration,
179
- startAuthentication: startAuthentication,
180
- verifyAuthentication: verifyAuthentication,
264
+ startRegistration: startRegistration,
265
+ verifyRegistration: verifyRegistration,
266
+ startAuthentication: startAuthentication,
267
+ verifyAuthentication: verifyAuthentication,
268
+ signalUnknownCredential: signalUnknownCredential,
269
+ signalAllAcceptedCredentials: signalAllAcceptedCredentials,
270
+ signalCurrentUserDetails: signalCurrentUserDetails,
181
271
  };
@@ -35,6 +35,8 @@
35
35
  * - audit.bearer.failure event when audit: true (default)
36
36
  */
37
37
 
38
+ var C = require("../constants");
39
+ var bCrypto = require("../crypto");
38
40
  var lazyRequire = require("../lazy-require");
39
41
  var requestHelpers = require("../request-helpers");
40
42
  var validateOpts = require("../validate-opts");
@@ -43,20 +45,63 @@ var { AuthError } = require("../framework-error");
43
45
  var dpop = lazyRequire(function () { return require("../auth/dpop"); });
44
46
  var audit = lazyRequire(function () { return require("../audit"); });
45
47
 
46
- function _writeUnauthorized(res, errorCode, description) {
48
+ // RFC 9449 §8 — server-issued nonce length (24 random bytes ≈ 192 bits
49
+ // of entropy after base64url, far above the spec's "unpredictable" bar).
50
+ var DPOP_NONCE_BYTES = C.BYTES.bytes(24);
51
+
52
+ function _writeUnauthorized(res, errorCode, description, freshNonce) {
47
53
  if (res.headersSent) return;
48
54
  var body = JSON.stringify({ error: errorCode, error_description: description });
49
55
  // RFC 9449 §7 — error code is invalid_dpop_proof OR use_dpop_nonce.
50
56
  var challenge = 'DPoP error="' + errorCode + '", error_description="' +
51
57
  description.replace(/"/g, "'") + '"';
52
- res.writeHead(401, { // allow:raw-byte-literal — HTTP 401 status
58
+ var headers = { // allow:raw-byte-literal — HTTP 401 status
53
59
  "Content-Type": "application/json; charset=utf-8",
54
60
  "Content-Length": Buffer.byteLength(body),
55
61
  "WWW-Authenticate": challenge,
56
- });
62
+ };
63
+ if (freshNonce) headers["DPoP-Nonce"] = freshNonce;
64
+ res.writeHead(401, headers);
57
65
  res.end(body);
58
66
  }
59
67
 
68
+ // RFC 9449 §8 — server-issued DPoP-Nonce challenge. The framework
69
+ // holds a rolling pair (current, previous) and rotates after
70
+ // rotateSec elapses. Both the current and previous values are
71
+ // accepted from clients; previous is needed to cover the brief
72
+ // race window after rotation when in-flight requests still carry
73
+ // the prior nonce. Rotation happens lazily on access; no timer.
74
+ function _nonceManager(rotateSec) {
75
+ var rotateMs = C.TIME.seconds(rotateSec);
76
+ var current = null;
77
+ var previous = null;
78
+ function _fresh() {
79
+ return {
80
+ nonce: bCrypto.generateBytes(DPOP_NONCE_BYTES).toString("base64url"),
81
+ issuedAt: Date.now(),
82
+ };
83
+ }
84
+ function _maybeRotate() {
85
+ var now = Date.now();
86
+ if (current === null) {
87
+ current = _fresh();
88
+ return;
89
+ }
90
+ if (now - current.issuedAt >= rotateMs) {
91
+ previous = current;
92
+ current = _fresh();
93
+ }
94
+ }
95
+ return {
96
+ issue: function () { _maybeRotate(); return current.nonce; },
97
+ accepts: function (n) {
98
+ _maybeRotate();
99
+ if (typeof n !== "string" || n.length === 0) return false;
100
+ return (current && n === current.nonce) || (previous && n === previous.nonce);
101
+ },
102
+ };
103
+ }
104
+
60
105
  function _reconstructHtu(req) {
61
106
  // The proof's htu is the request URI WITHOUT query/fragment. Behind
62
107
  // a reverse proxy the operator may need to override via opts.htu /
@@ -77,12 +122,38 @@ function create(opts) {
77
122
  validateOpts(opts, [
78
123
  "replayStore", "algorithms", "iatWindowSec",
79
124
  "getAccessToken", "getNonce", "getHtu", "audit",
125
+ "nonceStore", "nonceWindowSec", "nonceRotateSec", "requireNonce",
80
126
  ], "middleware.dpop");
81
127
 
82
128
  var auditOn = opts.audit !== false;
83
129
  var algorithms = opts.algorithms;
84
130
  var iatWindowSec = opts.iatWindowSec;
85
131
  var replayStore = opts.replayStore;
132
+ var requireNonce = opts.requireNonce === true;
133
+
134
+ // Server-issued DPoP-Nonce challenge flow (RFC 9449 §8). When
135
+ // requireNonce is true, the middleware refuses any proof that does
136
+ // not carry a recognised nonce, and emits a fresh DPoP-Nonce
137
+ // header on every 401 + as a refresh on every successful response.
138
+ // The rolling-pair manager rotates without timers; no operator
139
+ // store is needed.
140
+ var nonceMgr = null;
141
+ if (requireNonce) {
142
+ validateOpts.optionalPositiveFinite(opts.nonceRotateSec,
143
+ "middleware.dpop: nonceRotateSec", AuthError, "auth-dpop/bad-opt");
144
+ var rotateSec = opts.nonceRotateSec || (C.TIME.minutes(5) / C.TIME.seconds(1));
145
+ nonceMgr = _nonceManager(rotateSec);
146
+ }
147
+ // Reject the obsolete nonceStore opt with a clear migration message —
148
+ // pre-v0.7.89 docs may surface it; the rolling-pair shape supersedes.
149
+ if (opts.nonceStore !== undefined) {
150
+ throw new AuthError("auth-dpop/bad-opt",
151
+ "middleware.dpop: opts.nonceStore is not supported — use { requireNonce: true, nonceRotateSec? }; the rolling-pair manager is internal");
152
+ }
153
+ if (opts.nonceWindowSec !== undefined) {
154
+ throw new AuthError("auth-dpop/bad-opt",
155
+ "middleware.dpop: opts.nonceWindowSec is not supported — use nonceRotateSec");
156
+ }
86
157
 
87
158
  validateOpts.optionalFunction(opts.getAccessToken,
88
159
  "middleware.dpop: getAccessToken", AuthError, "auth-dpop/bad-opt");
@@ -91,10 +162,14 @@ function create(opts) {
91
162
  validateOpts.optionalFunction(opts.getHtu,
92
163
  "middleware.dpop: getHtu", AuthError, "auth-dpop/bad-opt");
93
164
 
165
+ function _freshNonce() { return nonceMgr ? nonceMgr.issue() : null; }
166
+
94
167
  return async function dpopMiddleware(req, res, next) {
95
168
  var proofHeader = req.headers && req.headers.dpop;
96
169
  if (typeof proofHeader !== "string" || proofHeader.length === 0) {
97
- return _writeUnauthorized(res, "invalid_dpop_proof", "DPoP header required");
170
+ return _writeUnauthorized(res,
171
+ nonceMgr ? "use_dpop_nonce" : "invalid_dpop_proof",
172
+ "DPoP header required", _freshNonce());
98
173
  }
99
174
  // RFC 9449 §4.1 — only ONE DPoP header value per request.
100
175
  if (Array.isArray(proofHeader)) {
@@ -117,6 +192,12 @@ function create(opts) {
117
192
  if (typeof opts.getNonce === "function") {
118
193
  try { nonce = await opts.getNonce(req); }
119
194
  catch (_e) { nonce = null; }
195
+ } else if (nonceMgr) {
196
+ // For server-managed nonces, verify() runs WITHOUT a strict
197
+ // expected-nonce; we then check the payload's nonce against
198
+ // our rolling-pair below. This lets us issue + rotate without
199
+ // requiring a request-by-request operator callback.
200
+ nonce = null;
120
201
  }
121
202
 
122
203
  var verifyOpts = { htm: htm, htu: htu };
@@ -150,7 +231,35 @@ function create(opts) {
150
231
  errorCode = "use_dpop_nonce";
151
232
  }
152
233
  return _writeUnauthorized(res, errorCode,
153
- (e && e.message) || "DPoP proof verification failed");
234
+ (e && e.message) || "DPoP proof verification failed",
235
+ _freshNonce());
236
+ }
237
+
238
+ // Server-managed nonce check — payload MUST carry a recognized
239
+ // rolling-pair nonce. Missing or stale → 401 + DPoP-Nonce.
240
+ if (nonceMgr) {
241
+ var presented = result.payload && result.payload.nonce;
242
+ if (typeof presented !== "string" || !nonceMgr.accepts(presented)) {
243
+ if (auditOn) {
244
+ try {
245
+ audit().safeEmit({
246
+ action: "auth.bearer.failure",
247
+ actor: { clientIp: requestHelpers.clientIp(req) },
248
+ outcome: "fail",
249
+ metadata: { method: "dpop", reason: "stale-nonce", route: req.url },
250
+ });
251
+ } catch (_ignored) { /* drop-silent */ }
252
+ }
253
+ return _writeUnauthorized(res, "use_dpop_nonce",
254
+ "DPoP-Nonce required (server-managed challenge)", _freshNonce());
255
+ }
256
+ }
257
+
258
+ // Refresh the nonce on every successful response so the client
259
+ // always carries the latest one (RFC 9449 §8.1 recommendation).
260
+ if (nonceMgr && !res.headersSent) {
261
+ try { res.setHeader("DPoP-Nonce", _freshNonce()); }
262
+ catch (_e) { /* drop-silent — header set best-effort */ }
154
263
  }
155
264
 
156
265
  req.dpop = result;
@@ -46,6 +46,7 @@ var requireMethods = require("./require-methods");
46
46
  var securityHeaders = require("./security-headers");
47
47
  var securityTxt = require("./security-txt");
48
48
  var sse = require("./sse");
49
+ var tusUpload = require("./tus-upload");
49
50
  var webAppManifest = require("./web-app-manifest");
50
51
 
51
52
  module.exports = {
@@ -79,6 +80,7 @@ module.exports = {
79
80
  dpop: dpop.create,
80
81
  hostAllowlist: hostAllowlist.create,
81
82
  networkAllowlist: networkAllowlist.create,
83
+ tusUpload: tusUpload.create,
82
84
  webAppManifest: webAppManifest.create,
83
85
 
84
86
  // Module exports for advanced use (constants, raw factory access)
@@ -110,6 +112,9 @@ module.exports = {
110
112
  dpop: dpop,
111
113
  hostAllowlist: hostAllowlist,
112
114
  networkAllowlist: networkAllowlist,
115
+ tusUpload: tusUpload,
113
116
  webAppManifest: webAppManifest,
114
117
  },
115
118
  };
119
+
120
+ module.exports.tusUpload.memoryStore = tusUpload.memoryStore;
@@ -0,0 +1,654 @@
1
+ "use strict";
2
+ /**
3
+ * TUS resumable upload middleware (tus.io v1.0.0).
4
+ *
5
+ * var tus = b.middleware.tusUpload({
6
+ * mountPath: "/uploads",
7
+ * store: b.middleware.tusUpload.memoryStore({ maxSize: C.BYTES.gib(2) }),
8
+ * maxSize: C.BYTES.gib(2),
9
+ * maxChunkSize: C.BYTES.mib(64),
10
+ * expirationSec: C.TIME.hours(24) / 1000,
11
+ * extensions: ["creation", "creation-with-upload", "expiration",
12
+ * "checksum", "termination"],
13
+ * checksumAlgorithms: ["sha3-512", "shake256"],
14
+ * onComplete: async function (uploadId, meta) { ... },
15
+ * audit: true,
16
+ * });
17
+ * router.use(tus);
18
+ *
19
+ * Wire-shape per tus.io 1.0.0 §2:
20
+ * POST <mountPath> → 201 + Location: <mountPath>/<id>
21
+ * HEAD <mountPath>/<id> → 200 + Upload-Offset, Upload-Length, Upload-Metadata
22
+ * PATCH <mountPath>/<id> → 204 + Upload-Offset
23
+ * DELETE <mountPath>/<id> → 204
24
+ * OPTIONS <mountPath> → 204 + Tus-* discovery
25
+ *
26
+ * Extensions implemented:
27
+ * - creation (§4) POST creates a new upload; Upload-Defer-Length
28
+ * supported per §4.3
29
+ * - creation-with-upload (§4.4) Content-Type application/offset+octet-stream
30
+ * on POST appends in the same call
31
+ * - expiration (§4.5) Upload-Expires header on every response;
32
+ * store.terminate() purges expired uploads
33
+ * - checksum (§3.5) Upload-Checksum: <algo> <base64> validated
34
+ * against received bytes; mismatch → 460
35
+ * - termination (§3.4) DELETE removes the upload
36
+ *
37
+ * Concatenation (§4.6) is intentionally not in v1 — operators that need
38
+ * parallel-chunk assembly compose it in their own store layer; re-open
39
+ * if an operator demonstrates a use case the store-level approach
40
+ * cannot satisfy.
41
+ */
42
+
43
+ var nodeCrypto = require("crypto"); // for createHash() in checksum extension
44
+ var C = require("../constants");
45
+ var bCrypto = require("../crypto");
46
+ var lazyRequire = require("../lazy-require");
47
+ var safeAsync = require("../safe-async");
48
+ var safeBuffer = require("../safe-buffer");
49
+ var validateOpts = require("../validate-opts");
50
+ var { defineClass } = require("../framework-error");
51
+
52
+ // Observability metric prefix for the TUS middleware. The framework
53
+ // audit pipeline routes through `observability.safeEvent` (metrics +
54
+ // counters) for hot-path lifecycle signals, not `audit.safeEmit`,
55
+ // because PATCH chunks fire dozens of times per upload and the
56
+ // audit chain is reserved for security-relevant state transitions.
57
+ var TUS_ID_BYTES = C.BYTES.bytes(18); // 144 bits ≈ 24 base64url chars per upload id
58
+
59
+ // HTTP status codes used by TUS — hoisted to named constants so the
60
+ // raw-byte-literal detector doesn't fire on every status path.
61
+ var STATUS_OK = 200; // allow:raw-byte-literal — HTTP status
62
+ var STATUS_CREATED = 201; // allow:raw-byte-literal — HTTP status
63
+ var STATUS_NO_CONTENT = 204; // allow:raw-byte-literal — HTTP status
64
+ var STATUS_BAD_REQUEST = 400; // allow:raw-byte-literal — HTTP status
65
+ var STATUS_NOT_FOUND = 404; // allow:raw-byte-literal — HTTP status
66
+ var STATUS_METHOD_NOT_ALLOWED = 405; // allow:raw-byte-literal — HTTP status
67
+ var STATUS_CONFLICT = 409; // allow:raw-byte-literal — HTTP status
68
+ var STATUS_PRECONDITION_FAILED = 412; // allow:raw-byte-literal — HTTP status
69
+ var STATUS_PAYLOAD_TOO_LARGE = 413; // allow:raw-byte-literal — HTTP status
70
+ var STATUS_UNSUPPORTED_MEDIA = 415; // allow:raw-byte-literal — HTTP status
71
+ var STATUS_CHECKSUM_MISMATCH = 460; // allow:raw-byte-literal — TUS-specific status (§3.5)
72
+ var STATUS_INTERNAL_ERROR = 500; // allow:raw-byte-literal — HTTP status
73
+
74
+ var TusError = defineClass("TusError", { alwaysPermanent: true });
75
+
76
+ var observability = lazyRequire(function () { return require("../observability"); });
77
+
78
+ var TUS_VERSION = "1.0.0";
79
+ var SUPPORTED_VERSIONS = ["1.0.0"];
80
+ var DEFAULT_EXTENSIONS = [
81
+ "creation", "creation-with-upload", "expiration",
82
+ "checksum", "termination",
83
+ ];
84
+ var DEFAULT_CHECKSUM_ALGORITHMS = ["sha3-512", "shake256"];
85
+ var KNOWN_CHECKSUM_ALGORITHMS = {
86
+ "sha3-512": "sha3-512",
87
+ "shake256": "shake256",
88
+ "sha-256": "sha256",
89
+ "sha-512": "sha512",
90
+ "sha3-256": "sha3-256",
91
+ };
92
+ var KNOWN_EXTENSIONS = {
93
+ "creation": true,
94
+ "creation-with-upload": true,
95
+ "expiration": true,
96
+ "checksum": true,
97
+ "termination": true,
98
+ };
99
+
100
+ function _b64uId() {
101
+ return bCrypto.generateBytes(TUS_ID_BYTES).toString("base64url");
102
+ }
103
+
104
+ function _parseMetadata(headerValue) {
105
+ // RFC-style key-value list: `key1 base64val1,key2 base64val2`. Per
106
+ // tus.io 1.0.0 §3.2 keys are ASCII printable except space/comma; values
107
+ // are base64-encoded UTF-8 octet sequences.
108
+ if (typeof headerValue !== "string" || headerValue.length === 0) return null;
109
+ var pairs = headerValue.split(",");
110
+ var out = {};
111
+ for (var i = 0; i < pairs.length; i++) {
112
+ var raw = pairs[i].trim();
113
+ if (raw.length === 0) continue;
114
+ var sp = raw.indexOf(" ");
115
+ var key, val;
116
+ if (sp === -1) { key = raw; val = ""; }
117
+ else { key = raw.slice(0, sp); val = raw.slice(sp + 1); }
118
+ if (!/^[!-+\--.0-~]+$/.test(key)) return null; // printable, no space/comma
119
+ if (val.length > 0 && !/^[A-Za-z0-9+/=]+$/.test(val)) return null;
120
+ var decoded = "";
121
+ if (val.length > 0) {
122
+ try { decoded = Buffer.from(val, "base64").toString("utf8"); }
123
+ catch (_e) { return null; }
124
+ }
125
+ out[key] = decoded;
126
+ }
127
+ return out;
128
+ }
129
+
130
+ function _serializeMetadata(metaObj) {
131
+ if (!metaObj || typeof metaObj !== "object") return "";
132
+ var keys = Object.keys(metaObj);
133
+ var parts = [];
134
+ for (var i = 0; i < keys.length; i++) {
135
+ var k = keys[i];
136
+ var v = metaObj[k];
137
+ var encoded = (typeof v === "string" && v.length > 0)
138
+ ? Buffer.from(v, "utf8").toString("base64")
139
+ : "";
140
+ parts.push(encoded.length > 0 ? (k + " " + encoded) : k);
141
+ }
142
+ return parts.join(",");
143
+ }
144
+
145
+ function _parseChecksumHeader(headerValue, allowedSet) {
146
+ // tus.io 1.0.0 §3.5: `Upload-Checksum: <algo> <base64-digest>`.
147
+ if (typeof headerValue !== "string") return null;
148
+ var sp = headerValue.indexOf(" ");
149
+ if (sp === -1) return { error: "malformed" };
150
+ var algo = headerValue.slice(0, sp).trim().toLowerCase();
151
+ var digestB64 = headerValue.slice(sp + 1).trim();
152
+ if (algo.length === 0 || digestB64.length === 0) return { error: "malformed" };
153
+ if (!allowedSet[algo]) return { error: "algo-unsupported" };
154
+ if (!/^[A-Za-z0-9+/=]+$/.test(digestB64)) return { error: "malformed" };
155
+ var nodeAlgo = KNOWN_CHECKSUM_ALGORITHMS[algo];
156
+ if (!nodeAlgo) return { error: "algo-unsupported" };
157
+ return { algo: algo, nodeAlgo: nodeAlgo, digestB64: digestB64 };
158
+ }
159
+
160
+ function memoryStore(opts) {
161
+ opts = opts || {};
162
+ var maxSize = opts.maxSize;
163
+ if (maxSize !== undefined && (typeof maxSize !== "number" || !isFinite(maxSize) || maxSize <= 0)) {
164
+ throw new TusError("tus/bad-store-opts",
165
+ "tusUpload.memoryStore: maxSize must be a positive finite number");
166
+ }
167
+ var defaultExpirationMs = opts.defaultExpirationMs || C.TIME.hours(24);
168
+
169
+ var uploads = new Map(); // id -> { length, deferLength, metadata, buf, offset, expireAt, completed, terminated }
170
+
171
+ function create(meta) {
172
+ var id = _b64uId();
173
+ var now = Date.now();
174
+ var rec = {
175
+ id: id,
176
+ length: (typeof meta.length === "number" && isFinite(meta.length)) ? meta.length : null,
177
+ deferLength: meta.deferLength === true,
178
+ metadata: meta.metadata || {},
179
+ buf: Buffer.alloc(0),
180
+ offset: 0,
181
+ expireAt: now + (meta.expirationMs || defaultExpirationMs),
182
+ completed: false,
183
+ terminated: false,
184
+ hashState: null,
185
+ };
186
+ uploads.set(id, rec);
187
+ return Promise.resolve(rec);
188
+ }
189
+
190
+ function head(id) {
191
+ var rec = uploads.get(id);
192
+ if (!rec || rec.terminated) return Promise.resolve(null);
193
+ if (rec.expireAt && rec.expireAt < Date.now()) {
194
+ uploads.delete(id);
195
+ return Promise.resolve(null);
196
+ }
197
+ return Promise.resolve(rec);
198
+ }
199
+
200
+ function append(id, chunk, offset) {
201
+ var rec = uploads.get(id);
202
+ if (!rec || rec.terminated) return Promise.reject(new TusError("tus/upload-not-found", "upload " + id + " not found"));
203
+ if (offset !== rec.offset) {
204
+ return Promise.reject(new TusError("tus/offset-mismatch", "expected offset " + rec.offset + ", got " + offset));
205
+ }
206
+ if (rec.length !== null && rec.offset + chunk.length > rec.length) {
207
+ return Promise.reject(new TusError("tus/length-exceeded", "chunk would exceed declared Upload-Length"));
208
+ }
209
+ if (maxSize !== undefined && rec.offset + chunk.length > maxSize) {
210
+ return Promise.reject(new TusError("tus/length-exceeded", "chunk would exceed memoryStore maxSize"));
211
+ }
212
+ rec.buf = Buffer.concat([rec.buf, chunk]);
213
+ rec.offset += chunk.length;
214
+ if (rec.length !== null && rec.offset === rec.length) rec.completed = true;
215
+ return Promise.resolve(rec);
216
+ }
217
+
218
+ function setLength(id, length) {
219
+ var rec = uploads.get(id);
220
+ if (!rec) return Promise.reject(new TusError("tus/upload-not-found", "upload " + id + " not found"));
221
+ if (rec.length !== null) return Promise.reject(new TusError("tus/length-already-set", "Upload-Length already declared"));
222
+ rec.length = length;
223
+ rec.deferLength = false;
224
+ return Promise.resolve(rec);
225
+ }
226
+
227
+ function terminate(id) {
228
+ var rec = uploads.get(id);
229
+ if (!rec) return Promise.resolve(false);
230
+ rec.terminated = true;
231
+ uploads.delete(id);
232
+ return Promise.resolve(true);
233
+ }
234
+
235
+ function purgeExpired() {
236
+ var now = Date.now();
237
+ var removed = 0;
238
+ for (var entry of uploads) {
239
+ if (entry[1].expireAt && entry[1].expireAt < now) { uploads.delete(entry[0]); removed++; }
240
+ }
241
+ return Promise.resolve(removed);
242
+ }
243
+
244
+ function getBuffer(id) {
245
+ var rec = uploads.get(id);
246
+ return Promise.resolve(rec ? rec.buf : null);
247
+ }
248
+
249
+ return {
250
+ name: "memory",
251
+ create: create,
252
+ head: head,
253
+ append: append,
254
+ setLength: setLength,
255
+ terminate: terminate,
256
+ purgeExpired: purgeExpired,
257
+ getBuffer: getBuffer,
258
+ };
259
+ }
260
+
261
+ function _writeError(res, status, body) {
262
+ if (res.headersSent) return;
263
+ var bodyStr = body || "";
264
+ res.writeHead(status, {
265
+ "Tus-Resumable": TUS_VERSION,
266
+ "Content-Type": "text/plain; charset=utf-8",
267
+ "Content-Length": Buffer.byteLength(bodyStr),
268
+ });
269
+ res.end(bodyStr);
270
+ }
271
+
272
+ function _emitTusBaseHeaders(res, extra) {
273
+ var headers = Object.assign({
274
+ "Tus-Resumable": TUS_VERSION,
275
+ }, extra || {});
276
+ return headers;
277
+ }
278
+
279
+ function _readChunk(req, maxChunkSize) {
280
+ return new Promise(function (resolve, reject) {
281
+ var collector = safeBuffer.boundedChunkCollector({
282
+ maxBytes: maxChunkSize,
283
+ errorClass: TusError,
284
+ sizeCode: "tus/chunk-too-large",
285
+ sizeMessage: "PATCH body exceeded maxChunkSize",
286
+ });
287
+ req.on("data", function (c) {
288
+ try { collector.push(c); }
289
+ catch (e) { req.removeAllListeners("data"); reject(e); }
290
+ });
291
+ req.on("end", function () { resolve(collector.result()); });
292
+ req.on("error", function (e) { reject(e); });
293
+ });
294
+ }
295
+
296
+ function create(opts) {
297
+ validateOpts.requireObject(opts, "middleware.tusUpload", TusError);
298
+ validateOpts(opts, [
299
+ "mountPath", "store", "maxSize", "maxChunkSize",
300
+ "expirationSec", "extensions", "checksumAlgorithms",
301
+ "onComplete", "onCreate", "onTerminate", "audit",
302
+ ], "middleware.tusUpload");
303
+
304
+ var mountPath = opts.mountPath;
305
+ if (typeof mountPath !== "string" || mountPath.length === 0 || mountPath.charAt(0) !== "/") {
306
+ throw new TusError("tus/bad-mountpath",
307
+ "middleware.tusUpload: mountPath must be a non-empty path starting with '/'");
308
+ }
309
+ if (mountPath.length > 1 && mountPath.charAt(mountPath.length - 1) === "/") {
310
+ mountPath = mountPath.slice(0, -1);
311
+ }
312
+
313
+ var store = opts.store;
314
+ if (!store || typeof store.create !== "function" || typeof store.head !== "function" ||
315
+ typeof store.append !== "function" || typeof store.terminate !== "function") {
316
+ throw new TusError("tus/bad-store",
317
+ "middleware.tusUpload: store must implement { create, head, append, terminate }");
318
+ }
319
+
320
+ var maxSize = opts.maxSize;
321
+ if (maxSize !== undefined && (typeof maxSize !== "number" || !isFinite(maxSize) || maxSize <= 0)) {
322
+ throw new TusError("tus/bad-opts", "middleware.tusUpload: maxSize must be a positive finite number");
323
+ }
324
+ var maxChunkSize = opts.maxChunkSize;
325
+ if (maxChunkSize === undefined) maxChunkSize = C.BYTES.mib(64);
326
+ if (typeof maxChunkSize !== "number" || !isFinite(maxChunkSize) || maxChunkSize <= 0) {
327
+ throw new TusError("tus/bad-opts", "middleware.tusUpload: maxChunkSize must be a positive finite number");
328
+ }
329
+ var expirationSec = opts.expirationSec;
330
+ if (expirationSec !== undefined && (typeof expirationSec !== "number" || !isFinite(expirationSec) || expirationSec <= 0)) {
331
+ throw new TusError("tus/bad-opts", "middleware.tusUpload: expirationSec must be a positive finite number");
332
+ }
333
+
334
+ var extensions = Array.isArray(opts.extensions) ? opts.extensions.slice() : DEFAULT_EXTENSIONS.slice();
335
+ for (var i = 0; i < extensions.length; i++) {
336
+ if (!KNOWN_EXTENSIONS[extensions[i]]) {
337
+ throw new TusError("tus/bad-opts",
338
+ "middleware.tusUpload: unknown extension '" + extensions[i] + "'");
339
+ }
340
+ }
341
+ var hasCreation = extensions.indexOf("creation") !== -1;
342
+ var hasCreationWithBody = extensions.indexOf("creation-with-upload") !== -1;
343
+ var hasExpiration = extensions.indexOf("expiration") !== -1;
344
+ var hasChecksum = extensions.indexOf("checksum") !== -1;
345
+ var hasTermination = extensions.indexOf("termination") !== -1;
346
+
347
+ var checksumAlgorithms = Array.isArray(opts.checksumAlgorithms)
348
+ ? opts.checksumAlgorithms.slice()
349
+ : DEFAULT_CHECKSUM_ALGORITHMS.slice();
350
+ var checksumAlgorithmSet = {};
351
+ for (var j = 0; j < checksumAlgorithms.length; j++) {
352
+ var algo = checksumAlgorithms[j];
353
+ if (!KNOWN_CHECKSUM_ALGORITHMS[algo]) {
354
+ throw new TusError("tus/bad-opts",
355
+ "middleware.tusUpload: unknown checksum algorithm '" + algo + "'");
356
+ }
357
+ checksumAlgorithmSet[algo] = true;
358
+ }
359
+
360
+ validateOpts.optionalFunction(opts.onComplete, "middleware.tusUpload: onComplete", TusError, "tus/bad-opts");
361
+ validateOpts.optionalFunction(opts.onCreate, "middleware.tusUpload: onCreate", TusError, "tus/bad-opts");
362
+ validateOpts.optionalFunction(opts.onTerminate, "middleware.tusUpload: onTerminate", TusError, "tus/bad-opts");
363
+
364
+ var auditOn = opts.audit !== false;
365
+
366
+ if (hasExpiration && typeof store.purgeExpired === "function") {
367
+ safeAsync.repeating(function () {
368
+ store.purgeExpired().catch(function () { /* drop-silent — sweep best-effort */ });
369
+ }, C.TIME.minutes(5), { name: "tus-upload-sweep" });
370
+ }
371
+
372
+ function _expirationHeader(rec) {
373
+ if (!hasExpiration || !rec || !rec.expireAt) return null;
374
+ return new Date(rec.expireAt).toUTCString();
375
+ }
376
+
377
+ function _emitMetric(verb, _outcome, _metadata) {
378
+ if (!auditOn) return;
379
+ try { observability().safeEvent("middleware.tusUpload." + verb, 1, {}); }
380
+ catch (_e) { /* drop-silent — observability sink */ }
381
+ }
382
+
383
+ async function _handleOptions(req, res) {
384
+ var headers = _emitTusBaseHeaders(res, {
385
+ "Tus-Version": SUPPORTED_VERSIONS.join(","),
386
+ "Tus-Extension": extensions.join(","),
387
+ });
388
+ if (maxSize !== undefined) headers["Tus-Max-Size"] = String(maxSize);
389
+ if (hasChecksum) headers["Tus-Checksum-Algorithm"] = checksumAlgorithms.join(",");
390
+ res.writeHead(STATUS_NO_CONTENT, headers);
391
+ res.end();
392
+ }
393
+
394
+ async function _handleCreate(req, res) {
395
+ if (!hasCreation) return _writeError(res, STATUS_METHOD_NOT_ALLOWED, "creation extension not enabled");
396
+ var lengthHdr = req.headers["upload-length"];
397
+ var deferHdr = req.headers["upload-defer-length"];
398
+ var metadataHdr = req.headers["upload-metadata"];
399
+
400
+ var uploadLength = null;
401
+ var deferLength = false;
402
+ if (lengthHdr !== undefined) {
403
+ uploadLength = parseInt(lengthHdr, 10);
404
+ if (!isFinite(uploadLength) || uploadLength < 0 || String(uploadLength) !== String(lengthHdr).trim()) {
405
+ return _writeError(res, STATUS_BAD_REQUEST, "Upload-Length must be a non-negative integer");
406
+ }
407
+ if (maxSize !== undefined && uploadLength > maxSize) {
408
+ return _writeError(res, STATUS_PAYLOAD_TOO_LARGE, "Upload-Length exceeds Tus-Max-Size");
409
+ }
410
+ } else if (String(deferHdr).trim() === "1") {
411
+ deferLength = true;
412
+ } else {
413
+ return _writeError(res, STATUS_BAD_REQUEST, "Upload-Length or Upload-Defer-Length: 1 required");
414
+ }
415
+
416
+ var metadata = null;
417
+ if (metadataHdr !== undefined) {
418
+ metadata = _parseMetadata(metadataHdr);
419
+ if (metadata === null) return _writeError(res, STATUS_BAD_REQUEST, "malformed Upload-Metadata");
420
+ }
421
+
422
+ var rec;
423
+ try {
424
+ rec = await store.create({
425
+ length: uploadLength,
426
+ deferLength: deferLength,
427
+ metadata: metadata || {},
428
+ expirationMs: expirationSec ? C.TIME.seconds(expirationSec) : undefined,
429
+ });
430
+ } catch (e) {
431
+ _emitMetric("create.fail");
432
+ return _writeError(res, STATUS_INTERNAL_ERROR, (e && e.message) || "store create failed");
433
+ }
434
+
435
+ if (typeof opts.onCreate === "function") {
436
+ try { await opts.onCreate(rec.id, { length: uploadLength, metadata: metadata }); }
437
+ catch (_e) { /* operator hook — drop-silent */ }
438
+ }
439
+
440
+ var location = mountPath + "/" + rec.id;
441
+ var headers = _emitTusBaseHeaders(res, { "Location": location });
442
+ var expHdr = _expirationHeader(rec);
443
+ if (expHdr) headers["Upload-Expires"] = expHdr;
444
+
445
+ // creation-with-upload: append the body in the same request when
446
+ // Content-Type is application/offset+octet-stream.
447
+ var contentType = req.headers["content-type"];
448
+ if (hasCreationWithBody && contentType === "application/offset+octet-stream") {
449
+ var chunk;
450
+ try { chunk = await _readChunk(req, maxChunkSize); }
451
+ catch (e) { return _writeError(res, e.code === "tus/chunk-too-large" ? STATUS_PAYLOAD_TOO_LARGE : STATUS_BAD_REQUEST, e.message); }
452
+ try {
453
+ rec = await store.append(rec.id, chunk, 0);
454
+ } catch (e) {
455
+ return _writeError(res, e.code === "tus/length-exceeded" ? STATUS_PAYLOAD_TOO_LARGE : STATUS_BAD_REQUEST, e.message);
456
+ }
457
+ headers["Upload-Offset"] = String(rec.offset);
458
+ if (rec.completed && typeof opts.onComplete === "function") {
459
+ try { await opts.onComplete(rec.id, { metadata: rec.metadata, store: store }); }
460
+ catch (_e) { /* operator hook — drop-silent */ }
461
+ }
462
+ }
463
+
464
+ res.writeHead(STATUS_CREATED, headers);
465
+ res.end();
466
+ _emitMetric("create.ok");
467
+ }
468
+
469
+ async function _handleHead(req, res, id) {
470
+ var rec;
471
+ try { rec = await store.head(id); }
472
+ catch (_e) { rec = null; }
473
+ if (!rec) return _writeError(res, STATUS_NOT_FOUND, "upload not found");
474
+ var headers = _emitTusBaseHeaders(res, {
475
+ "Upload-Offset": String(rec.offset),
476
+ "Cache-Control": "no-store",
477
+ });
478
+ if (rec.length !== null) headers["Upload-Length"] = String(rec.length);
479
+ else if (rec.deferLength) headers["Upload-Defer-Length"] = "1";
480
+ if (rec.metadata && Object.keys(rec.metadata).length > 0) {
481
+ headers["Upload-Metadata"] = _serializeMetadata(rec.metadata);
482
+ }
483
+ var expHdr = _expirationHeader(rec);
484
+ if (expHdr) headers["Upload-Expires"] = expHdr;
485
+ res.writeHead(STATUS_OK, headers);
486
+ res.end();
487
+ }
488
+
489
+ async function _handlePatch(req, res, id) {
490
+ var contentType = req.headers["content-type"];
491
+ if (contentType !== "application/offset+octet-stream") {
492
+ return _writeError(res, STATUS_UNSUPPORTED_MEDIA, "Content-Type must be application/offset+octet-stream");
493
+ }
494
+ var offsetHdr = req.headers["upload-offset"];
495
+ if (offsetHdr === undefined) return _writeError(res, STATUS_BAD_REQUEST, "Upload-Offset required");
496
+ var offset = parseInt(offsetHdr, 10);
497
+ if (!isFinite(offset) || offset < 0 || String(offset) !== String(offsetHdr).trim()) {
498
+ return _writeError(res, STATUS_BAD_REQUEST, "Upload-Offset must be a non-negative integer");
499
+ }
500
+
501
+ var rec;
502
+ try { rec = await store.head(id); }
503
+ catch (_e) { rec = null; }
504
+ if (!rec) return _writeError(res, STATUS_NOT_FOUND, "upload not found");
505
+
506
+ if (rec.length === null && req.headers["upload-length"] !== undefined) {
507
+ // Upload-Defer-Length finalization (§4.3) — declare length on first PATCH
508
+ var declared = parseInt(req.headers["upload-length"], 10);
509
+ if (!isFinite(declared) || declared < 0) {
510
+ return _writeError(res, STATUS_BAD_REQUEST, "Upload-Length must be a non-negative integer");
511
+ }
512
+ if (maxSize !== undefined && declared > maxSize) {
513
+ return _writeError(res, STATUS_PAYLOAD_TOO_LARGE, "Upload-Length exceeds Tus-Max-Size");
514
+ }
515
+ try { rec = await store.setLength(id, declared); }
516
+ catch (e) { return _writeError(res, STATUS_CONFLICT, e.message); }
517
+ }
518
+
519
+ if (offset !== rec.offset) {
520
+ return _writeError(res, STATUS_CONFLICT, "Upload-Offset mismatch (expected " + rec.offset + ")");
521
+ }
522
+
523
+ var checksum = null;
524
+ if (req.headers["upload-checksum"] !== undefined) {
525
+ if (!hasChecksum) return _writeError(res, STATUS_BAD_REQUEST, "checksum extension not enabled");
526
+ checksum = _parseChecksumHeader(req.headers["upload-checksum"], checksumAlgorithmSet);
527
+ if (!checksum || checksum.error) {
528
+ if (checksum && checksum.error === "algo-unsupported") {
529
+ return _writeError(res, STATUS_BAD_REQUEST, "checksum algorithm unsupported");
530
+ }
531
+ return _writeError(res, STATUS_BAD_REQUEST, "malformed Upload-Checksum");
532
+ }
533
+ }
534
+
535
+ var chunk;
536
+ try { chunk = await _readChunk(req, maxChunkSize); }
537
+ catch (e) { return _writeError(res, e.code === "tus/chunk-too-large" ? STATUS_PAYLOAD_TOO_LARGE : STATUS_BAD_REQUEST, e.message); }
538
+
539
+ if (checksum) {
540
+ var hasher = nodeCrypto.createHash(checksum.nodeAlgo);
541
+ hasher.update(chunk);
542
+ var digestB64 = hasher.digest("base64");
543
+ if (digestB64 !== checksum.digestB64) {
544
+ return _writeError(res, STATUS_CHECKSUM_MISMATCH, "Upload-Checksum mismatch");
545
+ }
546
+ }
547
+
548
+ try { rec = await store.append(id, chunk, offset); }
549
+ catch (e) {
550
+ var sc = STATUS_INTERNAL_ERROR;
551
+ if (e.code === "tus/offset-mismatch") sc = STATUS_CONFLICT;
552
+ else if (e.code === "tus/length-exceeded") sc = STATUS_PAYLOAD_TOO_LARGE;
553
+ else if (e.code === "tus/upload-not-found") sc = STATUS_NOT_FOUND;
554
+ return _writeError(res, sc, e.message);
555
+ }
556
+
557
+ var headers = _emitTusBaseHeaders(res, { "Upload-Offset": String(rec.offset) });
558
+ var expHdr = _expirationHeader(rec);
559
+ if (expHdr) headers["Upload-Expires"] = expHdr;
560
+
561
+ res.writeHead(STATUS_NO_CONTENT, headers);
562
+ res.end();
563
+
564
+ if (rec.completed && typeof opts.onComplete === "function") {
565
+ try { await opts.onComplete(id, { metadata: rec.metadata, store: store }); }
566
+ catch (_e) { /* operator hook — drop-silent */ }
567
+ _emitMetric("complete.ok");
568
+ }
569
+ }
570
+
571
+ async function _handleDelete(req, res, id) {
572
+ if (!hasTermination) return _writeError(res, STATUS_METHOD_NOT_ALLOWED, "termination extension not enabled");
573
+ var existed;
574
+ try { existed = await store.terminate(id); }
575
+ catch (_e) { existed = false; }
576
+ if (!existed) return _writeError(res, STATUS_NOT_FOUND, "upload not found");
577
+ if (typeof opts.onTerminate === "function") {
578
+ try { await opts.onTerminate(id); }
579
+ catch (_e) { /* operator hook — drop-silent */ }
580
+ }
581
+ res.writeHead(STATUS_NO_CONTENT, _emitTusBaseHeaders(res, {}));
582
+ res.end();
583
+ _emitMetric("terminate.ok");
584
+ }
585
+
586
+ return async function tusUploadMiddleware(req, res, next) {
587
+ var url = req.url || "/";
588
+ var qIdx = url.indexOf("?");
589
+ var path = qIdx === -1 ? url : url.slice(0, qIdx);
590
+
591
+ var isCollection = (path === mountPath);
592
+ var isResource = false;
593
+ var resourceId = null;
594
+ if (path.indexOf(mountPath + "/") === 0) {
595
+ resourceId = path.slice(mountPath.length + 1);
596
+ // No further sub-paths allowed — TUS resources are flat.
597
+ if (resourceId.indexOf("/") === -1 && /^[A-Za-z0-9_-]{1,128}$/.test(resourceId)) {
598
+ isResource = true;
599
+ }
600
+ }
601
+ if (!isCollection && !isResource) return next();
602
+
603
+ // Tus-Resumable header gate (§2.2). OPTIONS is exempt; all other
604
+ // verbs must declare a supported version.
605
+ var method = (req.method || "").toUpperCase();
606
+ if (method !== "OPTIONS") {
607
+ var version = req.headers["tus-resumable"];
608
+ if (version === undefined) {
609
+ return _writeError(res, STATUS_PRECONDITION_FAILED, "Tus-Resumable header required");
610
+ }
611
+ if (SUPPORTED_VERSIONS.indexOf(version) === -1) {
612
+ var hdrs = _emitTusBaseHeaders(res, { "Tus-Version": SUPPORTED_VERSIONS.join(",") });
613
+ res.writeHead(STATUS_PRECONDITION_FAILED, hdrs);
614
+ res.end("Tus-Resumable version unsupported");
615
+ return;
616
+ }
617
+ }
618
+
619
+ try {
620
+ if (method === "OPTIONS") return await _handleOptions(req, res);
621
+ if (isCollection && method === "POST") return await _handleCreate(req, res);
622
+ if (isResource && method === "HEAD") return await _handleHead(req, res, resourceId);
623
+ if (isResource && method === "PATCH") return await _handlePatch(req, res, resourceId);
624
+ if (isResource && method === "DELETE") return await _handleDelete(req, res, resourceId);
625
+ } catch (e) {
626
+ _emitMetric("error.fail");
627
+ return _writeError(res, STATUS_INTERNAL_ERROR, (e && e.message) || "internal error");
628
+ }
629
+
630
+ var allow = isCollection ? "OPTIONS, POST" : "OPTIONS, HEAD, PATCH" + (hasTermination ? ", DELETE" : "");
631
+ res.writeHead(STATUS_METHOD_NOT_ALLOWED, _emitTusBaseHeaders(res, {
632
+ "Allow": allow,
633
+ "Content-Type": "text/plain; charset=utf-8",
634
+ "Content-Length": "0",
635
+ }));
636
+ res.end();
637
+ };
638
+ }
639
+
640
+ function close(middleware) {
641
+ // Reserved for future store-close hook; the sweep timer is the only
642
+ // resource currently bound, and it lives inside the middleware closure.
643
+ if (middleware && typeof middleware.close === "function") middleware.close();
644
+ }
645
+
646
+ module.exports = {
647
+ create: create,
648
+ memoryStore: memoryStore,
649
+ close: close,
650
+ TusError: TusError,
651
+ TUS_VERSION: TUS_VERSION,
652
+ KNOWN_EXTENSIONS: KNOWN_EXTENSIONS,
653
+ KNOWN_CHECKSUM_ALGORITHMS: KNOWN_CHECKSUM_ALGORITHMS,
654
+ };
@@ -37,6 +37,7 @@
37
37
 
38
38
  var C = require("../constants");
39
39
  var numericBounds = require("../numeric-bounds");
40
+ var safeBuffer = require("../safe-buffer");
40
41
  var { defineClass } = require("../framework-error");
41
42
 
42
43
  var IniSafeError = defineClass("IniSafeError", { alwaysPermanent: true });
@@ -176,7 +177,7 @@ function _parseSectionHeader(line) {
176
177
  if (parts[i].length === 0) {
177
178
  throw _err("ini/bad-section", "section name has empty segment: " + JSON.stringify(inner));
178
179
  }
179
- if (!/^[A-Za-z0-9_-]+$/.test(parts[i])) {
180
+ if (!safeBuffer.BASE64URL_RE.test(parts[i])) {
180
181
  throw _err("ini/bad-section",
181
182
  "section segment must match [A-Za-z0-9_-]+ (got " + JSON.stringify(parts[i]) + ")");
182
183
  }
@@ -190,6 +190,12 @@ function secureZero(buf) {
190
190
  // itself does NOT bound length — that's the caller's contract.
191
191
  var HEX_RE = /^[0-9a-fA-F]+$/;
192
192
 
193
+ // BASE64URL_RE matches a non-empty base64url-encoded string (RFC 4648
194
+ // §5) with NO padding. Used by JOSE primitives (JWT/JWS/JWE compact
195
+ // serialisations), DPoP jti, WebAuthn credential IDs, etc. The regex
196
+ // is length-agnostic — callers cap length per protocol contract.
197
+ var BASE64URL_RE = /^[A-Za-z0-9_-]+$/;
198
+
193
199
  // CRLF_RE matches any control character used in HTTP-header / SMTP-
194
200
  // envelope injection attacks. Header values that contain CR or LF must
195
201
  // be rejected before serialization.
@@ -231,6 +237,7 @@ module.exports = {
231
237
  stripCrlf: stripCrlf,
232
238
  stripTrailingHspace: stripTrailingHspace,
233
239
  HEX_RE: HEX_RE,
240
+ BASE64URL_RE: BASE64URL_RE,
234
241
  CRLF_RE: CRLF_RE,
235
242
  TRAILING_HSPACE_RE: TRAILING_HSPACE_RE,
236
243
  SafeBufferError: SafeBufferError,
package/lib/webhook.js CHANGED
@@ -214,7 +214,7 @@ function _pqcSign(privateKeyPem, data) {
214
214
  return crypto.sign(data, privateKeyPem).toString("base64url");
215
215
  }
216
216
 
217
- var _BASE64URL_RE = /^[A-Za-z0-9_-]+$/;
217
+ var _BASE64URL_RE = safeBuffer.BASE64URL_RE;
218
218
 
219
219
  function _pqcVerify(publicKeyPem, data, expectedSig) {
220
220
  if (typeof expectedSig !== "string" || expectedSig.length === 0) return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.7.88",
3
+ "version": "0.7.89",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:5980b589-6ae2-43c3-88ad-31cb87e43594",
5
+ "serialNumber": "urn:uuid:bdcc6997-3839-40c9-b595-95e5fcf57410",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-06T06:50:00.974Z",
8
+ "timestamp": "2026-05-06T07:34:42.532Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.7.88",
22
+ "bom-ref": "@blamejs/core@0.7.89",
23
23
  "type": "library",
24
24
  "name": "blamejs",
25
- "version": "0.7.88",
25
+ "version": "0.7.89",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.7.88",
29
+ "purl": "pkg:npm/%40blamejs/core@0.7.89",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.7.88",
57
+ "ref": "@blamejs/core@0.7.89",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]