@blamejs/core 0.8.43 → 0.8.50

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 (222) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/README.md +10 -10
  3. package/index.js +52 -0
  4. package/lib/a2a.js +159 -34
  5. package/lib/acme.js +762 -0
  6. package/lib/ai-pref.js +166 -43
  7. package/lib/api-key.js +108 -47
  8. package/lib/api-snapshot.js +157 -40
  9. package/lib/app-shutdown.js +113 -77
  10. package/lib/archive.js +337 -40
  11. package/lib/arg-parser.js +697 -0
  12. package/lib/asyncapi.js +99 -55
  13. package/lib/atomic-file.js +465 -104
  14. package/lib/audit-chain.js +123 -34
  15. package/lib/audit-daily-review.js +389 -0
  16. package/lib/audit-sign.js +302 -56
  17. package/lib/audit-tools.js +412 -63
  18. package/lib/audit.js +656 -35
  19. package/lib/auth/jwt-external.js +17 -0
  20. package/lib/auth/oauth.js +7 -0
  21. package/lib/auth-bot-challenge.js +505 -0
  22. package/lib/auth-header.js +92 -25
  23. package/lib/backup/bundle.js +26 -0
  24. package/lib/backup/index.js +512 -89
  25. package/lib/backup/manifest.js +168 -7
  26. package/lib/break-glass.js +415 -39
  27. package/lib/budr.js +103 -30
  28. package/lib/bundler.js +86 -66
  29. package/lib/cache.js +192 -72
  30. package/lib/chain-writer.js +65 -40
  31. package/lib/circuit-breaker.js +56 -33
  32. package/lib/cli-helpers.js +106 -75
  33. package/lib/cli.js +6 -30
  34. package/lib/cloud-events.js +99 -32
  35. package/lib/cluster-storage.js +162 -37
  36. package/lib/cluster.js +340 -49
  37. package/lib/codepoint-class.js +66 -0
  38. package/lib/compliance.js +424 -24
  39. package/lib/config-drift.js +111 -46
  40. package/lib/config.js +94 -40
  41. package/lib/consent.js +165 -18
  42. package/lib/constants.js +1 -0
  43. package/lib/content-credentials.js +153 -48
  44. package/lib/cookies.js +154 -62
  45. package/lib/credential-hash.js +133 -61
  46. package/lib/crypto-field.js +702 -18
  47. package/lib/crypto-hpke.js +256 -0
  48. package/lib/crypto.js +744 -22
  49. package/lib/csv.js +178 -35
  50. package/lib/daemon.js +456 -0
  51. package/lib/dark-patterns.js +186 -55
  52. package/lib/db-query.js +79 -2
  53. package/lib/db.js +1431 -60
  54. package/lib/ddl-change-control.js +523 -0
  55. package/lib/deprecate.js +195 -40
  56. package/lib/dev.js +82 -39
  57. package/lib/dora.js +67 -48
  58. package/lib/dr-runbook.js +368 -0
  59. package/lib/dsr.js +142 -11
  60. package/lib/dual-control.js +91 -56
  61. package/lib/events.js +120 -41
  62. package/lib/external-db-migrate.js +192 -2
  63. package/lib/external-db.js +795 -50
  64. package/lib/fapi2.js +122 -1
  65. package/lib/fda-21cfr11.js +395 -0
  66. package/lib/fdx.js +132 -2
  67. package/lib/file-type.js +87 -0
  68. package/lib/file-upload.js +93 -0
  69. package/lib/flag.js +82 -20
  70. package/lib/forms.js +132 -29
  71. package/lib/framework-error.js +169 -0
  72. package/lib/framework-schema.js +163 -35
  73. package/lib/gate-contract.js +849 -175
  74. package/lib/graphql-federation.js +68 -7
  75. package/lib/guard-all.js +172 -55
  76. package/lib/guard-archive.js +286 -124
  77. package/lib/guard-auth.js +194 -21
  78. package/lib/guard-cidr.js +190 -28
  79. package/lib/guard-csv.js +397 -51
  80. package/lib/guard-domain.js +213 -91
  81. package/lib/guard-email.js +236 -29
  82. package/lib/guard-filename.js +307 -75
  83. package/lib/guard-graphql.js +263 -30
  84. package/lib/guard-html.js +310 -116
  85. package/lib/guard-image.js +243 -30
  86. package/lib/guard-json.js +260 -54
  87. package/lib/guard-jsonpath.js +235 -23
  88. package/lib/guard-jwt.js +284 -30
  89. package/lib/guard-markdown.js +204 -22
  90. package/lib/guard-mime.js +190 -26
  91. package/lib/guard-oauth.js +277 -28
  92. package/lib/guard-pdf.js +251 -27
  93. package/lib/guard-regex.js +226 -18
  94. package/lib/guard-shell.js +229 -26
  95. package/lib/guard-svg.js +177 -10
  96. package/lib/guard-template.js +232 -21
  97. package/lib/guard-time.js +195 -29
  98. package/lib/guard-uuid.js +189 -30
  99. package/lib/guard-xml.js +259 -36
  100. package/lib/guard-yaml.js +241 -44
  101. package/lib/honeytoken.js +63 -27
  102. package/lib/html-balance.js +83 -0
  103. package/lib/http-client.js +486 -59
  104. package/lib/http-message-signature.js +582 -0
  105. package/lib/i18n.js +102 -49
  106. package/lib/iab-mspa.js +112 -32
  107. package/lib/iab-tcf.js +107 -2
  108. package/lib/inbox.js +90 -52
  109. package/lib/keychain.js +865 -0
  110. package/lib/legal-hold.js +374 -0
  111. package/lib/local-db-thin.js +320 -0
  112. package/lib/log-stream.js +281 -51
  113. package/lib/log.js +184 -86
  114. package/lib/mail-bounce.js +107 -62
  115. package/lib/mail.js +295 -58
  116. package/lib/mcp.js +108 -27
  117. package/lib/metrics.js +98 -89
  118. package/lib/middleware/age-gate.js +36 -0
  119. package/lib/middleware/ai-act-disclosure.js +37 -0
  120. package/lib/middleware/api-encrypt.js +45 -0
  121. package/lib/middleware/assetlinks.js +40 -0
  122. package/lib/middleware/asyncapi-serve.js +35 -0
  123. package/lib/middleware/attach-user.js +40 -0
  124. package/lib/middleware/bearer-auth.js +40 -0
  125. package/lib/middleware/body-parser.js +230 -0
  126. package/lib/middleware/bot-disclose.js +34 -0
  127. package/lib/middleware/bot-guard.js +39 -0
  128. package/lib/middleware/compression.js +37 -0
  129. package/lib/middleware/cookies.js +32 -0
  130. package/lib/middleware/cors.js +40 -0
  131. package/lib/middleware/csp-nonce.js +40 -0
  132. package/lib/middleware/csp-report.js +34 -0
  133. package/lib/middleware/csrf-protect.js +43 -0
  134. package/lib/middleware/daily-byte-quota.js +53 -85
  135. package/lib/middleware/db-role-for.js +40 -0
  136. package/lib/middleware/dpop.js +40 -0
  137. package/lib/middleware/error-handler.js +37 -14
  138. package/lib/middleware/fetch-metadata.js +39 -0
  139. package/lib/middleware/flag-context.js +34 -0
  140. package/lib/middleware/gpc.js +33 -0
  141. package/lib/middleware/headers.js +35 -0
  142. package/lib/middleware/health.js +46 -0
  143. package/lib/middleware/host-allowlist.js +30 -0
  144. package/lib/middleware/network-allowlist.js +38 -0
  145. package/lib/middleware/openapi-serve.js +34 -0
  146. package/lib/middleware/rate-limit.js +160 -18
  147. package/lib/middleware/request-id.js +36 -18
  148. package/lib/middleware/request-log.js +37 -0
  149. package/lib/middleware/require-aal.js +29 -0
  150. package/lib/middleware/require-auth.js +32 -0
  151. package/lib/middleware/require-bound-key.js +41 -0
  152. package/lib/middleware/require-content-type.js +32 -0
  153. package/lib/middleware/require-methods.js +27 -0
  154. package/lib/middleware/require-mtls.js +33 -0
  155. package/lib/middleware/require-step-up.js +37 -0
  156. package/lib/middleware/security-headers.js +44 -0
  157. package/lib/middleware/security-txt.js +38 -0
  158. package/lib/middleware/span-http-server.js +37 -0
  159. package/lib/middleware/sse.js +36 -0
  160. package/lib/middleware/trace-log-correlation.js +33 -0
  161. package/lib/middleware/trace-propagate.js +32 -0
  162. package/lib/middleware/tus-upload.js +90 -0
  163. package/lib/middleware/web-app-manifest.js +53 -0
  164. package/lib/mtls-ca.js +100 -70
  165. package/lib/network-byte-quota.js +308 -0
  166. package/lib/network-heartbeat.js +135 -0
  167. package/lib/network-tls.js +534 -4
  168. package/lib/network.js +103 -0
  169. package/lib/notify.js +114 -43
  170. package/lib/ntp-check.js +192 -51
  171. package/lib/observability.js +145 -47
  172. package/lib/openapi.js +90 -44
  173. package/lib/outbox.js +99 -1
  174. package/lib/pagination.js +168 -86
  175. package/lib/parsers/index.js +16 -5
  176. package/lib/permissions.js +93 -40
  177. package/lib/pqc-agent.js +84 -8
  178. package/lib/pqc-software.js +94 -60
  179. package/lib/process-spawn.js +95 -21
  180. package/lib/pubsub.js +96 -66
  181. package/lib/queue.js +375 -54
  182. package/lib/redact.js +793 -21
  183. package/lib/render.js +139 -47
  184. package/lib/request-helpers.js +485 -121
  185. package/lib/restore-bundle.js +142 -39
  186. package/lib/restore-rollback.js +136 -45
  187. package/lib/retention.js +178 -50
  188. package/lib/retry.js +116 -33
  189. package/lib/router.js +475 -23
  190. package/lib/safe-async.js +543 -94
  191. package/lib/safe-buffer.js +337 -41
  192. package/lib/safe-json.js +467 -62
  193. package/lib/safe-jsonpath.js +285 -0
  194. package/lib/safe-schema.js +631 -87
  195. package/lib/safe-sql.js +221 -59
  196. package/lib/safe-url.js +278 -46
  197. package/lib/sandbox-worker.js +135 -0
  198. package/lib/sandbox.js +358 -0
  199. package/lib/scheduler.js +135 -70
  200. package/lib/self-update.js +647 -0
  201. package/lib/session-device-binding.js +431 -0
  202. package/lib/session.js +259 -49
  203. package/lib/slug.js +138 -26
  204. package/lib/ssrf-guard.js +316 -56
  205. package/lib/storage.js +433 -70
  206. package/lib/subject.js +405 -23
  207. package/lib/template.js +148 -8
  208. package/lib/tenant-quota.js +545 -0
  209. package/lib/testing.js +440 -53
  210. package/lib/time.js +291 -23
  211. package/lib/tls-exporter.js +239 -0
  212. package/lib/tracing.js +90 -74
  213. package/lib/uuid.js +97 -22
  214. package/lib/vault/index.js +284 -22
  215. package/lib/vault/seal-pem-file.js +66 -0
  216. package/lib/watcher.js +368 -0
  217. package/lib/webhook.js +196 -63
  218. package/lib/websocket.js +393 -68
  219. package/lib/wiki-concepts.js +338 -0
  220. package/lib/worker-pool.js +464 -0
  221. package/package.json +3 -3
  222. package/sbom.cyclonedx.json +7 -7
package/lib/sandbox.js ADDED
@@ -0,0 +1,358 @@
1
+ "use strict";
2
+ /**
3
+ * b.sandbox - isolation harness for operator-supplied transforms.
4
+ *
5
+ * Some primitives (b.template with sandbox: true, custom audit-export
6
+ * formatters, response-shape rewriters, ETL hooks) need to run JS
7
+ * source the operator wrote against per-request input. In-process eval
8
+ * gives that source the framework full module graph: filesystem,
9
+ * network, process, child_process, the entire b.* surface, vault keys,
10
+ * and audit-bypass via direct DB writes. b.sandbox runs the source
11
+ * inside a fresh worker_threads.Worker with strict resource limits and
12
+ * a hand-built scope that exposes ONLY the globals the operator
13
+ * allowlists at create() time.
14
+ *
15
+ * var result = await b.sandbox.run({
16
+ * source: "return { upper: input.name.toUpperCase() };",
17
+ * input: { name: "alice" },
18
+ * timeoutMs: 250,
19
+ * maxBytes: C.BYTES.mib(8),
20
+ * allowed: ["JSON", "Math", "Date"],
21
+ * });
22
+ * // result -> { result: { upper: "ALICE" }, runtimeMs: 12, peakBytes: 4194304 }
23
+ *
24
+ * Default-deny posture (lib/sandbox-worker.js docstring has the full list):
25
+ * - No require / process / Buffer / setTimeout / setImmediate /
26
+ * setInterval / queueMicrotask / global. The bootstrap deletes
27
+ * each off globalThis BEFORE compiling operator source.
28
+ * - No filesystem / network / child_process / spawn / dns -
29
+ * unreachable once require is gone.
30
+ * - No worker re-entry - worker_threads itself unreachable.
31
+ * - Timeout (default: 250ms, max: 10s) terminates the worker.
32
+ * - Heap caps (maxOldGenerationSizeMb / maxYoungGenerationSizeMb)
33
+ * derived from maxBytes; v8 kills the worker on overflow.
34
+ * - Result size cap = maxBytes / 4.
35
+ *
36
+ * Allowed-globals list:
37
+ * The allowed opt names which extra globals operator source may
38
+ * reference. The list is intersected against KNOWN_SAFE_BUILTINS at
39
+ * the host before being shipped to the worker - anything outside
40
+ * the allowlist refuses at the call site. JS-language primitives
41
+ * (Object, Array, String, Number, Boolean, Symbol, Promise, Error,
42
+ * TypeError, RangeError, RegExp) survive regardless because they
43
+ * cannot be removed without breaking literal expressions.
44
+ *
45
+ * Composability with b.template:
46
+ * b.template.create({ sandbox: true }) routes operator-supplied
47
+ * helper-function bodies through b.sandbox before exposing them as
48
+ * helpers in the template scope. The template engine itself remains
49
+ * eval-free - sandbox is the secondary defense for the rare cases
50
+ * where an operator NEEDS to ship a transform alongside a template
51
+ * (date formatters with locale-dependent fallbacks, etc.).
52
+ *
53
+ * Audit shape:
54
+ * - sandbox.run - outcome=success; metadata: { runtimeMs, peakBytes, sourceBytes }
55
+ * - sandbox.run.refused - outcome=failure; metadata: { reason, runtimeMs, peakBytes, sourceBytes }
56
+ *
57
+ * Failure modes (every one throws SandboxError):
58
+ * - sandbox/bad-opts - unknown opts key
59
+ * - sandbox/bad-source - source is not a non-empty string
60
+ * - sandbox/bad-allowed - allowed contains non-string or non-allowlisted name
61
+ * - sandbox/bad-timeout - timeoutMs is not a positive finite int (or > MAX_TIMEOUT_MS)
62
+ * - sandbox/bad-max-bytes - maxBytes is not a positive finite int (or out of range)
63
+ * - sandbox/bad-input - input is not JSON-serializable
64
+ * - sandbox/input-too-large - JSON.stringify(input).length > maxBytes
65
+ * - sandbox/timeout - worker exceeded timeoutMs
66
+ * - sandbox/oversized-result - worker output > maxBytes / 4
67
+ * - sandbox/parse-error - source did not parse inside the worker
68
+ * - sandbox/runtime-error - operator transform threw
69
+ * - sandbox/spawn-failed - worker thread failed to spawn
70
+ * - sandbox/worker-error - worker thread errored after spawn
71
+ * - sandbox/worker-nonzero-exit - worker died (heap-cap kill class)
72
+ * - sandbox/no-result - worker exited without posting (heap-cap class)
73
+ * - sandbox/no-worker-threads - runtime lacks node:worker_threads
74
+ *
75
+ * Operators feeding untrusted source MUST also pair this with their
76
+ * own posture (operator-uploaded transforms only after a code-review
77
+ * gate, etc.) - sandbox is one defense layer, not a license to accept
78
+ * arbitrary source from the public internet.
79
+ */
80
+
81
+ var path = require("path");
82
+ var lazyRequire = require("./lazy-require");
83
+ var validateOpts = require("./validate-opts");
84
+ var numericBounds = require("./numeric-bounds");
85
+ var constants = require("./constants");
86
+ var { SandboxError } = require("./framework-error");
87
+
88
+ var audit = lazyRequire(function () { return require("./audit"); });
89
+
90
+ // Built-in allowlist for the allowed opt. Filesystem / network /
91
+ // process / require are deliberately absent. JS-language primitives
92
+ // stay reachable inside the worker regardless of this list.
93
+ var KNOWN_SAFE_BUILTINS = Object.freeze({
94
+ JSON: true, Math: true, Date: true,
95
+ Map: true, Set: true, WeakMap: true, WeakSet: true,
96
+ RegExp: true, Error: true, TypeError: true, RangeError: true,
97
+ Number: true, String: true, Boolean: true,
98
+ Array: true, Object: true, ArrayBuffer: true,
99
+ Uint8Array: true, Uint16Array: true, Uint32Array: true,
100
+ Int8Array: true, Int16Array: true, Int32Array: true,
101
+ Float32Array: true, Float64Array: true,
102
+ DataView: true, Symbol: true, Promise: true,
103
+ });
104
+
105
+ // JS-language primitives that survive regardless of allowlist -
106
+ // stripping these would mean operator transforms cannot evaluate
107
+ // even simple literals. Mirrors lib/sandbox-worker.js.
108
+ var ALWAYS_AVAILABLE = Object.freeze([
109
+ "Object", "Array", "String", "Number", "Boolean", "Symbol",
110
+ "Promise", "Error", "TypeError", "RangeError", "RegExp",
111
+ ]);
112
+
113
+ var WORKER_PATH = path.resolve(__dirname, "sandbox-worker.js");
114
+
115
+ // Default caps. Sourced from C.* helpers so the unit lives at the call site.
116
+ var DEFAULT_TIMEOUT_MS = 250;
117
+ var MAX_TIMEOUT_MS = constants.TIME.seconds(10);
118
+ var DEFAULT_MAX_BYTES = constants.BYTES.mib(64);
119
+ var MAX_MAX_BYTES = constants.BYTES.gib(1);
120
+ var MIN_MAX_BYTES = constants.BYTES.mib(4);
121
+
122
+ function _validateAllowed(allowed) {
123
+ if (allowed === undefined || allowed === null) return [];
124
+ if (!Array.isArray(allowed)) {
125
+ throw new SandboxError("sandbox/bad-allowed",
126
+ "sandbox.run: opts.allowed must be an array of identifier strings");
127
+ }
128
+ var out = [];
129
+ for (var i = 0; i < allowed.length; i += 1) {
130
+ var name = allowed[i];
131
+ if (typeof name !== "string" || name.length === 0) {
132
+ throw new SandboxError("sandbox/bad-allowed",
133
+ "sandbox.run: opts.allowed[" + i + "] must be a non-empty identifier string");
134
+ }
135
+ if (!KNOWN_SAFE_BUILTINS[name]) {
136
+ throw new SandboxError("sandbox/bad-allowed",
137
+ "sandbox.run: opts.allowed[" + i + "] = " + JSON.stringify(name) +
138
+ " is not in the sandbox built-in allowlist " +
139
+ "(known-safe: " + Object.keys(KNOWN_SAFE_BUILTINS).join(", ") + ")");
140
+ }
141
+ if (out.indexOf(name) === -1) out.push(name);
142
+ }
143
+ return out;
144
+ }
145
+
146
+ function _emitAudit(action, outcome, metadata) {
147
+ try {
148
+ audit().safeEmit({
149
+ action: action,
150
+ outcome: outcome,
151
+ metadata: metadata,
152
+ });
153
+ } catch (_e) { /* drop-silent - audit best-effort */ }
154
+ }
155
+
156
+ function run(opts) {
157
+ opts = opts || {};
158
+ try {
159
+ validateOpts(opts, ["source", "input", "timeoutMs", "maxBytes", "allowed"], "sandbox.run");
160
+ } catch (e) { return Promise.reject(new SandboxError("sandbox/bad-opts", e.message)); }
161
+
162
+ try {
163
+ validateOpts.requireNonEmptyString(opts.source,
164
+ "sandbox.run: opts.source", SandboxError, "sandbox/bad-source");
165
+ } catch (e) { return Promise.reject(e); }
166
+ var sourceBytes = Buffer.byteLength(opts.source, "utf8");
167
+
168
+ var timeoutMs;
169
+ try {
170
+ timeoutMs = (opts.timeoutMs === undefined) ? DEFAULT_TIMEOUT_MS : opts.timeoutMs;
171
+ numericBounds.requirePositiveFiniteIntIfPresent(timeoutMs,
172
+ "sandbox.run: opts.timeoutMs", SandboxError, "sandbox/bad-timeout");
173
+ } catch (e) { return Promise.reject(e); }
174
+ if (timeoutMs > MAX_TIMEOUT_MS) {
175
+ return Promise.reject(new SandboxError("sandbox/bad-timeout",
176
+ "sandbox.run: opts.timeoutMs (" + timeoutMs + ") exceeds the framework cap of " + MAX_TIMEOUT_MS + " ms"));
177
+ }
178
+
179
+ var maxBytes;
180
+ try {
181
+ maxBytes = (opts.maxBytes === undefined) ? DEFAULT_MAX_BYTES : opts.maxBytes;
182
+ numericBounds.requirePositiveFiniteIntIfPresent(maxBytes,
183
+ "sandbox.run: opts.maxBytes", SandboxError, "sandbox/bad-max-bytes");
184
+ } catch (e) { return Promise.reject(e); }
185
+ if (maxBytes < MIN_MAX_BYTES) {
186
+ return Promise.reject(new SandboxError("sandbox/bad-max-bytes",
187
+ "sandbox.run: opts.maxBytes (" + maxBytes + ") below the framework floor of " + MIN_MAX_BYTES + " bytes"));
188
+ }
189
+ if (maxBytes > MAX_MAX_BYTES) {
190
+ return Promise.reject(new SandboxError("sandbox/bad-max-bytes",
191
+ "sandbox.run: opts.maxBytes (" + maxBytes + ") exceeds the framework cap of " + MAX_MAX_BYTES + " bytes"));
192
+ }
193
+
194
+ var allowedGlobals;
195
+ try { allowedGlobals = _validateAllowed(opts.allowed); }
196
+ catch (e) { return Promise.reject(e); }
197
+
198
+ var inputJson;
199
+ try { inputJson = (opts.input === undefined) ? null : JSON.stringify(opts.input); }
200
+ catch (eSer) {
201
+ return Promise.reject(new SandboxError("sandbox/bad-input",
202
+ "sandbox.run: opts.input is not JSON-serializable: " + (eSer && eSer.message)));
203
+ }
204
+ if (inputJson !== null && inputJson.length > maxBytes) {
205
+ return Promise.reject(new SandboxError("sandbox/input-too-large",
206
+ "sandbox.run: opts.input serialized to " + inputJson.length + " bytes (>" + maxBytes + ")"));
207
+ }
208
+
209
+ var workerThreads;
210
+ try { workerThreads = require("node:worker_threads"); }
211
+ catch (_e) {
212
+ return Promise.reject(new SandboxError("sandbox/no-worker-threads",
213
+ "sandbox.run: node:worker_threads is unavailable in this runtime"));
214
+ }
215
+
216
+ // resourceLimits in MiB. Derive from maxBytes - keep a small headroom
217
+ // floor so the worker can boot. Round each cap down to a MiB integer.
218
+ // Floors / caps are quanta of MiB chosen to fit a small embedded
219
+ // worker; passed straight to v8's resourceLimits.
220
+ var oneMib = constants.BYTES.mib(1);
221
+ // The MiB-unit caps below are integers passed directly to v8's
222
+ // resourceLimits (already typed in MiB by the v8 API), not byte
223
+ // counts - the constants helpers don't apply.
224
+ var minHeapFloorMib = 64; // allow:raw-byte-literal — MiB unit count, not bytes
225
+ var youngGenCapMib = 32; // allow:raw-byte-literal — MiB unit count, not bytes
226
+ var youngGenFloorMib = 8; // allow:raw-byte-literal — MiB unit count, not bytes
227
+ var codeRangeCapMib = 16; // allow:raw-byte-literal — MiB unit count, not bytes
228
+ var codeRangeFloorMib = 8; // allow:raw-byte-literal — MiB unit count, not bytes
229
+ var stackMib = 4; // MiB unit count, not bytes
230
+ var heapMib = Math.max(minHeapFloorMib, Math.floor(maxBytes / oneMib));
231
+ var resourceLimits = {
232
+ maxOldGenerationSizeMb: heapMib,
233
+ maxYoungGenerationSizeMb: Math.max(youngGenFloorMib, Math.min(heapMib, youngGenCapMib)),
234
+ codeRangeSizeMb: Math.max(codeRangeFloorMib, Math.min(heapMib, codeRangeCapMib)),
235
+ stackSizeMb: stackMib,
236
+ };
237
+
238
+ // Reserve 1/4 of maxBytes as the per-result hard cap. The worker
239
+ // refuses any result whose stringified form exceeds this.
240
+ var maxResultBytes = Math.floor(maxBytes / 4);
241
+
242
+ return new Promise(function (resolve, reject) {
243
+ var startedAt = Date.now();
244
+ var settled = false;
245
+ var worker;
246
+ try {
247
+ worker = new workerThreads.Worker(WORKER_PATH, {
248
+ workerData: {
249
+ source: opts.source,
250
+ input: opts.input,
251
+ allowedGlobals: allowedGlobals,
252
+ maxResultBytes: maxResultBytes,
253
+ },
254
+ resourceLimits: resourceLimits,
255
+ stdout: true,
256
+ stderr: true,
257
+ });
258
+ } catch (eSpawn) {
259
+ var spawnRuntimeMs = Date.now() - startedAt;
260
+ _emitAudit("sandbox.run.refused", "failure", {
261
+ reason: "sandbox/spawn-failed", runtimeMs: spawnRuntimeMs, peakBytes: 0, sourceBytes: sourceBytes,
262
+ });
263
+ reject(new SandboxError("sandbox/spawn-failed",
264
+ "sandbox.run: failed to spawn worker: " + (eSpawn && eSpawn.message)));
265
+ return;
266
+ }
267
+
268
+ var timer = setTimeout(function () {
269
+ if (settled) return;
270
+ settled = true;
271
+ try { worker.terminate(); } catch (_e) { /* terminate best-effort */ }
272
+ var elapsed = Date.now() - startedAt;
273
+ _emitAudit("sandbox.run.refused", "failure", {
274
+ reason: "sandbox/timeout", runtimeMs: elapsed, peakBytes: 0, sourceBytes: sourceBytes,
275
+ });
276
+ reject(new SandboxError("sandbox/timeout",
277
+ "sandbox.run: worker exceeded timeoutMs=" + timeoutMs + " (elapsed " + elapsed + "ms)"));
278
+ }, timeoutMs);
279
+
280
+ worker.on("message", function (msg) {
281
+ if (settled) return;
282
+ settled = true;
283
+ clearTimeout(timer);
284
+ try { worker.terminate(); } catch (_e) { /* terminate best-effort */ }
285
+ if (!msg || typeof msg !== "object") {
286
+ _emitAudit("sandbox.run.refused", "failure", {
287
+ reason: "sandbox/bad-worker-message", runtimeMs: Date.now() - startedAt, peakBytes: 0, sourceBytes: sourceBytes,
288
+ });
289
+ return reject(new SandboxError("sandbox/bad-worker-message",
290
+ "sandbox.run: worker returned a non-object message"));
291
+ }
292
+ var runtimeMs = (typeof msg.runtimeMs === "number") ? msg.runtimeMs : (Date.now() - startedAt);
293
+ var peakBytes = (typeof msg.peakBytes === "number") ? msg.peakBytes : 0;
294
+ if (msg.ok) {
295
+ var parsed;
296
+ try { parsed = (msg.resultJson === undefined) ? undefined : JSON.parse(msg.resultJson); } // allow:bare-json-parse — resultJson is produced by lib/sandbox-worker.js via JSON.stringify and bounded by maxResultBytes; never directly from operator/network input
297
+ catch (eParse) {
298
+ _emitAudit("sandbox.run.refused", "failure", {
299
+ reason: "sandbox/bad-result-json", runtimeMs: runtimeMs, peakBytes: peakBytes, sourceBytes: sourceBytes,
300
+ });
301
+ return reject(new SandboxError("sandbox/bad-result-json",
302
+ "sandbox.run: worker result was not parseable JSON: " + (eParse && eParse.message)));
303
+ }
304
+ _emitAudit("sandbox.run", "success", {
305
+ runtimeMs: runtimeMs, peakBytes: peakBytes, sourceBytes: sourceBytes,
306
+ });
307
+ return resolve({ result: parsed, runtimeMs: runtimeMs, peakBytes: peakBytes });
308
+ }
309
+ _emitAudit("sandbox.run.refused", "failure", {
310
+ reason: msg.code || "sandbox/runtime-error", runtimeMs: runtimeMs, peakBytes: peakBytes, sourceBytes: sourceBytes,
311
+ });
312
+ return reject(new SandboxError(msg.code || "sandbox/runtime-error",
313
+ msg.message || "sandbox.run: worker reported a refusal"));
314
+ });
315
+
316
+ worker.on("error", function (err) {
317
+ if (settled) return;
318
+ settled = true;
319
+ clearTimeout(timer);
320
+ var elapsed = Date.now() - startedAt;
321
+ _emitAudit("sandbox.run.refused", "failure", {
322
+ reason: "sandbox/worker-error", runtimeMs: elapsed, peakBytes: 0, sourceBytes: sourceBytes,
323
+ });
324
+ reject(new SandboxError("sandbox/worker-error",
325
+ "sandbox.run: worker errored: " + (err && err.message ? err.message : String(err))));
326
+ });
327
+
328
+ worker.on("exit", function (code) {
329
+ if (settled) return;
330
+ settled = true;
331
+ clearTimeout(timer);
332
+ var elapsed = Date.now() - startedAt;
333
+ // Code 0 with no message means the worker exited without posting -
334
+ // usually a heap-cap kill. Surface as oversized.
335
+ var reason = (code === 0) ? "sandbox/no-result" : "sandbox/worker-nonzero-exit";
336
+ var message = (code === 0)
337
+ ? "sandbox.run: worker exited without posting a result (heap cap or premature return)"
338
+ : "sandbox.run: worker exited with code " + code + " (likely resource-limit kill)";
339
+ _emitAudit("sandbox.run.refused", "failure", {
340
+ reason: reason, runtimeMs: elapsed, peakBytes: 0, sourceBytes: sourceBytes,
341
+ });
342
+ reject(new SandboxError(reason, message));
343
+ });
344
+ });
345
+ }
346
+
347
+ module.exports = {
348
+ run: run,
349
+ KNOWN_SAFE_BUILTINS: KNOWN_SAFE_BUILTINS,
350
+ ALWAYS_AVAILABLE: ALWAYS_AVAILABLE,
351
+ DEFAULT_TIMEOUT_MS: DEFAULT_TIMEOUT_MS,
352
+ MAX_TIMEOUT_MS: MAX_TIMEOUT_MS,
353
+ DEFAULT_MAX_BYTES: DEFAULT_MAX_BYTES,
354
+ MAX_MAX_BYTES: MAX_MAX_BYTES,
355
+ MIN_MAX_BYTES: MIN_MAX_BYTES,
356
+ WORKER_PATH: WORKER_PATH,
357
+ SandboxError: SandboxError,
358
+ };
package/lib/scheduler.js CHANGED
@@ -1,76 +1,42 @@
1
1
  "use strict";
2
2
  /**
3
- * scheduler — cron + interval scheduler over lib/jobs (or direct fn).
3
+ * @module b.scheduler
4
+ * @featured true
5
+ * @nav Production
6
+ * @title Scheduler
4
7
  *
5
- * The framework's primitive for "run X at Y" — backed by jobs/queue
6
- * for retries, audit, and cluster-aware dispatch, with a direct-fn
7
- * escape hatch for the simple cases.
8
+ * @intro
9
+ * Cron-style task scheduler with cluster leader gating, deduplicated
10
+ * ticks, drift correction, and an audit event on every tick.
8
11
  *
9
- * var sched = b.scheduler.create({
10
- * jobs: jobsInstance, // optional; needed for { job: "name" }
11
- * cluster: b.cluster, // optional; gates fires to leader only
12
- * audit: true, // default true
13
- * });
14
- *
15
- * sched.schedule({
16
- * name: "nightly-cleanup",
17
- * cron: "0 2 * * *", // POSIX 5-field cron
18
- * timezone: "America/New_York", // IANA name; default = server-local
19
- * job: "cleanup", // dispatched via jobs.enqueue
20
- * payload: { scope: "all" },
21
- * });
22
- *
23
- * sched.schedule({
24
- * name: "stats-aggregation",
25
- * every: 300000, // ms between runs
26
- * baseline: "00:00", // HH:MM anchor (optional)
27
- * timezone: "America/New_York",
28
- * job: "aggregate-stats",
29
- * });
30
- *
31
- * sched.schedule({
32
- * name: "heartbeat",
33
- * every: 60000,
34
- * run: async function () { … }, // direct function (no jobs needed)
35
- * });
12
+ * Two registration shapes share the same engine: 5-field POSIX cron
13
+ * (`"0 2 * * *"`) for wall-clock schedules and `every: ms` (with an
14
+ * optional `baseline: "HH:MM"` anchor) for interval schedules.
15
+ * Timezones are IANA names; without one the schedule follows the
16
+ * server's local clock. Cron shorthands `@hourly`, `@daily`,
17
+ * `@midnight`, `@weekly`, `@monthly`, `@yearly` and `@annually` are
18
+ * accepted.
36
19
  *
37
- * await sched.start(); // arms timers
38
- * await sched.stop(); // clears timers, drops pending fires
20
+ * When opts.cluster is wired, fires are gated to the current leader.
21
+ * Every fire INSERTs a row into _blamejs_scheduler_ticks keyed on
22
+ * (name, scheduledAtUnix); the PRIMARY KEY race deduplicates across a
23
+ * split-brain window — losers increment task.tickClaimLost and skip.
24
+ * Tick-claim rows older than opts.tickRetentionMs (default 7 days)
25
+ * are pruned automatically by the leader, throttled to at most one
26
+ * sweep per opts.pruneIntervalMs (default 60s). Operators can force a
27
+ * sweep with sched.pruneTickClaims(olderThanMs?).
39
28
  *
40
- * sched.list(); // [{ name, when, lastRun, nextRun, running }]
29
+ * Drift correction: nextRun is computed forward from now (not from
30
+ * the nominal scheduled time) so a long-running fire never queues a
31
+ * backlog of catch-up ticks. A watchdog clears the `running` flag if
32
+ * a fire's promise hasn't settled after opts.maxJobMs (default 10
33
+ * minutes) so a hung handler can't permanently lock out future fires.
34
+ * Every state transition emits an audit event under
35
+ * `system.scheduler.*` so operators see every fire, miss, watchdog
36
+ * reset, and tick-claim race in their audit log.
41
37
  *
42
- * Cron grammar (5 fields, space-separated):
43
- *
44
- * minute (0–59) hour (0–23) dom (1–31) month (1–12) dow (0–7; 0/7=Sun)
45
- *
46
- * Each field accepts: * N N,M,… A-B *\/N A-B/N
47
- *
48
- * Shorthands: @hourly @daily @midnight @weekly @monthly @yearly @annually
49
- *
50
- * Cluster gating: when opts.cluster is wired and the local node is not
51
- * the leader, schedule fires no-op. The leader still computes nextRun
52
- * locally so a leader transition picks up cleanly.
53
- *
54
- * Exactly-once-globally: when opts.cluster is wired, every fire first
55
- * INSERTs a row into _blamejs_scheduler_ticks keyed on (taskName,
56
- * scheduledAtUnix). The PRIMARY KEY race ensures that even if two
57
- * nodes briefly believe they are the leader (split-brain on lease
58
- * boundary), only the row-winner runs the task. The loser increments
59
- * task.tickClaimLost (visible via list()) and skips silently. Task
60
- * handlers should still be idempotent — operators may add jobs.enqueue
61
- * dedup keys for defense-in-depth.
62
- *
63
- * Tick-claim retention: rows older than opts.tickRetentionMs (default
64
- * 7 days) are pruned automatically — at most once per opts.pruneInterval
65
- * Ms (default 60s) — by the leader on its next successful fire. Operators
66
- * can also call sched.pruneTickClaims(olderThanMs?) on demand to force
67
- * a sweep (e.g. from a maintenance script) and observe the count via
68
- * the system.scheduler.tick.pruned audit event.
69
- *
70
- * Watchdog: if a fire's promise hasn't settled after MAX_JOB_MS
71
- * (10min default; opts.maxJobMs to override), the running flag is
72
- * force-cleared and a warning emitted, so a hung job doesn't lock out
73
- * future fires.
38
+ * @card
39
+ * Cron-style task scheduler with cluster leader gating, deduplicated ticks, drift correction, and an audit event on every tick.
74
40
  */
75
41
 
76
42
  var lazyRequire = require("./lazy-require");
@@ -168,6 +134,29 @@ function _parseCronField(text, range) {
168
134
  return set;
169
135
  }
170
136
 
137
+ /**
138
+ * @primitive b.scheduler.parseCron
139
+ * @signature b.scheduler.parseCron(expr)
140
+ * @since 0.5.0
141
+ * @related b.scheduler.create, b.scheduler.nextCronFire
142
+ *
143
+ * Parse a 5-field POSIX cron expression (or one of the `@hourly`,
144
+ * `@daily`, `@midnight`, `@weekly`, `@monthly`, `@yearly`, `@annually`
145
+ * shorthands) into a struct of populated minute / hour / dom / month /
146
+ * dow sets plus the normalized expression text. Throws SchedulerError
147
+ * (`scheduler/invalid-cron`) on malformed input — empty fields, bad
148
+ * step / range syntax, or values outside each field's bounds. The
149
+ * `dow` field accepts both 0 and 7 for Sunday and normalizes to 0.
150
+ *
151
+ * @example
152
+ * var cron = b.scheduler.parseCron("0 2 * * *");
153
+ * cron.expr; // → "0 2 * * *"
154
+ * cron.minute.has(0); // → true
155
+ * cron.hour.has(2); // → true
156
+ *
157
+ * var weekly = b.scheduler.parseCron("@weekly");
158
+ * weekly.expr; // → "0 0 * * 0"
159
+ */
171
160
  function parseCron(expr) {
172
161
  if (typeof expr !== "string" || expr.length === 0) {
173
162
  throw new SchedulerError("scheduler/invalid-cron",
@@ -268,9 +257,27 @@ function _matchesCron(cron, parts) {
268
257
  return true; // both fully wild
269
258
  }
270
259
 
271
- // nextCronFire — earliest UTC ms ≥ `after` whose wall-clock in `tz`
272
- // matches the cron sets. Walks minute by minute; bounded at ~530K
273
- // iterations (1 year of minutes) before giving up with a clear error.
260
+ /**
261
+ * @primitive b.scheduler.nextCronFire
262
+ * @signature b.scheduler.nextCronFire(cron, after, timeZone)
263
+ * @since 0.5.0
264
+ * @related b.scheduler.parseCron, b.scheduler.nextBaselineFire
265
+ *
266
+ * Earliest UTC millisecond strictly after `after` whose wall-clock in
267
+ * `timeZone` matches the parsed cron sets. Walks minute-by-minute; the
268
+ * search is bounded at one year plus a one-hour DST cushion before
269
+ * throwing SchedulerError (`scheduler/cron-no-fire`) so an impossible
270
+ * date constraint surfaces loudly instead of looping forever. Pass
271
+ * `null` for `timeZone` to follow the server's local clock.
272
+ *
273
+ * @example
274
+ * var cron = b.scheduler.parseCron("0 2 * * *");
275
+ * var when = b.scheduler.nextCronFire(cron, new Date("2026-05-09T00:00:00Z"), "UTC");
276
+ * new Date(when).toISOString();
277
+ * // → "2026-05-09T02:00:00.000Z"
278
+ */
279
+ // Walks minute by minute; bounded at ~530K iterations (1 year of
280
+ // minutes) before giving up with a clear error.
274
281
  function nextCronFire(cron, after, timeZone) {
275
282
  var MINUTE_MS = C.TIME.minutes(1);
276
283
  // Round up to the next whole minute boundary
@@ -288,7 +295,28 @@ function nextCronFire(cron, after, timeZone) {
288
295
  "(impossible date constraint?)", true);
289
296
  }
290
297
 
291
- // nextBaselineFire — next UTC ms whose wall-clock in `tz` matches HH:MM.
298
+ /**
299
+ * @primitive b.scheduler.nextBaselineFire
300
+ * @signature b.scheduler.nextBaselineFire(timeOfDay, timeZone, after)
301
+ * @since 0.5.0
302
+ * @related b.scheduler.nextCronFire, b.scheduler.create
303
+ *
304
+ * Earliest UTC millisecond strictly after `after` whose wall-clock in
305
+ * `timeZone` matches the supplied `HH:MM` time-of-day. Used internally
306
+ * to anchor `every`-shaped tasks to a daily baseline; exposed so
307
+ * operators can compute the same instant for fixtures or external
308
+ * coordination. Throws SchedulerError on malformed input
309
+ * (`scheduler/invalid-baseline`) or on a no-fire-within-24h timezone
310
+ * bug (`scheduler/baseline-no-fire`). Pass `null` for `timeZone` to
311
+ * follow the server's local clock.
312
+ *
313
+ * @example
314
+ * var when = b.scheduler.nextBaselineFire(
315
+ * "02:30", "UTC", new Date("2026-05-09T01:00:00Z")
316
+ * );
317
+ * new Date(when).toISOString();
318
+ * // → "2026-05-09T02:30:00.000Z"
319
+ */
292
320
  function nextBaselineFire(timeOfDay, timeZone, after) {
293
321
  var match = String(timeOfDay).match(/^(\d{1,2}):(\d{2})$/);
294
322
  if (!match) {
@@ -316,6 +344,43 @@ function nextBaselineFire(timeOfDay, timeZone, after) {
316
344
 
317
345
  // ---- Engine ----
318
346
 
347
+ /**
348
+ * @primitive b.scheduler.create
349
+ * @signature b.scheduler.create(opts)
350
+ * @since 0.5.0
351
+ * @related b.scheduler.parseCron, b.cluster.init, b.jobs.create
352
+ *
353
+ * Build a scheduler instance. Returns a facade exposing `schedule`,
354
+ * `register`, `start`, `stop`, `list`, `getStatus`, and
355
+ * `pruneTickClaims`. Tasks are registered before `start()`; `start()`
356
+ * arms timers, `stop()` clears them and drops pending fires. When
357
+ * `opts.cluster` is supplied, fires are gated to the leader and a
358
+ * tick-claim row in `_blamejs_scheduler_ticks` deduplicates split-brain
359
+ * windows. When `opts.jobs` is supplied, tasks declared with
360
+ * `{ job: "name" }` dispatch via the jobs queue; tasks declared with
361
+ * `{ run: fn }` execute the function directly.
362
+ *
363
+ * @opts
364
+ * jobs: object, // optional jobs instance for { job: "name" } tasks
365
+ * cluster: object, // optional cluster instance — gates fires to leader
366
+ * audit: boolean, // emit system.scheduler.* audit events (default true)
367
+ * maxJobMs: number, // watchdog reset threshold (default 10 minutes)
368
+ * tickRetentionMs: number, // tick-claim row retention (default 7 days)
369
+ * pruneIntervalMs: number, // throttle for opportunistic prune (default 60s)
370
+ *
371
+ * @example
372
+ * var sched = b.scheduler.create({ audit: true });
373
+ * sched.schedule({
374
+ * name: "nightly-cleanup",
375
+ * cron: "0 2 * * *",
376
+ * timezone: "UTC",
377
+ * run: async function () { return "ok"; },
378
+ * });
379
+ * await sched.start();
380
+ * var snapshot = sched.list();
381
+ * snapshot[0].name; // → "nightly-cleanup"
382
+ * await sched.stop();
383
+ */
319
384
  function create(opts) {
320
385
  opts = opts || {};
321
386
  validateOpts(opts, [