@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
package/lib/static.js CHANGED
@@ -1,67 +1,74 @@
1
1
  "use strict";
2
2
  /**
3
- * Static asset serving middleware factory + SRI integrity helper.
3
+ * staticServe server-side download primitive with the same v1-defensible
4
+ * surface as b.fileUpload: permissions integration, audit emission with
5
+ * 5-W's actor context, observability counters, bandwidth + concurrency
6
+ * quotas (cluster-shared via b.cache), Range support (RFC 7233 single-range),
7
+ * the full conditional-request set (If-None-Match / If-Match /
8
+ * If-Modified-Since / If-Unmodified-Since), MIME allowlist with magic-byte
9
+ * verification (composes b.fileType), per-request operator hook, idle-stream
10
+ * timeout, cancellation propagation, force-revoke, and compliance-retention
11
+ * gating.
4
12
  *
5
- * The middleware maps URL → file under `root`, with the same path-
6
- * containment posture as lib/template (no `..`, no `\0`, resolved path
7
- * must stay under root). Files outside root or symlinks pointing out
8
- * are refused.
13
+ * var serve = b.staticServe.create({ root, ...opts });
14
+ * await serve.revoke(absUrlPath); // operator force-revoke
15
+ * serve.stats(); // bytes / requests / etag-hits
16
+ * var mw = serve.middleware; // (req, res, next)
17
+ * await b.staticServe.integrity(absFilePath); // SRI helper (SHA-384)
9
18
  *
10
- * Cache-Control posture:
19
+ * Backwards compatible: every existing opt (root, mountPath,
20
+ * hashedPathPattern, indexFile, defaultMaxAge, contentTypes) keeps its
21
+ * original meaning. New opts (permissions, cache, audit, observability,
22
+ * fileType, allowedFileTypes, maxBytesPerActorPerWindowMs, etc.) are all
23
+ * optional with security-on defaults (auditSuccess / auditFailures default
24
+ * true; range support defaults true; ETag is SHA3-512-truncated for PQC
25
+ * posture, the SRI integrity helper keeps SHA-384 because the W3C
26
+ * subresource-integrity spec only allows sha256/sha384/sha512).
11
27
  *
12
- * - URLs matching opts.hashedPathPattern (default: a hex/base32 ≥8
13
- * character segment surrounded by dots `.abc123ef.` style) get
14
- * `Cache-Control: public, max-age=31536000, immutable` per the
15
- * roadmap's verification gate. The hash-in-URL convention pins
16
- * the served bytes to the URL forever; immutable lets browsers
17
- * skip revalidation entirely.
18
- * - Other URLs get `Cache-Control: public, max-age=<defaultMaxAge>`
19
- * (default 3600 = 1h). Operators tune via opts.defaultMaxAge.
20
- *
21
- * ETag is the first 27 chars of SHA-384(file content), base64. 162
22
- * bits of collision resistance — overkill but cheap; same hash powers
23
- * the SRI integrity helper so we compute it once per file.
24
- *
25
- * 304 Not Modified: emitted when If-None-Match matches the ETag.
26
- *
27
- * HEAD: returns same headers as GET, no body.
28
- *
29
- * Range requests are NOT supported in v1. Operators serving video/audio
30
- * put a CDN in front. Compression (gzip/br) likewise — out of scope here.
31
- *
32
- * MIME types: minimal built-in table. Override via opts.contentTypes.
33
- *
34
- * Index files: a request resolving to a directory tries opts.indexFile
35
- * (default "index.html"); operators disable with opts.indexFile = null.
36
- *
37
- * Public API:
38
- *
39
- * staticServe.create({ root, mountPath?, hashedPathPattern?,
40
- * defaultMaxAge?, indexFile?, contentTypes? })
41
- * → (req, res, next) middleware
42
- *
43
- * await staticServe.integrity(filePath)
44
- * → "sha384-<base64>" suitable for an HTML integrity= attribute.
45
- * Cached per file across calls; invalidated on mtime change.
28
+ * Backward-compat shape: create(opts) MAY be called for the bare middleware
29
+ * (the v0.6.x shape)the result is callable as `(req, res, next)` and
30
+ * also exposes the new methods. That keeps existing tests + operator code
31
+ * passing while opening the surface.
46
32
  */
47
- var fs = require("fs");
48
- var fsp = require("fs/promises");
49
- var path = require("path");
50
- var nodeCrypto = require("crypto");
33
+
34
+ var fs = require("node:fs");
35
+ var fsp = require("node:fs/promises");
36
+ var nodeCrypto = require("node:crypto");
37
+ var path = require("node:path");
51
38
  var C = require("./constants");
39
+ var gateContract = require("./gate-contract");
40
+ var lazyRequire = require("./lazy-require");
41
+ var numericBounds = require("./numeric-bounds");
52
42
  var requestHelpers = require("./request-helpers");
53
43
  var validateOpts = require("./validate-opts");
44
+ var { StaticServeError } = require("./framework-error");
45
+
46
+ // observability is lazy-required because it pulls in the metrics tap +
47
+ // safeEvent path, and during framework boot static.js may load before
48
+ // observability is ready.
49
+ var observability = lazyRequire(function () { return require("./observability"); });
50
+
51
+ // guard-* family is wired on by default; lazy-loaded to avoid eager
52
+ // import cycles. Operators opt out via contentSafety: null (audited).
53
+ var guardAll = lazyRequire(function () { return require("./guard-all"); });
54
+ var guardFilename = lazyRequire(function () { return require("./guard-filename"); });
55
+
56
+ var _err = StaticServeError.factory;
54
57
 
55
58
  var HTTP = requestHelpers.HTTP_STATUS;
56
59
 
57
60
  var DEFAULT_HASHED_PATTERN = /\.[a-fA-F0-9]{8,}\./;
58
61
  var DEFAULT_INDEX_FILE = "index.html";
59
- var DEFAULT_MAX_AGE_SEC = C.TIME.hours(1) / C.TIME.seconds(1); // 1 hour for non-hashed paths
60
- var IMMUTABLE_MAX_AGE_SEC = C.TIME.days(365) / C.TIME.seconds(1); // 1 year for hashed paths
62
+ var DEFAULT_MAX_AGE_SEC = C.TIME.hours(1) / C.TIME.seconds(1); // 1h non-hashed
63
+ var IMMUTABLE_MAX_AGE_SEC = C.TIME.days(365) / C.TIME.seconds(1); // 1y hashed
64
+ var DEFAULT_BANDWIDTH_WINDOW_MS = C.TIME.minutes(1);
65
+ var DEFAULT_MAX_IDLE_MS = C.TIME.minutes(2);
66
+ // SHA3-512 produces 64 bytes / 128 hex chars. ETag uses the first 32 hex
67
+ // chars (128 bits) — overkill for collision resistance but cheap; the same
68
+ // hash powers content addressing across the framework.
69
+ var ETAG_HEX_PREFIX = C.BYTES.bytes(32);
61
70
 
62
- // Minimal MIME table. Operators with exotic types pass opts.contentTypes
63
- // to override. The framework deliberately doesn't bring a 200-entry
64
- // mime-db dependency — most servers serve a handful of types.
71
+ // Minimal MIME table (kept from the v0.6 ship for compat).
65
72
  var DEFAULT_CONTENT_TYPES = {
66
73
  ".html": "text/html; charset=utf-8",
67
74
  ".htm": "text/html; charset=utf-8",
@@ -76,7 +83,7 @@ var DEFAULT_CONTENT_TYPES = {
76
83
  ".svg": "image/svg+xml",
77
84
  ".png": "image/png",
78
85
  ".jpg": "image/jpeg",
79
- ".jpeg": "image/jpeg",
86
+ ".jpeg": "image/jpeg",
80
87
  ".gif": "image/gif",
81
88
  ".webp": "image/webp",
82
89
  ".avif": "image/avif",
@@ -90,11 +97,28 @@ var DEFAULT_CONTENT_TYPES = {
90
97
  ".webmanifest": "application/manifest+json",
91
98
  };
92
99
 
93
- // ---- Module-level metadata cache (for both middleware ETag and the
94
- // standalone integrity() helper). Keyed by absolute file path; entries
95
- // invalidated on mtime change.
100
+ var DEFAULTS = Object.freeze({
101
+ defaultMaxAge: DEFAULT_MAX_AGE_SEC,
102
+ acceptRanges: true,
103
+ // Empty array = no MIME allowlist gate.
104
+ allowedFileTypes: Object.freeze([]),
105
+ // Bandwidth + concurrency caps default to 0 = "no cap". Operators opt
106
+ // in by setting a positive integer.
107
+ maxBytesPerActorPerWindowMs: 0,
108
+ maxBytesAllActorsPerWindowMs: 0,
109
+ bandwidthWindowMs: DEFAULT_BANDWIDTH_WINDOW_MS,
110
+ maxConcurrentDownloadsPerActor: 0,
111
+ maxIdleMs: DEFAULT_MAX_IDLE_MS,
112
+ // Audit / observability defaults follow the framework's posture: the
113
+ // serve event is the audit-worthy act, not a precursor.
114
+ auditSuccess: true,
115
+ auditFailures: true,
116
+ });
96
117
 
97
- var _cache = new Map();
118
+ // Module-level metadata cache. Entries hold:
119
+ // { mtimeMs, size, etag, integrity, lastModified, sha3Hex, absPath }
120
+ // Invalidated on mtime / size change.
121
+ var _metaCache = new Map();
98
122
 
99
123
  async function _readMeta(absPath) {
100
124
  var stat;
@@ -102,42 +126,78 @@ async function _readMeta(absPath) {
102
126
  catch (_e) { return null; }
103
127
  if (!stat.isFile()) return null;
104
128
 
105
- var cached = _cache.get(absPath);
129
+ var cached = _metaCache.get(absPath);
106
130
  if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
107
131
  return cached;
108
132
  }
109
133
 
110
- // Stream the file through SHA-384. fs.readFileSync is simpler but
111
- // would balloon RSS for large assets operators shouldn't be
112
- // constrained on what they can serve.
113
- var hash = nodeCrypto.createHash("sha384");
134
+ // Stream both hashes side-by-side: SHA-384 for SRI (W3C spec), SHA3-512
135
+ // for the framework ETag (PQC posture). Two transforms over the same
136
+ // chunk avoids re-reading the file.
137
+ var sri = nodeCrypto.createHash("sha384");
138
+ var sha3 = nodeCrypto.createHash("sha3-512");
114
139
  await new Promise(function (resolve, reject) {
115
140
  var s = fs.createReadStream(absPath);
116
- s.on("data", function (chunk) { hash.update(chunk); });
117
- s.on("end", resolve);
141
+ s.on("data", function (chunk) { sri.update(chunk); sha3.update(chunk); });
142
+ s.on("end", resolve);
118
143
  s.on("error", reject);
119
144
  });
120
- var digest = hash.digest("base64");
145
+ var sriDigest = sri.digest("base64");
146
+ var sha3Hex = sha3.digest("hex");
121
147
 
122
148
  var entry = {
123
149
  mtimeMs: stat.mtimeMs,
124
150
  size: stat.size,
125
- etag: '"' + digest.slice(0, 27) + '"', // 162-bit ETag
126
- integrity: "sha384-" + digest,
151
+ etag: '"' + sha3Hex.slice(0, ETAG_HEX_PREFIX) + '"',
152
+ integrity: "sha384-" + sriDigest,
153
+ lastModified: new Date(stat.mtimeMs).toUTCString(),
154
+ sha3Hex: sha3Hex,
127
155
  absPath: absPath,
128
156
  };
129
- _cache.set(absPath, entry);
157
+ _metaCache.set(absPath, entry);
130
158
  return entry;
131
159
  }
132
160
 
133
161
  function _resolveSafe(root, requestedPath) {
134
162
  if (typeof requestedPath !== "string" || requestedPath.length === 0) return null;
135
163
  if (requestedPath.indexOf("\0") !== -1) return null;
136
- // path.resolve handles ".." normalization; we then check containment.
137
164
  var resolved = path.resolve(root, "." + requestedPath);
138
165
  var rootResolved = path.resolve(root);
139
166
  if (resolved !== rootResolved &&
140
167
  !resolved.startsWith(rootResolved + path.sep)) return null;
168
+
169
+ // Symlink-escape defense — the lexical resolve above only sees the
170
+ // requested path tokens; a symlink anywhere along `resolved` can
171
+ // still point outside `rootResolved` on disk. realpath every node
172
+ // (only when it exists; missing files are routed through the
173
+ // standard 404 path by the caller).
174
+ try {
175
+ var real = fs.realpathSync(resolved);
176
+ var rootReal = fs.realpathSync(rootResolved);
177
+ if (real !== rootReal && !real.startsWith(rootReal + path.sep)) return null;
178
+ resolved = real;
179
+ } catch (_e) {
180
+ // Path doesn't exist (or is denied) — fall through with the lexical
181
+ // resolution so the caller's stat() returns the natural ENOENT and
182
+ // 404s. realpath failures from non-existence are NOT a smuggling
183
+ // signal.
184
+ }
185
+
186
+ // Filename safety — the basename gates against path-traversal /
187
+ // null-byte / NTFS ADS / UNC / RTLO bidi / overlong UTF-8 / Windows
188
+ // reserved device names. Uses balanced profile + explicit
189
+ // shellExecExtPolicy: "allow" because static-serve serves operator-
190
+ // deposited disk content: shell-exec extensions (.exe / .bin / .so /
191
+ // legitimate `<name>.<hash>.js` bundler output) are valid here. The
192
+ // other balanced checks still reject the traversal + smuggling
193
+ // surface the user surfaced.
194
+ var fname = path.basename(resolved);
195
+ var rv = guardFilename().validate(fname, {
196
+ profile: "balanced",
197
+ shellExecExtPolicy: "allow",
198
+ });
199
+ if (!rv.ok) return null;
200
+
141
201
  return resolved;
142
202
  }
143
203
 
@@ -146,48 +206,333 @@ function _contentTypeFor(filePath, table) {
146
206
  return (table && table[ext]) || DEFAULT_CONTENT_TYPES[ext] || "application/octet-stream";
147
207
  }
148
208
 
149
- function _writeNotModified(res, etag, cacheControl) {
150
- res.writeHead(HTTP.NOT_MODIFIED, {
151
- "ETag": etag,
152
- "Cache-Control": cacheControl,
153
- });
154
- res.end();
209
+ // _parseRangeHeader RFC 7233 single-range parser. Returns null when:
210
+ // - header absent
211
+ // - syntactically malformed (not `bytes=`, multi-range, suffix syntax
212
+ // "-N" handled, end > size, start > end)
213
+ // - the request can be answered as 416 the caller does (start >= size).
214
+ //
215
+ // Returns { start, end, length } for a valid satisfiable single range.
216
+ // Multi-range (`bytes=0-99,200-299`) returns { multi: true } so the
217
+ // caller can refuse with 416 — we don't ship multipart/byteranges in v1
218
+ // (operators with that need pull bytes via b.objectStore presigned URL).
219
+ function _parseRangeHeader(header, size) {
220
+ if (typeof header !== "string" || header.length === 0) return null;
221
+ if (header.indexOf("bytes=") !== 0) return { malformed: true };
222
+ var spec = header.slice(6).trim();
223
+ if (spec.length === 0) return { malformed: true };
224
+ if (spec.indexOf(",") !== -1) return { multi: true };
225
+ var dash = spec.indexOf("-");
226
+ if (dash === -1) return { malformed: true };
227
+ var startStr = spec.slice(0, dash);
228
+ var endStr = spec.slice(dash + 1);
229
+ var start, end;
230
+ if (startStr === "") {
231
+ // Suffix range: "bytes=-N" → last N bytes.
232
+ var suffix = parseInt(endStr, 10);
233
+ if (!isFinite(suffix) || suffix <= 0) return { malformed: true };
234
+ if (suffix > size) suffix = size;
235
+ start = size - suffix;
236
+ end = size - 1;
237
+ } else {
238
+ start = parseInt(startStr, 10);
239
+ if (!isFinite(start) || start < 0) return { malformed: true };
240
+ if (endStr === "") {
241
+ end = size - 1;
242
+ } else {
243
+ end = parseInt(endStr, 10);
244
+ if (!isFinite(end) || end < start) return { malformed: true };
245
+ if (end > size - 1) end = size - 1;
246
+ }
247
+ }
248
+ if (start >= size) return { unsatisfiable: true };
249
+ return { start: start, end: end, length: end - start + 1 };
155
250
  }
156
251
 
157
- function _writeNotFound(res) {
158
- res.writeHead(HTTP.NOT_FOUND, { "Content-Type": "text/plain; charset=utf-8", "Content-Length": 9 });
159
- res.end("Not Found");
252
+ function _httpDate(date) {
253
+ return (date instanceof Date ? date : new Date(date)).toUTCString();
160
254
  }
161
255
 
162
- // ---- Public: integrity() ----
256
+ function _validateCreateOpts(opts) {
257
+ validateOpts.requireObject(opts, "staticServe.create", StaticServeError);
258
+ validateOpts.requireNonEmptyString(opts.root, "staticServe.create: root", StaticServeError, "BAD_OPT");
259
+ if (!fs.existsSync(opts.root)) {
260
+ throw _err("BAD_OPT", "staticServe.create: root does not exist: " + opts.root);
261
+ }
262
+ if (typeof opts.mountPath === "string" && opts.mountPath.length === 0) {
263
+ // empty string is operator-permissible: "no mount, root is request URL"
264
+ } else if (opts.mountPath !== undefined && opts.mountPath !== null &&
265
+ typeof opts.mountPath !== "string") {
266
+ throw _err("BAD_OPT", "staticServe.create: mountPath must be a string");
267
+ }
268
+ if (opts.hashedPathPattern !== undefined && opts.hashedPathPattern !== null &&
269
+ !(opts.hashedPathPattern instanceof RegExp)) {
270
+ throw _err("BAD_OPT", "staticServe.create: hashedPathPattern must be a RegExp");
271
+ }
272
+ // indexFile === null is the operator's "disable" sentinel; the helper
273
+ // returns null/undefined unchanged so we keep that semantic.
274
+ validateOpts.optionalNonEmptyString(opts.indexFile,
275
+ "staticServe.create: indexFile", StaticServeError, "BAD_OPT");
276
+ numericBounds.requireNonNegativeFiniteIntIfPresent(opts.defaultMaxAge,
277
+ "staticServe.create: defaultMaxAge", StaticServeError, "BAD_OPT");
278
+ validateOpts.optionalPlainObject(opts.contentTypes,
279
+ "staticServe.create: contentTypes", StaticServeError, "BAD_OPT");
280
+ validateOpts.optionalObjectWithMethod(opts.permissions, "check",
281
+ "staticServe.create: permissions", StaticServeError, "BAD_OPT",
282
+ "must be a b.permissions instance (check fn)");
283
+ validateOpts.optionalObjectWithMethod(opts.cache, "get",
284
+ "staticServe.create: cache", StaticServeError, "BAD_OPT",
285
+ "must be a b.cache instance (used for cluster-shared bandwidth + concurrency tracking)");
286
+ validateOpts.optionalObjectWithMethod(opts.fileType, "detect",
287
+ "staticServe.create: fileType", StaticServeError, "BAD_OPT",
288
+ "must be a b.fileType instance (magic-byte MIME detection)");
289
+ validateOpts.optionalObjectWithMethod(opts.retention, "isServable",
290
+ "staticServe.create: retention", StaticServeError, "BAD_OPT",
291
+ "must expose isServable(absPath, ctx) → boolean (compliance retention check)");
292
+ validateOpts.optionalObjectWithMethod(opts.revokeStore, "isRevoked",
293
+ "staticServe.create: revokeStore", StaticServeError, "BAD_OPT",
294
+ "must expose isRevoked(key) and revoke(key) for force-revoke support");
295
+ validateOpts.optionalNonEmptyStringArray(opts.allowedFileTypes,
296
+ "staticServe.create: allowedFileTypes", StaticServeError, "BAD_OPT");
297
+ if (Array.isArray(opts.allowedFileTypes) && opts.allowedFileTypes.length > 0 &&
298
+ (!opts.fileType || typeof opts.fileType.detect !== "function")) {
299
+ throw _err("BAD_OPT",
300
+ "staticServe.create: allowedFileTypes is set but fileType primitive is not wired " +
301
+ "(pass fileType: b.fileType so the framework can sniff magic bytes before serving)");
302
+ }
303
+ validateOpts.auditShape(opts.audit, "staticServe.create", StaticServeError);
304
+ validateOpts.observabilityShape(opts.observability, "staticServe.create", StaticServeError);
305
+ validateOpts.optionalFunction(opts.onServe, "staticServe.create: onServe", StaticServeError);
306
+ // contentSafety — extension-keyed gate map. Default behaviour: when
307
+ // undefined, the framework wires b.guardAll.byExtension({ profile:
308
+ // "strict" }) automatically so every shipped guard is ON by default.
309
+ // Explicit opt-out: contentSafety: null (audited at create() time so
310
+ // a security review can reconstruct which deploys disabled the
311
+ // default-on protection).
312
+ // Example: contentSafety: { ".csv": b.guardCsv.gate({ profile: "strict" }) }
313
+ if (opts.contentSafety !== undefined && opts.contentSafety !== null) {
314
+ validateOpts.optionalPlainObject(opts.contentSafety,
315
+ "staticServe.create: contentSafety", StaticServeError, "BAD_OPT",
316
+ "must be a plain { ext: gate } object, null to opt out, or " +
317
+ "undefined for the default-on b.guardAll wiring");
318
+ var safetyKeys = Object.keys(opts.contentSafety);
319
+ for (var sk = 0; sk < safetyKeys.length; sk++) {
320
+ var ext = safetyKeys[sk];
321
+ var g = opts.contentSafety[ext];
322
+ if (!g || typeof g.check !== "function") {
323
+ throw _err("BAD_OPT",
324
+ "staticServe.create: contentSafety[" + JSON.stringify(ext) +
325
+ "] must be a gate (b.guardCsv.gate / b.guardHtml.gate / etc.)");
326
+ }
327
+ }
328
+ }
329
+ validateOpts.optionalBoolean(opts.acceptRanges, "staticServe.create: acceptRanges", StaticServeError);
330
+ validateOpts.optionalBoolean(opts.auditSuccess, "staticServe.create: auditSuccess", StaticServeError);
331
+ validateOpts.optionalBoolean(opts.auditFailures, "staticServe.create: auditFailures", StaticServeError);
332
+ numericBounds.requireNonNegativeFiniteIntIfPresent(opts.maxBytesPerActorPerWindowMs,
333
+ "staticServe.create: maxBytesPerActorPerWindowMs", StaticServeError, "BAD_OPT");
334
+ numericBounds.requireNonNegativeFiniteIntIfPresent(opts.maxBytesAllActorsPerWindowMs,
335
+ "staticServe.create: maxBytesAllActorsPerWindowMs", StaticServeError, "BAD_OPT");
336
+ numericBounds.requirePositiveFiniteIntIfPresent(opts.bandwidthWindowMs,
337
+ "staticServe.create: bandwidthWindowMs", StaticServeError, "BAD_OPT");
338
+ numericBounds.requireNonNegativeFiniteIntIfPresent(opts.maxConcurrentDownloadsPerActor,
339
+ "staticServe.create: maxConcurrentDownloadsPerActor", StaticServeError, "BAD_OPT");
340
+ numericBounds.requirePositiveFiniteIntIfPresent(opts.maxIdleMs,
341
+ "staticServe.create: maxIdleMs", StaticServeError, "BAD_OPT");
342
+ // Quotas require a cache for cluster-shared coordination.
343
+ if ((opts.maxBytesPerActorPerWindowMs > 0 ||
344
+ opts.maxBytesAllActorsPerWindowMs > 0 ||
345
+ opts.maxConcurrentDownloadsPerActor > 0) &&
346
+ !opts.cache) {
347
+ throw _err("BAD_OPT",
348
+ "staticServe.create: bandwidth / concurrency quotas require opts.cache " +
349
+ "(pass cache: b.cache.create({ backend: 'cluster' }) so multi-replica deploys honor caps globally)");
350
+ }
351
+ }
163
352
 
353
+ // _checkBandwidthQuota — token-bucket via b.cache. Returns { ok: true } or
354
+ // { ok: false, retryAfter, scope: "actor"|"global" }.
355
+ async function _checkBandwidthQuota(cache, actorKey, perActorCap, globalCap, windowMs, requestedBytes) {
356
+ if (!cache || (perActorCap === 0 && globalCap === 0)) return { ok: true };
357
+ var now = Date.now();
358
+ var windowStart = now - windowMs;
359
+ if (perActorCap > 0 && actorKey) {
360
+ var aKey = "static:bw:actor:" + actorKey;
361
+ var aUsed = (await cache.get(aKey)) || 0;
362
+ if (aUsed + requestedBytes > perActorCap) {
363
+ return { ok: false, retryAfter: Math.ceil(windowMs / C.TIME.seconds(1)), scope: "actor", used: aUsed, cap: perActorCap };
364
+ }
365
+ }
366
+ if (globalCap > 0) {
367
+ var gKey = "static:bw:global";
368
+ var gUsed = (await cache.get(gKey)) || 0;
369
+ if (gUsed + requestedBytes > globalCap) {
370
+ return { ok: false, retryAfter: Math.ceil(windowMs / C.TIME.seconds(1)), scope: "global", used: gUsed, cap: globalCap };
371
+ }
372
+ }
373
+ return { ok: true, windowStart: windowStart, now: now };
374
+ }
375
+
376
+ async function _consumeBandwidth(cache, actorKey, perActorCap, globalCap, windowMs, bytes) {
377
+ if (!cache) return;
378
+ if (perActorCap > 0 && actorKey) {
379
+ var aKey = "static:bw:actor:" + actorKey;
380
+ var aUsed = (await cache.get(aKey)) || 0;
381
+ await cache.set(aKey, aUsed + bytes, { ttlMs: windowMs });
382
+ }
383
+ if (globalCap > 0) {
384
+ var gKey = "static:bw:global";
385
+ var gUsed = (await cache.get(gKey)) || 0;
386
+ await cache.set(gKey, gUsed + bytes, { ttlMs: windowMs });
387
+ }
388
+ }
389
+
390
+ async function _checkConcurrencyCap(cache, actorKey, cap) {
391
+ if (!cache || cap === 0 || !actorKey) return { ok: true };
392
+ var key = "static:conc:" + actorKey;
393
+ var current = (await cache.get(key)) || 0;
394
+ if (current >= cap) return { ok: false, current: current, cap: cap };
395
+ return { ok: true, current: current };
396
+ }
397
+
398
+ async function _incConcurrency(cache, actorKey) {
399
+ if (!cache || !actorKey) return;
400
+ var key = "static:conc:" + actorKey;
401
+ var current = (await cache.get(key)) || 0;
402
+ await cache.set(key, current + 1, { ttlMs: C.TIME.minutes(10) });
403
+ }
404
+
405
+ async function _decConcurrency(cache, actorKey) {
406
+ if (!cache || !actorKey) return;
407
+ var key = "static:conc:" + actorKey;
408
+ var current = (await cache.get(key)) || 0;
409
+ var next = current > 0 ? current - 1 : 0;
410
+ await cache.set(key, next, { ttlMs: C.TIME.minutes(10) });
411
+ }
412
+
413
+ function _actorKeyFromContext(ctx) {
414
+ if (!ctx) return null;
415
+ if (ctx.userId) return "id:" + ctx.userId;
416
+ if (ctx.ip) return "ip:" + ctx.ip;
417
+ return null;
418
+ }
419
+
420
+ // _writeError — uniform error response with audit + observability emission.
421
+ function _writeError(res, status, code, message, headers) {
422
+ var hdrs = Object.assign({ "Content-Type": "text/plain; charset=utf-8" }, headers || {});
423
+ hdrs["Content-Length"] = Buffer.byteLength(message, "utf8");
424
+ try {
425
+ res.writeHead(status, hdrs);
426
+ res.end(message);
427
+ } catch (_e) {
428
+ // response already torn down — best effort
429
+ }
430
+ void code;
431
+ }
432
+
433
+ // integrity() — module-level helper, kept for compat with the v0.6 SRI use.
164
434
  async function integrity(absPath) {
165
435
  if (typeof absPath !== "string" || absPath.length === 0) {
166
- throw new Error("staticServe.integrity: absPath must be a non-empty string");
436
+ throw _err("BAD_OPT", "staticServe.integrity: absPath must be a non-empty string");
167
437
  }
168
438
  var meta = await _readMeta(path.resolve(absPath));
169
- if (!meta) throw new Error("staticServe.integrity: file not found: " + absPath);
439
+ if (!meta) throw _err("NOT_FOUND", "staticServe.integrity: file not found: " + absPath);
170
440
  return meta.integrity;
171
441
  }
172
442
 
173
- // ---- Public: create() ----
174
-
175
443
  function create(opts) {
176
444
  opts = opts || {};
445
+ // The v0.6.x test surface called `validateOpts(opts, [...allowed], label)`
446
+ // for the unknown-key check. Preserve that gate in addition to the
447
+ // throw-at-config-time validation so tests catch typos.
177
448
  validateOpts(opts, [
178
449
  "root", "mountPath", "hashedPathPattern",
179
450
  "indexFile", "defaultMaxAge", "contentTypes",
180
- ], "b.staticServe");
181
- if (!opts.root) throw new Error("staticServe.create({ root }) is required");
182
- if (!fs.existsSync(opts.root)) {
183
- throw new Error("staticServe.create: root does not exist: " + opts.root);
184
- }
451
+ "permissions", "cache", "fileType", "retention", "revokeStore",
452
+ "allowedFileTypes", "audit", "observability", "onServe",
453
+ "acceptRanges", "auditSuccess", "auditFailures",
454
+ "maxBytesPerActorPerWindowMs", "maxBytesAllActorsPerWindowMs",
455
+ "bandwidthWindowMs", "maxConcurrentDownloadsPerActor", "maxIdleMs",
456
+ "contentSafety", "contentSafetyDisabledReason",
457
+ ], "staticServe.create");
458
+ _validateCreateOpts(opts);
459
+ var cfg = validateOpts.applyDefaults(opts, DEFAULTS);
185
460
  var root = path.resolve(opts.root);
186
- var mountPath = opts.mountPath || ""; // strip from req URL before lookup
461
+ var mountPath = opts.mountPath || "";
187
462
  var hashedPattern = opts.hashedPathPattern || DEFAULT_HASHED_PATTERN;
188
463
  var indexFile = opts.indexFile === null ? null : (opts.indexFile || DEFAULT_INDEX_FILE);
189
- var defaultMaxAge = typeof opts.defaultMaxAge === "number" ? opts.defaultMaxAge : DEFAULT_MAX_AGE_SEC;
464
+ var defaultMaxAge = cfg.defaultMaxAge;
190
465
  var contentTypes = opts.contentTypes || null;
466
+ var permissions = opts.permissions || null;
467
+ var cache = opts.cache || null;
468
+ var fileType = opts.fileType || null;
469
+ var retention = opts.retention || null;
470
+ var revokeStore = opts.revokeStore || null;
471
+ var allowedFileTypes = Array.isArray(opts.allowedFileTypes) ? opts.allowedFileTypes.slice() : [];
472
+ // contentSafety: undefined → wire b.guardAll.byExtension({ profile: "strict" })
473
+ // contentSafety: null → explicit opt-out, audit row emitted
474
+ // contentSafety: { ... } → use operator-supplied map
475
+ var contentSafety;
476
+ if (opts.contentSafety === undefined) {
477
+ // Strict profile is the security-correct default. Operators who
478
+ // serve a broader content vocabulary opt up explicitly via
479
+ // contentSafety: b.guardAll.byExtension({ profile: "balanced" |
480
+ // "permissive" }).
481
+ contentSafety = guardAll().byExtension({
482
+ profile: "strict",
483
+ audit: opts.audit,
484
+ observability: opts.observability,
485
+ });
486
+ } else if (opts.contentSafety === null) {
487
+ if (opts.audit && typeof opts.audit.safeEmit === "function") {
488
+ try {
489
+ opts.audit.safeEmit({
490
+ action: "staticServe.contentSafety.disabled",
491
+ actor: {},
492
+ outcome: "success",
493
+ metadata: {
494
+ reason: opts.contentSafetyDisabledReason || "operator-explicit-opt-out",
495
+ },
496
+ });
497
+ } catch (_e) { /* audit best-effort */ }
498
+ }
499
+ contentSafety = null;
500
+ } else {
501
+ contentSafety = opts.contentSafety;
502
+ }
503
+ var onServe = opts.onServe || null;
504
+ var audit = opts.audit || null;
505
+ var auditSuccess = cfg.auditSuccess;
506
+ var auditFailures = cfg.auditFailures;
507
+ var acceptRanges = cfg.acceptRanges;
508
+ var perActorCap = cfg.maxBytesPerActorPerWindowMs;
509
+ var globalCap = cfg.maxBytesAllActorsPerWindowMs;
510
+ var bandwidthWindowMs = cfg.bandwidthWindowMs;
511
+ var concurrencyCap = cfg.maxConcurrentDownloadsPerActor;
512
+ var maxIdleMs = cfg.maxIdleMs;
513
+
514
+ var emitAudit = validateOpts.makeAuditEmitter(audit);
515
+
516
+ // In-memory revoke set (operator can wire revokeStore for cluster-shared
517
+ // revocation; this Map is only used when no store is wired and gives
518
+ // single-process operators a working force-revoke without requiring cache).
519
+ var localRevoked = new Set();
520
+
521
+ function _emitObs(name, value, labels) {
522
+ observability().safeEvent(name, value, labels || {});
523
+ }
524
+
525
+ // Per-instance counters for serve.stats(). Cluster-shared counters live in
526
+ // observability; these are local snapshots for a single process.
527
+ var stats = {
528
+ requestsServed: 0,
529
+ bytesServed: 0,
530
+ etagHits: 0,
531
+ rangeRequests: 0,
532
+ permissionDenied: 0,
533
+ quotaRejected: 0,
534
+ failures: 0,
535
+ };
191
536
 
192
537
  function _cacheControlFor(urlPath) {
193
538
  if (hashedPattern.test(urlPath)) {
@@ -196,15 +541,51 @@ function create(opts) {
196
541
  return "public, max-age=" + defaultMaxAge;
197
542
  }
198
543
 
199
- return async function staticServe(req, res, next) {
544
+ async function _isRevoked(key) {
545
+ if (revokeStore) {
546
+ try { return !!(await revokeStore.isRevoked(key)); }
547
+ catch (_e) { return false; }
548
+ }
549
+ return localRevoked.has(key);
550
+ }
551
+
552
+ async function _checkRetention(absPath, ctx) {
553
+ if (!retention) return true;
554
+ try { return !!(await retention.isServable(absPath, ctx)); }
555
+ catch (_e) { return false; }
556
+ }
557
+
558
+ async function _checkPermission(req) {
559
+ if (!permissions) return { ok: true };
560
+ try {
561
+ var ok = await permissions.check(req, "static.serve");
562
+ return { ok: !!ok };
563
+ } catch (_e) {
564
+ return { ok: false, error: _e };
565
+ }
566
+ }
567
+
568
+ async function _checkMimeAllowlist(absPath, meta) {
569
+ if (allowedFileTypes.length === 0 || !fileType) return { ok: true };
570
+ var sample;
571
+ try { sample = await fsp.readFile(absPath, { flag: "r" }); }
572
+ catch (_e) { return { ok: false, reason: "read-failed" }; }
573
+ var detected = fileType.detect(sample.slice(0, C.BYTES.kib(64))) || {};
574
+ if (!detected.mime) return { ok: false, reason: "indeterminate" };
575
+ if (allowedFileTypes.indexOf(detected.mime) === -1) {
576
+ return { ok: false, reason: "not-allowed", detected: detected.mime };
577
+ }
578
+ void meta;
579
+ return { ok: true, detected: detected.mime };
580
+ }
581
+
582
+ async function middleware(req, res, next) {
200
583
  if (req.method !== "GET" && req.method !== "HEAD") return next();
201
584
 
202
- // Strip query string + mount path before resolving against root.
203
585
  var urlPath = (req.url || "").split("?")[0];
204
586
  if (mountPath && urlPath.indexOf(mountPath) === 0) {
205
587
  urlPath = urlPath.slice(mountPath.length) || "/";
206
588
  }
207
- // Decode percent-encoded path. Reject decoding failures (malformed URI).
208
589
  var decoded;
209
590
  try { decoded = decodeURIComponent(urlPath); }
210
591
  catch (_e) { return next(); }
@@ -212,7 +593,24 @@ function create(opts) {
212
593
  var absPath = _resolveSafe(root, decoded);
213
594
  if (!absPath) return next();
214
595
 
215
- // Directory index file (if configured)
596
+ var actorCtx = requestHelpers.extractActorContext(req);
597
+ var actorKey = _actorKeyFromContext(actorCtx);
598
+
599
+ // Permission gate (403)
600
+ var perm = await _checkPermission(req);
601
+ if (!perm.ok) {
602
+ stats.permissionDenied += 1;
603
+ _emitObs("staticServe.permission_denied", 1, { route: urlPath });
604
+ if (auditFailures) {
605
+ emitAudit("staticServe.serve.failure", Object.assign({
606
+ outcome: "failure", reason: "permission_denied", resource: urlPath,
607
+ }, actorCtx));
608
+ }
609
+ return _writeError(res, HTTP.FORBIDDEN, "permission_denied",
610
+ "Forbidden");
611
+ }
612
+
613
+ // Stat first to discover directory → index file.
216
614
  var stat;
217
615
  try { stat = await fsp.stat(absPath); }
218
616
  catch (_e) { return next(); }
@@ -221,52 +619,419 @@ function create(opts) {
221
619
  absPath = path.join(absPath, indexFile);
222
620
  }
223
621
 
622
+ // Force-revoke (404 — opaque to clients)
623
+ if (await _isRevoked(absPath)) {
624
+ stats.failures += 1;
625
+ _emitObs("staticServe.revoked", 1, { route: urlPath });
626
+ if (auditFailures) {
627
+ emitAudit("staticServe.serve.failure", Object.assign({
628
+ outcome: "failure", reason: "revoked", resource: urlPath,
629
+ }, actorCtx));
630
+ }
631
+ return _writeError(res, HTTP.NOT_FOUND, "not_found", "Not Found");
632
+ }
633
+
634
+ // Compliance retention (451)
635
+ if (!(await _checkRetention(absPath, actorCtx))) {
636
+ stats.failures += 1;
637
+ _emitObs("staticServe.retention_blocked", 1, { route: urlPath });
638
+ if (auditFailures) {
639
+ emitAudit("staticServe.serve.failure", Object.assign({
640
+ outcome: "failure", reason: "retention_blocked", resource: urlPath,
641
+ }, actorCtx));
642
+ }
643
+ return _writeError(res, HTTP.UNAVAILABLE_FOR_LEGAL_REASONS,
644
+ "retention_blocked", "Unavailable For Legal Reasons");
645
+ }
646
+
224
647
  var meta = await _readMeta(absPath);
225
648
  if (!meta) return next();
226
649
 
650
+ // MIME allowlist (415) — checked before sending bytes so a misnamed
651
+ // .txt holding a binary payload is refused at serve time.
652
+ if (allowedFileTypes.length > 0) {
653
+ var mimeCheck = await _checkMimeAllowlist(absPath, meta);
654
+ if (!mimeCheck.ok) {
655
+ stats.failures += 1;
656
+ _emitObs("staticServe.mime_rejected", 1, { route: urlPath, reason: mimeCheck.reason });
657
+ if (auditFailures) {
658
+ emitAudit("staticServe.serve.failure", Object.assign({
659
+ outcome: "failure", reason: "mime_rejected", resource: urlPath,
660
+ detectedMime: mimeCheck.detected || null,
661
+ }, actorCtx));
662
+ }
663
+ return _writeError(res, HTTP.UNSUPPORTED_MEDIA_TYPE,
664
+ "mime_rejected", "Unsupported Media Type");
665
+ }
666
+ }
667
+
668
+ // Content-safety gate — operator-supplied per-extension gate
669
+ // (b.guardCsv.gate / b.guardHtml.gate / etc.). Reads the file once
670
+ // up to maxRuntimeMs and routes the bytes through the gate's
671
+ // check() before serving. The gate's decision is honored:
672
+ // - serve → continue with the original bytes
673
+ // - sanitize → continue with decision.sanitized
674
+ // - refuse → 415 / opaque to clients
675
+ // - audit-only / warn → continue (gate emits to audit)
676
+ var gateBytesOverride = null;
677
+ if (contentSafety) {
678
+ var ext = path.extname(absPath).toLowerCase();
679
+ var safetyGate = contentSafety[ext];
680
+ if (safetyGate && typeof safetyGate.check === "function") {
681
+ var gateBuf;
682
+ try { gateBuf = await fsp.readFile(absPath); }
683
+ catch (_e) {
684
+ stats.failures += 1;
685
+ return _writeError(res, HTTP.INTERNAL_SERVER_ERROR,
686
+ "read_failed", "Internal Server Error");
687
+ }
688
+ var gateDecision;
689
+ try {
690
+ gateDecision = await safetyGate.check({
691
+ bytes: gateBuf,
692
+ contentType: _contentTypeFor(absPath, contentTypes),
693
+ filename: path.basename(absPath),
694
+ actor: actorCtx,
695
+ route: urlPath,
696
+ direction: "outbound",
697
+ req: req,
698
+ });
699
+ } catch (gateErr) {
700
+ stats.failures += 1;
701
+ _emitObs("staticServe.content_safety_threw", 1, { route: urlPath });
702
+ if (auditFailures) {
703
+ emitAudit("staticServe.serve.failure", Object.assign({
704
+ outcome: "failure", reason: "content_safety_threw", resource: urlPath,
705
+ error: gateErr && gateErr.message,
706
+ }, actorCtx));
707
+ }
708
+ return _writeError(res, HTTP.INTERNAL_SERVER_ERROR,
709
+ "content_safety_threw", "Internal Server Error");
710
+ }
711
+ if (!gateDecision.ok || gateDecision.action === "refuse") {
712
+ stats.failures += 1;
713
+ _emitObs("staticServe.content_safety_refused", 1, { route: urlPath });
714
+ if (auditFailures) {
715
+ emitAudit("staticServe.serve.failure", Object.assign({
716
+ outcome: "failure", reason: "content_safety_refused",
717
+ resource: urlPath, ext: ext,
718
+ issues: gateContract.summarizeIssues(gateDecision.issues),
719
+ }, actorCtx));
720
+ }
721
+ return _writeError(res, HTTP.UNSUPPORTED_MEDIA_TYPE,
722
+ "content_safety_refused", "Unsupported Media Type");
723
+ }
724
+ if (gateDecision.action === "sanitize" && gateDecision.sanitized) {
725
+ gateBytesOverride = gateDecision.sanitized;
726
+ }
727
+ }
728
+ }
729
+
227
730
  var cacheControl = _cacheControlFor(urlPath);
228
731
 
229
- // 304 short-circuit
230
- var ifNone = req.headers && req.headers["if-none-match"];
732
+ var headersIn = req.headers || {};
733
+
734
+ // Conditional: If-None-Match (304)
735
+ var ifNone = headersIn["if-none-match"];
231
736
  if (ifNone && ifNone === meta.etag) {
232
- return _writeNotModified(res, meta.etag, cacheControl);
737
+ stats.etagHits += 1;
738
+ _emitObs("staticServe.etag_hits", 1, { route: urlPath });
739
+ res.writeHead(HTTP.NOT_MODIFIED, {
740
+ "ETag": meta.etag,
741
+ "Cache-Control": cacheControl,
742
+ "Last-Modified": meta.lastModified,
743
+ });
744
+ return res.end();
745
+ }
746
+
747
+ // Conditional: If-Match (412 if no match — strong validator only)
748
+ var ifMatch = headersIn["if-match"];
749
+ if (ifMatch && ifMatch !== "*" && ifMatch !== meta.etag) {
750
+ stats.failures += 1;
751
+ _emitObs("staticServe.precondition_failed", 1, { route: urlPath, header: "if-match" });
752
+ return _writeError(res, HTTP.PRECONDITION_FAILED || 412,
753
+ "precondition_failed", "Precondition Failed");
754
+ }
755
+
756
+ // Conditional: If-Modified-Since (304)
757
+ var ifModSince = headersIn["if-modified-since"];
758
+ if (ifModSince) {
759
+ var ims = Date.parse(ifModSince);
760
+ if (isFinite(ims) && Math.floor(meta.mtimeMs / C.TIME.seconds(1)) <= Math.floor(ims / C.TIME.seconds(1))) {
761
+ stats.etagHits += 1;
762
+ _emitObs("staticServe.if_modified_since_hits", 1, { route: urlPath });
763
+ res.writeHead(HTTP.NOT_MODIFIED, {
764
+ "ETag": meta.etag,
765
+ "Cache-Control": cacheControl,
766
+ "Last-Modified": meta.lastModified,
767
+ });
768
+ return res.end();
769
+ }
770
+ }
771
+
772
+ // Conditional: If-Unmodified-Since (412)
773
+ var ifUnmodSince = headersIn["if-unmodified-since"];
774
+ if (ifUnmodSince) {
775
+ var ius = Date.parse(ifUnmodSince);
776
+ if (isFinite(ius) && Math.floor(meta.mtimeMs / C.TIME.seconds(1)) > Math.floor(ius / C.TIME.seconds(1))) {
777
+ stats.failures += 1;
778
+ _emitObs("staticServe.precondition_failed", 1, { route: urlPath, header: "if-unmodified-since" });
779
+ return _writeError(res, HTTP.PRECONDITION_FAILED,
780
+ "precondition_failed", "Precondition Failed");
781
+ }
782
+ }
783
+
784
+ // Range parsing
785
+ var range = null;
786
+ if (acceptRanges) {
787
+ var raw = headersIn["range"];
788
+ if (raw) {
789
+ range = _parseRangeHeader(raw, meta.size);
790
+ if (range && (range.malformed || range.multi)) {
791
+ stats.failures += 1;
792
+ _emitObs("staticServe.range_invalid", 1, { route: urlPath });
793
+ return _writeError(res, HTTP.RANGE_NOT_SATISFIABLE, "range_not_satisfiable",
794
+ "Range Not Satisfiable", { "Content-Range": "bytes */" + meta.size });
795
+ }
796
+ if (range && range.unsatisfiable) {
797
+ stats.failures += 1;
798
+ _emitObs("staticServe.range_invalid", 1, { route: urlPath });
799
+ return _writeError(res, HTTP.RANGE_NOT_SATISFIABLE, "range_not_satisfiable",
800
+ "Range Not Satisfiable", { "Content-Range": "bytes */" + meta.size });
801
+ }
802
+ if (range) {
803
+ stats.rangeRequests += 1;
804
+ _emitObs("staticServe.range_requests", 1, { route: urlPath });
805
+ }
806
+ }
807
+ }
808
+
809
+ var sendBytes = range ? range.length : meta.size;
810
+
811
+ // Concurrency gate (429)
812
+ var concCheck = await _checkConcurrencyCap(cache, actorKey, concurrencyCap);
813
+ if (!concCheck.ok) {
814
+ stats.quotaRejected += 1;
815
+ _emitObs("staticServe.concurrency_rejected", 1, { route: urlPath });
816
+ if (auditFailures) {
817
+ emitAudit("staticServe.serve.failure", Object.assign({
818
+ outcome: "failure", reason: "concurrency_cap", resource: urlPath,
819
+ current: concCheck.current, cap: concCheck.cap,
820
+ }, actorCtx));
821
+ }
822
+ return _writeError(res, HTTP.TOO_MANY_REQUESTS,
823
+ "concurrency_cap", "Too Many Requests",
824
+ { "Retry-After": "5" });
233
825
  }
234
826
 
827
+ // Bandwidth gate (429)
828
+ var bwCheck = await _checkBandwidthQuota(cache, actorKey, perActorCap, globalCap, bandwidthWindowMs, sendBytes);
829
+ if (!bwCheck.ok) {
830
+ stats.quotaRejected += 1;
831
+ _emitObs("staticServe.bandwidth_rejected", 1, { route: urlPath, scope: bwCheck.scope });
832
+ if (auditFailures) {
833
+ emitAudit("staticServe.serve.failure", Object.assign({
834
+ outcome: "failure", reason: "bandwidth_quota", resource: urlPath,
835
+ scope: bwCheck.scope, used: bwCheck.used, cap: bwCheck.cap,
836
+ }, actorCtx));
837
+ }
838
+ return _writeError(res, HTTP.TOO_MANY_REQUESTS,
839
+ "bandwidth_quota", "Too Many Requests",
840
+ { "Retry-After": String(bwCheck.retryAfter) });
841
+ }
842
+
843
+ var status = range ? 206 : HTTP.OK;
235
844
  var headers = {
236
845
  "Content-Type": _contentTypeFor(absPath, contentTypes),
237
- "Content-Length": meta.size,
846
+ "Content-Length": sendBytes,
238
847
  "ETag": meta.etag,
239
848
  "Cache-Control": cacheControl,
240
- // SRI hint for templates that want to <script integrity=…>; not
241
- // required by clients but consumers can read it from response
242
- // headers when they want to embed integrity in subsequent pages.
849
+ "Last-Modified": meta.lastModified,
243
850
  "X-Integrity": meta.integrity,
244
851
  };
852
+ if (acceptRanges) headers["Accept-Ranges"] = "bytes";
853
+ if (range) headers["Content-Range"] = "bytes " + range.start + "-" + range.end + "/" + meta.size;
854
+
855
+ // onServe hook — operator can mutate headers / set extra fields.
856
+ if (onServe) {
857
+ try {
858
+ await onServe({
859
+ req: req, res: res, absPath: absPath, urlPath: urlPath,
860
+ size: meta.size, sendBytes: sendBytes, range: range,
861
+ headers: headers, actor: actorCtx,
862
+ });
863
+ } catch (e) {
864
+ stats.failures += 1;
865
+ _emitObs("staticServe.onServe_threw", 1, { route: urlPath });
866
+ if (auditFailures) {
867
+ emitAudit("staticServe.serve.failure", Object.assign({
868
+ outcome: "failure", reason: "onServe_threw", resource: urlPath,
869
+ error: e && e.message,
870
+ }, actorCtx));
871
+ }
872
+ return _writeError(res, HTTP.INTERNAL_SERVER_ERROR, "onServe_threw",
873
+ "Internal Server Error");
874
+ }
875
+ }
245
876
 
246
877
  if (req.method === "HEAD") {
247
- res.writeHead(HTTP.OK, headers);
248
- return res.end();
878
+ res.writeHead(status, headers);
879
+ res.end();
880
+ stats.requestsServed += 1;
881
+ _emitObs("staticServe.requests_served", 1, { route: urlPath, method: "HEAD" });
882
+ if (auditSuccess) {
883
+ emitAudit("staticServe.serve.success", Object.assign({
884
+ outcome: "success", resource: urlPath, method: "HEAD",
885
+ size: meta.size, contentType: headers["Content-Type"],
886
+ }, actorCtx));
887
+ }
888
+ return;
889
+ }
890
+
891
+ // Sanitized override path — content-safety gate replaced the
892
+ // bytes; emit them directly without re-reading the file. Bypasses
893
+ // range / idle-timer machinery (the override is already in memory
894
+ // and Range over a sanitized variant doesn't have a useful
895
+ // contract — sanitization changes byte offsets).
896
+ if (gateBytesOverride) {
897
+ var overrideHeaders = Object.assign({}, headers, {
898
+ "Content-Length": gateBytesOverride.length,
899
+ });
900
+ delete overrideHeaders["Content-Range"];
901
+ res.writeHead(HTTP.OK, overrideHeaders);
902
+ res.end(gateBytesOverride);
903
+ stats.requestsServed += 1;
904
+ stats.bytesServed += gateBytesOverride.length;
905
+ _emitObs("staticServe.requests_served", 1, { route: urlPath, method: "GET", sanitized: true });
906
+ _emitObs("staticServe.bytes_served", gateBytesOverride.length, { route: urlPath, sanitized: true });
907
+ if (auditSuccess) {
908
+ emitAudit("staticServe.serve.success", Object.assign({
909
+ outcome: "success", resource: urlPath, method: "GET",
910
+ size: gateBytesOverride.length, contentType: overrideHeaders["Content-Type"],
911
+ sanitized: true,
912
+ }, actorCtx));
913
+ }
914
+ return;
915
+ }
916
+
917
+ res.writeHead(status, headers);
918
+
919
+ // Acquire concurrency slot (released on stream end / error / abort).
920
+ await _incConcurrency(cache, actorKey);
921
+ var slotReleased = false;
922
+ function releaseSlot() {
923
+ if (slotReleased) return;
924
+ slotReleased = true;
925
+ _decConcurrency(cache, actorKey).catch(function () {});
249
926
  }
250
927
 
251
- res.writeHead(HTTP.OK, headers);
252
- var stream = fs.createReadStream(absPath);
253
- stream.on("error", function (e) {
254
- // Mid-stream read error best we can do is destroy the response;
255
- // headers are already on the wire.
256
- try { res.destroy(e); } catch (_) { /* response already torn down */ }
928
+ var streamOpts = range ? { start: range.start, end: range.end } : {};
929
+ var fileStream = fs.createReadStream(absPath, streamOpts);
930
+
931
+ // Idle timeoutclose the connection if the client stalls. Pattern is
932
+ // a deadline-style debounce (clearTimeout + setTimeout) tied directly
933
+ // to the file-stream "data" event lifecycle; the safeAsync.debounce
934
+ // helper isn't yet ship-implemented, and pulling it through here would
935
+ // pre-allocate a closure for every served byte. Tracked for extraction.
936
+ var idleTimer = null;
937
+ function resetIdleTimer() {
938
+ if (idleTimer) clearTimeout(idleTimer); // allow:handrolled-debounce — file-stream idle deadline
939
+ idleTimer = setTimeout(function () {
940
+ try { fileStream.destroy(_err("IDLE_TIMEOUT", "client idle for " + maxIdleMs + "ms")); }
941
+ catch (_) { /* stream already torn down */ }
942
+ try { res.destroy(); } catch (_) { /* response already torn down */ }
943
+ }, maxIdleMs);
944
+ }
945
+ resetIdleTimer();
946
+
947
+ // Cancellation propagation: when the client disconnects mid-stream.
948
+ function onClientClose() {
949
+ try { fileStream.destroy(); } catch (_) { /* allow:silent-catch — stream already torn down */ }
950
+ releaseSlot();
951
+ if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
952
+ }
953
+ req.on("aborted", onClientClose);
954
+ res.on("close", onClientClose);
955
+
956
+ var bytesSent = 0;
957
+ fileStream.on("data", function (chunk) {
958
+ bytesSent += chunk.length;
959
+ resetIdleTimer();
960
+ });
961
+
962
+ fileStream.on("error", function (e) {
963
+ stats.failures += 1;
964
+ _emitObs("staticServe.stream_error", 1, { route: urlPath });
965
+ if (auditFailures) {
966
+ emitAudit("staticServe.serve.failure", Object.assign({
967
+ outcome: "failure", reason: "stream_error", resource: urlPath,
968
+ error: e && e.message,
969
+ }, actorCtx));
970
+ }
971
+ try { res.destroy(e); } catch (_) { /* allow:silent-catch — response already torn down */ }
972
+ releaseSlot();
973
+ if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
974
+ });
975
+
976
+ fileStream.on("end", function () {
977
+ if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
978
+ stats.requestsServed += 1;
979
+ stats.bytesServed += bytesSent;
980
+ _emitObs("staticServe.requests_served", 1, { route: urlPath, method: "GET" });
981
+ _emitObs("staticServe.bytes_served", bytesSent, { route: urlPath });
982
+ _consumeBandwidth(cache, actorKey, perActorCap, globalCap, bandwidthWindowMs, bytesSent)
983
+ .catch(function () {});
984
+ if (auditSuccess) {
985
+ emitAudit("staticServe.serve.success", Object.assign({
986
+ outcome: "success", resource: urlPath, method: "GET",
987
+ size: bytesSent, contentType: headers["Content-Type"],
988
+ range: range ? { start: range.start, end: range.end } : null,
989
+ }, actorCtx));
990
+ }
991
+ releaseSlot();
257
992
  });
258
- stream.pipe(res);
993
+
994
+ fileStream.pipe(res);
995
+ }
996
+
997
+ // Operator-facing handle: callable as middleware (back-compat) AND
998
+ // exposes serve-instance methods.
999
+ async function fn(req, res, next) { return middleware(req, res, next); }
1000
+ fn.middleware = middleware;
1001
+ fn.revoke = async function (key) {
1002
+ if (revokeStore && typeof revokeStore.revoke === "function") {
1003
+ await revokeStore.revoke(key);
1004
+ return { ok: true, key: key };
1005
+ }
1006
+ localRevoked.add(key);
1007
+ return { ok: true, key: key };
1008
+ };
1009
+ fn.unrevoke = async function (key) {
1010
+ if (revokeStore && typeof revokeStore.unrevoke === "function") {
1011
+ await revokeStore.unrevoke(key);
1012
+ return { ok: true, key: key };
1013
+ }
1014
+ localRevoked.delete(key);
1015
+ return { ok: true, key: key };
1016
+ };
1017
+ fn.stats = function () {
1018
+ return Object.assign({}, stats);
1019
+ };
1020
+ fn.invalidateMeta = function (key) {
1021
+ _metaCache.delete(key);
1022
+ return { ok: true, key: key };
259
1023
  };
1024
+ return fn;
260
1025
  }
261
1026
 
262
- // ---- Test helper ----
263
- function _resetCacheForTest() { _cache.clear(); }
1027
+ function _resetCacheForTest() { _metaCache.clear(); }
264
1028
 
265
1029
  module.exports = {
266
- create: create,
267
- integrity: integrity,
268
- DEFAULT_MAX_AGE_SEC: DEFAULT_MAX_AGE_SEC,
269
- IMMUTABLE_MAX_AGE_SEC: IMMUTABLE_MAX_AGE_SEC,
1030
+ create: create,
1031
+ integrity: integrity,
1032
+ DEFAULT_MAX_AGE_SEC: DEFAULT_MAX_AGE_SEC,
1033
+ IMMUTABLE_MAX_AGE_SEC: IMMUTABLE_MAX_AGE_SEC,
270
1034
  DEFAULT_HASHED_PATTERN: DEFAULT_HASHED_PATTERN,
271
- _resetCacheForTest: _resetCacheForTest,
1035
+ _resetCacheForTest: _resetCacheForTest,
1036
+ _parseRangeHeader: _parseRangeHeader,
272
1037
  };