@blamejs/core 0.8.43 → 0.8.49

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 +92 -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/watcher.js ADDED
@@ -0,0 +1,368 @@
1
+ "use strict";
2
+ /**
3
+ * b.watcher — recursive filesystem-watch primitive with cross-platform
4
+ * event normalization.
5
+ *
6
+ * Wraps `fs.watch(root, { recursive: true })` and turns the per-platform
7
+ * event soup (Linux inotify "rename" + "change", macOS FSEvents
8
+ * coalesced "rename", Windows ReadDirectoryChangesW pure "rename" /
9
+ * "change") into a single shape:
10
+ *
11
+ * onChange({ type, relativePath, fullPath, size, mtime })
12
+ * onDelete({ type, relativePath, fullPath })
13
+ * onError(err)
14
+ *
15
+ * `type` is one of "file" or "dir". The watcher is build-tool-shaped:
16
+ * use it to drive incremental rebuilds, hot-reload-on-change,
17
+ * config-file watching, or content-store cache busts. It is NOT a
18
+ * security primitive — fs.watch is best-effort across kernels and the
19
+ * caller must not rely on it for audit-grade change detection.
20
+ *
21
+ * Cross-platform notes baked in:
22
+ * - macOS FSEvents fires "rename" for create / delete / move; the
23
+ * watcher disambiguates by stat-ing the path post-event.
24
+ * - Linux inotify can emit "change" before the file is fully written;
25
+ * debounce coalesces a burst of writes into one onChange.
26
+ * - Windows ReadDirectoryChangesW emits both "rename" and "change"
27
+ * for a single create — debounce + stat dedup the duplicate.
28
+ * - Symlinks are skipped on the post-event stat (lstat) to avoid
29
+ * following an attacker-controlled link out of `root`.
30
+ * - `recursive: true` on Linux (kernel 6.0+, since Node 20+ uses
31
+ * inotify_add_watch with IN_MASK_CREATE for nested dirs) — older
32
+ * kernels degrade to a single-directory watch and emit a warning
33
+ * event through `onError` once at startup.
34
+ *
35
+ * Audit emits:
36
+ * watcher.started — { root }
37
+ * watcher.stopped — { root, eventCount }
38
+ * watcher.error — { root, code } (drop-silent fallback)
39
+ *
40
+ * Public surface:
41
+ * watcher.create({ root, ignore?, debounceMs?, onChange?, onDelete?,
42
+ * onError?, audit? })
43
+ * → { stop, root, _flushForTest }
44
+ *
45
+ * watcher.WatcherError
46
+ */
47
+
48
+ var fs = require("fs");
49
+ var path = require("path");
50
+ var lazyRequire = require("./lazy-require");
51
+ var validateOpts = require("./validate-opts");
52
+ var { WatcherError } = require("./framework-error");
53
+
54
+ var audit = lazyRequire(function () { return require("./audit"); });
55
+ var observability = lazyRequire(function () { return require("./observability"); });
56
+
57
+ var DEFAULT_DEBOUNCE_MS = 100;
58
+ // Per-watcher event count cap before we self-terminate as a safety net
59
+ // against runaway directories that emit millions of events per minute.
60
+ // Operators with legitimate high-churn directories raise this via opts.
61
+ var DEFAULT_MAX_PENDING = 10000; // allow:raw-byte-literal — pending-event queue cap
62
+
63
+ // ---- glob-style matcher ----
64
+ //
65
+ // Supports three shapes per entry:
66
+ // "*.ext" — basename glob (extension or wildcard pattern)
67
+ // "dir/**" — prefix match against the relative path
68
+ // "exact/path" — exact relative-path match (case-sensitive)
69
+ //
70
+ // Anything else throws at create-time so a typo surfaces at boot.
71
+ //
72
+ // Implementation note: every pattern is parsed at create-time into a
73
+ // fixed shape (literal segments + `*` placeholders) and matched with a
74
+ // linear two-pointer walk over the basename. No dynamic RegExp — the
75
+ // linear walker has no catastrophic backtracking surface even when the
76
+ // pattern contains many `*`. Pattern length + `*` count are also
77
+ // bounded at parse-time as defense in depth.
78
+ var MAX_IGNORE_PATTERN_LEN = 256;
79
+ var MAX_IGNORE_STAR_COUNT = 16;
80
+
81
+ function _parseGlobBasename(pattern) {
82
+ // Split on `*` — the alternating literal pieces are matched in order
83
+ // against the basename with `*` consuming any non-separator run.
84
+ var parts = pattern.split("*");
85
+ for (var i = 0; i < parts.length; i += 1) {
86
+ if (parts[i].indexOf("/") !== -1 || parts[i].indexOf("\\") !== -1) {
87
+ throw new WatcherError("watcher/bad-ignore",
88
+ "watcher.create: glob pattern '" + pattern +
89
+ "' contains a path separator outside the dir/** prefix form");
90
+ }
91
+ }
92
+ return parts;
93
+ }
94
+
95
+ function _matchGlobBasename(parts, base) {
96
+ // Two-pointer walk. parts[0] must prefix the basename; parts[last]
97
+ // must suffix; intermediate parts must appear in order. `*` matches
98
+ // any run of non-separator chars (basename has no separators).
99
+ if (parts.length === 0) return false;
100
+ if (parts[0].length > 0) {
101
+ if (base.indexOf(parts[0]) !== 0) return false;
102
+ }
103
+ var pos = parts[0].length;
104
+ if (parts.length === 1) return base.length === pos;
105
+ // Match the trailing literal first to anchor.
106
+ var tail = parts[parts.length - 1];
107
+ if (tail.length > 0) {
108
+ if (base.length - tail.length < pos) return false;
109
+ if (base.lastIndexOf(tail) !== base.length - tail.length) return false;
110
+ }
111
+ var endLimit = base.length - tail.length;
112
+ for (var k = 1; k < parts.length - 1; k += 1) {
113
+ var seg = parts[k];
114
+ if (seg.length === 0) continue;
115
+ var found = base.indexOf(seg, pos);
116
+ if (found === -1 || found + seg.length > endLimit) return false;
117
+ pos = found + seg.length;
118
+ }
119
+ return true;
120
+ }
121
+
122
+ function _compileIgnore(patterns) {
123
+ if (!Array.isArray(patterns) || patterns.length === 0) {
124
+ return function () { return false; };
125
+ }
126
+ var compiled = [];
127
+ for (var i = 0; i < patterns.length; i += 1) {
128
+ var p = patterns[i];
129
+ if (typeof p !== "string" || p.length === 0) {
130
+ throw new WatcherError("watcher/bad-ignore",
131
+ "watcher.create: ignore[" + i + "] must be a non-empty string");
132
+ }
133
+ if (p.length > MAX_IGNORE_PATTERN_LEN) {
134
+ throw new WatcherError("watcher/bad-ignore",
135
+ "watcher.create: ignore[" + i + "] exceeds " + MAX_IGNORE_PATTERN_LEN + "-byte cap");
136
+ }
137
+ var starCount = 0;
138
+ for (var s = 0; s < p.length; s += 1) if (p.charCodeAt(s) === 42 /* * */) starCount += 1;
139
+ if (starCount > MAX_IGNORE_STAR_COUNT) {
140
+ throw new WatcherError("watcher/bad-ignore",
141
+ "watcher.create: ignore[" + i + "] exceeds " + MAX_IGNORE_STAR_COUNT + "-wildcard cap");
142
+ }
143
+ if (p.indexOf("**") !== -1) {
144
+ // dir/** prefix-match — strip the trailing **; reject `**` mid-pattern.
145
+ if (!/^[^*]*\/?\*\*$/.test(p)) {
146
+ throw new WatcherError("watcher/bad-ignore",
147
+ "watcher.create: ignore[" + i + "] '**' is only supported as a trailing dir/** prefix form");
148
+ }
149
+ var prefix = p.replace(/\/?\*\*$/, "");
150
+ compiled.push({ kind: "prefix", value: prefix });
151
+ } else if (starCount > 0) {
152
+ compiled.push({ kind: "glob", value: _parseGlobBasename(p) });
153
+ } else {
154
+ compiled.push({ kind: "exact", value: p });
155
+ }
156
+ }
157
+ return function (relPath) {
158
+ var base = path.basename(relPath);
159
+ var normalized = relPath.split(path.sep).join("/");
160
+ for (var j = 0; j < compiled.length; j += 1) {
161
+ var c = compiled[j];
162
+ if (c.kind === "exact" && (c.value === relPath || c.value === normalized)) return true;
163
+ if (c.kind === "prefix" && (normalized === c.value || normalized.indexOf(c.value + "/") === 0)) return true;
164
+ if (c.kind === "glob" && _matchGlobBasename(c.value, base)) return true;
165
+ }
166
+ return false;
167
+ };
168
+ }
169
+
170
+ function _validateOpts(opts) {
171
+ validateOpts.requireObject(opts, "watcher.create", WatcherError, "watcher/bad-opts");
172
+ validateOpts.requireNonEmptyString(opts.root, "root", WatcherError, "watcher/bad-root");
173
+ validateOpts.optionalFiniteNonNegative(opts.debounceMs, "debounceMs", WatcherError, "watcher/bad-debounce-ms");
174
+ if (opts.maxPending !== undefined &&
175
+ (typeof opts.maxPending !== "number" || !isFinite(opts.maxPending) || opts.maxPending < 1)) {
176
+ throw new WatcherError("watcher/bad-max-pending",
177
+ "watcher.create: maxPending must be a positive finite number");
178
+ }
179
+ validateOpts.optionalFunction(opts.onChange, "onChange", WatcherError, "watcher/bad-hook");
180
+ validateOpts.optionalFunction(opts.onDelete, "onDelete", WatcherError, "watcher/bad-hook");
181
+ validateOpts.optionalFunction(opts.onError, "onError", WatcherError, "watcher/bad-hook");
182
+ if (opts.ignore !== undefined && !Array.isArray(opts.ignore)) {
183
+ throw new WatcherError("watcher/bad-ignore",
184
+ "watcher.create: ignore must be an array of glob patterns");
185
+ }
186
+ }
187
+
188
+ function create(opts) {
189
+ _validateOpts(opts);
190
+
191
+ var root = path.resolve(opts.root);
192
+ var debounceMs = (opts.debounceMs !== undefined) ? opts.debounceMs : DEFAULT_DEBOUNCE_MS;
193
+ var maxPending = (opts.maxPending !== undefined) ? opts.maxPending : DEFAULT_MAX_PENDING;
194
+ var onChange = opts.onChange || function () {};
195
+ var onDelete = opts.onDelete || function () {};
196
+ var onError = opts.onError || function () {};
197
+ var isIgnored = _compileIgnore(opts.ignore);
198
+ var auditOn = opts.audit !== false;
199
+
200
+ // Pre-flight: root must exist and be a directory.
201
+ var rootStat;
202
+ try { rootStat = fs.statSync(root); }
203
+ catch (e) {
204
+ throw new WatcherError("watcher/root-missing",
205
+ "watcher.create: root '" + root + "' is not accessible: " + ((e && e.message) || String(e)));
206
+ }
207
+ if (!rootStat.isDirectory()) {
208
+ throw new WatcherError("watcher/root-not-dir",
209
+ "watcher.create: root '" + root + "' is not a directory");
210
+ }
211
+
212
+ // Pending-event coalescer: per-relative-path debounce timer +
213
+ // last-known shape ("change" | "delete"). The most recent observation
214
+ // wins when the timer fires. Uses Map for ordered iteration on
215
+ // self-terminate paths.
216
+ var pending = new Map();
217
+ var stopped = false;
218
+ var eventCount = 0;
219
+ var watcherHandle = null;
220
+
221
+ function _safeEmitAudit(action, metadata) {
222
+ if (!auditOn) return;
223
+ try { audit().safeEmit({ action: action, outcome: "success", metadata: metadata || {} }); }
224
+ catch (_e) { /* drop-silent — audit best-effort */ }
225
+ }
226
+
227
+ function _safeError(err) {
228
+ try { observability().safeEvent("watcher.error", 1, { code: (err && err.code) || "unknown" }); }
229
+ catch (_e) { /* drop-silent */ }
230
+ try { onError(err); } catch (_e) { /* operator error handler must not crash the watcher */ }
231
+ }
232
+
233
+ function _normalizeAndDispatch(relPath) {
234
+ if (stopped) return;
235
+ if (isIgnored(relPath)) return;
236
+ var fullPath = path.join(root, relPath);
237
+ // lstat (NOT stat) — refuses to follow symlinks out of root.
238
+ var lst;
239
+ try { lst = fs.lstatSync(fullPath); }
240
+ catch (e) {
241
+ if (e && e.code === "ENOENT") {
242
+ // Path is gone — delete event. Type unknown by the time we
243
+ // observe; emit "file" as the conservative default. Operators
244
+ // tracking dir vs file deletes must keep their own shadow tree.
245
+ try {
246
+ onDelete({ type: "file", relativePath: relPath, fullPath: fullPath });
247
+ } catch (_eh) { /* operator hook must not crash dispatch */ }
248
+ return;
249
+ }
250
+ _safeError(e);
251
+ return;
252
+ }
253
+ if (lst.isSymbolicLink()) {
254
+ // Skip — never follow a symlink. The watcher ignores the event.
255
+ return;
256
+ }
257
+ var type = lst.isDirectory() ? "dir" : "file";
258
+ try {
259
+ onChange({
260
+ type: type,
261
+ relativePath: relPath,
262
+ fullPath: fullPath,
263
+ size: lst.size,
264
+ mtime: lst.mtime,
265
+ });
266
+ } catch (_eh) { /* operator hook must not crash dispatch */ }
267
+ }
268
+
269
+ function _enqueue(relPath) {
270
+ if (stopped) return;
271
+ eventCount += 1;
272
+ if (pending.size >= maxPending) {
273
+ // Safety net — operator's directory is producing more events
274
+ // than the watcher can keep up with. Emit one error and stop;
275
+ // operators raise maxPending or fix the source.
276
+ var overflow = new WatcherError("watcher/overflow",
277
+ "watcher: pending event queue exceeded maxPending=" + maxPending);
278
+ _safeError(overflow);
279
+ stop();
280
+ return;
281
+ }
282
+ var existing = pending.get(relPath);
283
+ if (existing && existing.timer) clearTimeout(existing.timer);
284
+ var entry = { timer: null };
285
+ entry.timer = setTimeout(function () {
286
+ pending.delete(relPath);
287
+ _normalizeAndDispatch(relPath);
288
+ }, debounceMs);
289
+ // Keep timers from blocking process exit — the operator's stop()
290
+ // call (or appShutdown) clears them explicitly.
291
+ if (entry.timer && typeof entry.timer.unref === "function") entry.timer.unref();
292
+ pending.set(relPath, entry);
293
+ }
294
+
295
+ // ---- start the underlying watch ----
296
+ try {
297
+ watcherHandle = fs.watch(root, { recursive: true, persistent: true }, function (eventType, filename) {
298
+ if (stopped) return;
299
+ // filename can be null on some platforms when the buffer
300
+ // overflows. Drop — there is nothing actionable.
301
+ if (!filename) return;
302
+ // node returns OS-native paths; normalize to root-relative.
303
+ var rel = filename;
304
+ // fs.watch passes a relative path already, but on macOS it can
305
+ // be an absolute path under /private/var/... when the root is a
306
+ // tmpdir symlink. Strip the root prefix defensively.
307
+ if (path.isAbsolute(rel) && rel.indexOf(root) === 0) {
308
+ rel = path.relative(root, rel);
309
+ }
310
+ // Both inotify and ReadDirectoryChangesW occasionally fire with
311
+ // an empty filename for the root directory itself — ignore.
312
+ if (rel === "" || rel === ".") return;
313
+ _enqueue(rel);
314
+ });
315
+ watcherHandle.on("error", function (err) { _safeError(err); });
316
+ } catch (e) {
317
+ // Older kernels without recursive inotify return ERR_FEATURE_UNAVAILABLE.
318
+ // Surface as an operator-actionable error rather than a silent
319
+ // single-directory degradation.
320
+ if (e && (e.code === "ERR_FEATURE_UNAVAILABLE_ON_PLATFORM" || e.code === "ENOSYS")) {
321
+ throw new WatcherError("watcher/recursive-unsupported",
322
+ "watcher.create: recursive watch not supported on this platform/kernel: " +
323
+ ((e && e.message) || String(e)));
324
+ }
325
+ throw new WatcherError("watcher/start-failed",
326
+ "watcher.create: fs.watch failed: " + ((e && e.message) || String(e)));
327
+ }
328
+
329
+ _safeEmitAudit("watcher.started", { root: root });
330
+
331
+ function stop() {
332
+ if (stopped) return;
333
+ stopped = true;
334
+ // Clear any pending debounces so process exit isn't held up.
335
+ pending.forEach(function (entry) {
336
+ if (entry && entry.timer) clearTimeout(entry.timer);
337
+ });
338
+ pending.clear();
339
+ if (watcherHandle) {
340
+ try { watcherHandle.close(); } catch (_e) { /* best-effort */ }
341
+ watcherHandle = null;
342
+ }
343
+ _safeEmitAudit("watcher.stopped", { root: root, eventCount: eventCount });
344
+ }
345
+
346
+ // Test seam — flushes all pending debounce timers immediately so
347
+ // tests don't have to await debounceMs. Not part of the operator
348
+ // contract.
349
+ function _flushForTest() {
350
+ var snapshot = Array.from(pending.entries());
351
+ pending.clear();
352
+ for (var i = 0; i < snapshot.length; i += 1) {
353
+ if (snapshot[i][1] && snapshot[i][1].timer) clearTimeout(snapshot[i][1].timer);
354
+ _normalizeAndDispatch(snapshot[i][0]);
355
+ }
356
+ }
357
+
358
+ return {
359
+ stop: stop,
360
+ root: root,
361
+ _flushForTest: _flushForTest,
362
+ };
363
+ }
364
+
365
+ module.exports = {
366
+ create: create,
367
+ WatcherError: WatcherError,
368
+ };
package/lib/webhook.js CHANGED
@@ -1,71 +1,53 @@
1
1
  "use strict";
2
2
  /**
3
- * b.webhook — outbound webhook signing + inbound verification.
3
+ * @module b.webhook
4
+ * @featured true
5
+ * @nav Communication
6
+ * @title Webhook
4
7
  *
5
- * var signer = b.webhook.signer({
6
- * algo: "hmac-sha3-512",
7
- * keys: { v1: secretBytes },
8
- * defaultKid: "v1",
9
- * });
10
- *
11
- * await signer.send({ url: "https://example.com/hook", body: jsonString });
12
- * // POSTs with:
13
- * // Webhook-Signature: t=<unix-seconds>,id=<uuid>,v1=<sig-hex>
14
- *
15
- * var verifier = b.webhook.verifier({
16
- * algo: "hmac-sha3-512",
17
- * keys: { v1: secret, v0: oldSecret }, // multi-key for rotation
18
- * toleranceMs: b.constants.TIME.minutes(5),
19
- * nonceStore: b.nonceStore.create({ ... }), // optional replay defense
20
- * });
21
- *
22
- * router.use(b.middleware.bodyParser({ keepRawBody: true })); // REQUIRED
23
- * router.post("/inbound-webhook", verifier.middleware(), function (req, res) {
24
- * // req.webhook = { algo, kid, timestamp, id }
25
- * });
26
- *
27
- * Algorithms:
28
- * "hmac-sha3-512" — symmetric. keys: { kid → Buffer/string secret }
29
- * "pqc-pem" — asymmetric. keys map for signer:
30
- * { kid → { privateKey, publicKey } } (PEM)
31
- * keys map for verifier:
32
- * { kid → publicKey } (PEM)
33
- * Algorithm (SLH-DSA-SHAKE-256f / ML-DSA-87) is
34
- * auto-detected by Node from the PEM. No classical
35
- * (Ed25519, RSA, ECDSA) signature scheme is exposed.
8
+ * @intro
9
+ * Outbound webhook delivery with cryptographic signing in a single
10
+ * `Webhook-Signature` header, retry + dead-letter via `b.retry`, and
11
+ * idempotency keys baked into the signed string so a captured
12
+ * signature cannot be replayed with a fresh id. Inbound verification
13
+ * is the symmetric primitive: `verifier()` returns a middleware that
14
+ * parses the header, enforces the timestamp window, finds a matching
15
+ * kid, runs constant-time signature compare, and (when configured)
16
+ * consults a nonce store for replay defense.
36
17
  *
37
- * Signed string (deterministic, prefix-bound to defend against algorithm-
38
- * substitution and key-substitution attacks):
18
+ * Algorithms: `hmac-sha3-512` (symmetric, kid Buffer/string secret)
19
+ * or `pqc-pem` (asymmetric — SLH-DSA-SHAKE-256f / ML-DSA-87 / ML-DSA-65,
20
+ * auto-detected by Node from the PEM). No classical (Ed25519 / RSA /
21
+ * ECDSA) signature scheme is exposed.
39
22
  *
40
- * <algo>.<kid>.<timestamp>.<id>.<body>
23
+ * Signed string is prefix-bound to defend against algorithm- and
24
+ * key-substitution attacks: `<algo>.<kid>.<timestamp>.<id>.<body>`.
25
+ * Header is the Stripe-shape `t=<seconds>,id=<uuid>,<kid>=<sig>`;
26
+ * `t` and `id` are reserved segment names, every other pair is a
27
+ * kid → signature mapping. The signer emits exactly one kid; the
28
+ * verifier accepts any number so operators rotating keys point the
29
+ * verifier at both old + new keys and migrate signers progressively.
41
30
  *
42
- * Header format (single combined header, Stripe-shape):
31
+ * PQC signatures are emitted as base64url (~40 KB for SLH-DSA-SHAKE-
32
+ * 256f, vs ~59 KB hex) to fit common front-end header caps; the
33
+ * verifier accepts EITHER encoding for transition windows.
43
34
  *
44
- * Webhook-Signature: t=<unix-seconds>,id=<uuid>,<kid>=<sig-hex>
35
+ * Replay defense: passing a `nonceStore` (any object exposing
36
+ * `checkAndInsert(nonce, expireAt) → bool/Promise<bool>`) records
37
+ * seen ids; a second delivery with the same id rejects with REPLAY.
38
+ * `b.nonceStore` is the reference implementation; operators plug in
39
+ * Redis / SQL by satisfying the same shape.
45
40
  *
46
- * `t` and `id` are reserved segment names; every other `<name>=<value>`
47
- * pair is treated as a kid signature mapping. Multiple kid pairs are
48
- * accepted on the verifier side; the signer emits exactly one. Operators
49
- * rotating keys point the verifier at both old + new keys and migrate
50
- * signers progressively.
41
+ * Audit defaults are ON for both success and failure on both sides
42
+ * the inbound verify IS the auditable boundary event, not a
43
+ * precursor to one. Operators with extreme volume opt out via
44
+ * `auditSuccess: false`; failures remain on regardless.
51
45
  *
52
- * Replay defense:
53
- * - `id` is included in the signed string, so a captured signature
54
- * cannot be reused with a fresh id.
55
- * - Optional `nonceStore` records seen ids; second delivery with the
56
- * same id rejects with REPLAY. The framework's b.nonceStore is the
57
- * reference impl; operators plug in Redis/SQL by passing any object
58
- * with `checkAndInsert(nonce, expireAt) → bool/Promise<bool>`.
59
- *
60
- * Validation policy:
61
- *
62
- * - signer/verifier creation opts → throw at config time
63
- * - signer.sign body type → throw at call site
64
- * - signer.send url shape → throw at call site (via safeUrl)
65
- * - verifier.verify input shape → throw WebhookError at call site
66
- * - nonceStore.checkAndInsert err → propagates (fail-closed)
46
+ * @card
47
+ * Outbound webhook delivery with cryptographic signing in a single `Webhook-Signature` header, retry + dead-letter via `b.retry`, and idempotency keys baked into the signed string so a captured signature cannot be replayed with a fresh id.
67
48
  */
68
49
 
50
+ var nodeCrypto = require("crypto");
69
51
  var crypto = require("./crypto");
70
52
  var httpClient = require("./http-client");
71
53
  var safeBuffer = require("./safe-buffer");
@@ -91,6 +73,16 @@ var ALGOS = Object.freeze({
91
73
  PQC_PEM: "pqc-pem",
92
74
  });
93
75
 
76
+ // PQC signature algorithms accepted under the "pqc-pem" algo. Node
77
+ // auto-detects the active algorithm from the PEM (asymmetricKeyType ===
78
+ // "ml-dsa-65" | "ml-dsa-87" | "slh-dsa-shake-256f"). When the operator
79
+ // pins `pqcAlgorithm` at signer/verifier construction the framework
80
+ // asserts the PEM matches at config time so a key-rotation that
81
+ // accidentally swapped algorithms surfaces at boot, not at first
82
+ // signature failure. Permitted values match the audit-signing primitive
83
+ // (lib/audit-sign.js SUPPORTED_SIGNING_ALGS).
84
+ var PQC_ALGORITHMS = Object.freeze(["slh-dsa-shake-256f", "ml-dsa-87", "ml-dsa-65"]);
85
+
94
86
  var HEADER = Object.freeze({
95
87
  SIGNATURE: "Webhook-Signature",
96
88
  });
@@ -170,6 +162,45 @@ function _validateKeysShape(name, algo, keys, side) {
170
162
  }
171
163
  }
172
164
 
165
+ // _detectPqcAlgorithmFromPem — read asymmetricKeyType from a PEM key.
166
+ // Used to assert the operator-pinned pqcAlgorithm matches the PEM at
167
+ // config time. Returns null on un-parseable input (caller already
168
+ // validated key shape, so this only fires for malformed PEM).
169
+ function _detectPqcAlgorithmFromPem(pem) {
170
+ try {
171
+ var k = typeof pem === "string"
172
+ ? nodeCrypto.createPrivateKey(pem)
173
+ : nodeCrypto.createPrivateKey({ key: pem, format: "pem" });
174
+ return k.asymmetricKeyType;
175
+ } catch (_e1) {
176
+ try {
177
+ var pubk = typeof pem === "string"
178
+ ? nodeCrypto.createPublicKey(pem)
179
+ : nodeCrypto.createPublicKey({ key: pem, format: "pem" });
180
+ return pubk.asymmetricKeyType;
181
+ } catch (_e2) { return null; }
182
+ }
183
+ }
184
+
185
+ function _assertPqcAlgorithmMatches(name, pqcAlgorithm, keys, side) {
186
+ if (typeof pqcAlgorithm !== "string") return;
187
+ if (PQC_ALGORITHMS.indexOf(pqcAlgorithm) === -1) {
188
+ throw _err("BAD_OPT", name + ": pqcAlgorithm must be one of " +
189
+ PQC_ALGORITHMS.join(", ") + ", got " + JSON.stringify(pqcAlgorithm));
190
+ }
191
+ var kids = Object.keys(keys);
192
+ for (var i = 0; i < kids.length; i++) {
193
+ var k = keys[kids[i]];
194
+ var pem = side === "signer" ? (k.privateKey || k.publicKey) : k;
195
+ var detected = _detectPqcAlgorithmFromPem(pem);
196
+ if (detected && detected !== pqcAlgorithm) {
197
+ throw _err("BAD_OPT", name + ": pqcAlgorithm '" + pqcAlgorithm +
198
+ "' does not match PEM (kid '" + kids[i] + "' has asymmetricKeyType=" +
199
+ JSON.stringify(detected) + ")");
200
+ }
201
+ }
202
+ }
203
+
173
204
  function _validateBody(body) {
174
205
  if (typeof body !== "string" && !Buffer.isBuffer(body)) {
175
206
  throw _err("BAD_BODY", "webhook: body must be a string or Buffer, got " + typeof body);
@@ -275,8 +306,56 @@ function _validateSignerOpts(opts) {
275
306
  validateOpts.optionalFunction(opts.idGenerator, "webhook.signer: idGenerator", WebhookError);
276
307
  validateOpts.optionalFunction(opts.now, "webhook.signer: now", WebhookError);
277
308
  validateOpts.auditShape(opts.audit, "webhook.signer", WebhookError);
309
+ if (opts.pqcAlgorithm !== undefined) {
310
+ if (opts.algo !== ALGOS.PQC_PEM) {
311
+ throw _err("BAD_OPT", "webhook.signer: pqcAlgorithm only meaningful with algo='pqc-pem'");
312
+ }
313
+ _assertPqcAlgorithmMatches("webhook.signer", opts.pqcAlgorithm, opts.keys, "signer");
314
+ }
278
315
  }
279
316
 
317
+ /**
318
+ * @primitive b.webhook.signer
319
+ * @signature b.webhook.signer(opts)
320
+ * @since 0.1.0
321
+ * @status stable
322
+ * @compliance soc2, pci-dss
323
+ * @related b.webhook.verifier
324
+ *
325
+ * Build an outbound signer. Returns `{ sign, headers, send }`: `sign`
326
+ * computes the signature header pair for a body without doing I/O;
327
+ * `headers` returns just the headers map; `send` performs the POST via
328
+ * `b.httpClient.request` wrapped in `b.retry.withRetry`. Each call
329
+ * generates a fresh idempotency `id` (ULID-shaped via `b.crypto.
330
+ * generateToken` by default; operators override with `idGenerator`)
331
+ * that's bound into the signed string so captured signatures cannot
332
+ * replay with a different id.
333
+ *
334
+ * @opts
335
+ * algo: "hmac-sha3-512" | "pqc-pem",
336
+ * keys: { [kid]: Buffer | string } // hmac
337
+ * | { [kid]: { privateKey, publicKey } } // pqc-pem
338
+ * defaultKid: string, // required when keys has >1 kid
339
+ * pqcAlgorithm: "slh-dsa-shake-256f" | "ml-dsa-87" | "ml-dsa-65",
340
+ * signatureHeader: string, // default "Webhook-Signature"
341
+ * idGenerator: function () => string,
342
+ * now: function () => number, // ms
343
+ * retry: object, // b.retry.withRetry opts
344
+ * http: object, // b.httpClient.request opts
345
+ * audit: object, // b.audit handle
346
+ * auditFailures: boolean, // default true
347
+ * auditSuccess: boolean, // default true
348
+ *
349
+ * @example
350
+ * var b = require("@blamejs/core");
351
+ * var signer = b.webhook.signer({
352
+ * algo: "hmac-sha3-512",
353
+ * keys: { v1: Buffer.from("0123456789abcdef0123456789abcdef") },
354
+ * defaultKid: "v1",
355
+ * });
356
+ * var headers = signer.headers('{"event":"user.created"}');
357
+ * // → { "Webhook-Signature": "t=1714500000,id=...,v1=<hex>" }
358
+ */
280
359
  function signer(opts) {
281
360
  _validateSignerOpts(opts);
282
361
  var algo = opts.algo;
@@ -435,8 +514,61 @@ function _validateVerifierOpts(opts) {
435
514
  validateOpts.auditShape(opts.audit, "webhook.verifier", WebhookError);
436
515
  validateOpts.optionalBoolean(opts.auditFailures, "webhook.verifier: auditFailures", WebhookError);
437
516
  validateOpts.optionalBoolean(opts.auditSuccess, "webhook.verifier: auditSuccess", WebhookError);
517
+ if (opts.pqcAlgorithm !== undefined) {
518
+ if (opts.algo !== ALGOS.PQC_PEM) {
519
+ throw _err("BAD_OPT", "webhook.verifier: pqcAlgorithm only meaningful with algo='pqc-pem'");
520
+ }
521
+ _assertPqcAlgorithmMatches("webhook.verifier", opts.pqcAlgorithm, opts.keys, "verifier");
522
+ }
438
523
  }
439
524
 
525
+ /**
526
+ * @primitive b.webhook.verifier
527
+ * @signature b.webhook.verifier(opts)
528
+ * @since 0.1.0
529
+ * @status stable
530
+ * @compliance soc2, pci-dss
531
+ * @related b.webhook.signer
532
+ *
533
+ * Build an inbound verifier. Returns `{ verify, middleware }`: `verify`
534
+ * checks an explicit `{ body, headers }` pair and resolves to
535
+ * `{ algo, kid, timestamp, id }` on success; `middleware` is an
536
+ * Express-style middleware that pulls `req.bodyRaw` (requires
537
+ * `b.middleware.bodyParser({ keepRawBody: true })`), verifies, and
538
+ * stashes the result on `req.webhook`. Failures throw `WebhookError`
539
+ * with a stable `code` (`MISSING_HEADER` / `BAD_HEADER_FORMAT` /
540
+ * `EXPIRED` / `FUTURE` / `UNKNOWN_KID` / `BAD_SIGNATURE` / `REPLAY` /
541
+ * ...) and the middleware translates them to HTTP 401 / 500.
542
+ *
543
+ * @opts
544
+ * algo: "hmac-sha3-512" | "pqc-pem",
545
+ * keys: { [kid]: Buffer | string } // hmac
546
+ * | { [kid]: string | Buffer }, // pqc-pem (PEM public key)
547
+ * pqcAlgorithm: "slh-dsa-shake-256f" | "ml-dsa-87" | "ml-dsa-65",
548
+ * toleranceMs: number, // default 5 minutes
549
+ * clockSkewMs: number, // default 1 minute
550
+ * signatureHeader: string, // default "Webhook-Signature"
551
+ * nonceStore: { checkAndInsert(nonce, expireAt) },
552
+ * now: function () => number,
553
+ * audit: object,
554
+ * auditFailures: boolean, // default true
555
+ * auditSuccess: boolean, // default true
556
+ *
557
+ * @example
558
+ * var b = require("@blamejs/core");
559
+ * var verifier = b.webhook.verifier({
560
+ * algo: "hmac-sha3-512",
561
+ * keys: { v1: Buffer.from("0123456789abcdef0123456789abcdef") },
562
+ * toleranceMs: b.constants.TIME.minutes(5),
563
+ * });
564
+ * // wire into a router:
565
+ * // router.use(b.middleware.bodyParser({ keepRawBody: true }));
566
+ * // router.post("/inbound", verifier.middleware(), function (req, res) {
567
+ * // // req.webhook = { algo, kid, timestamp, id }
568
+ * // });
569
+ * var mw = verifier.middleware();
570
+ * // → function (req, res, next) { ... }
571
+ */
440
572
  function verifier(opts) {
441
573
  _validateVerifierOpts(opts);
442
574
  var cfg = validateOpts.applyDefaults(opts, DEFAULTS);
@@ -606,10 +738,11 @@ function _writeError(res, status, code, message) {
606
738
  // ---- Public surface ----
607
739
 
608
740
  module.exports = {
609
- signer: signer,
610
- verifier: verifier,
611
- ALGOS: ALGOS,
612
- HEADER: HEADER,
613
- DEFAULTS: DEFAULTS,
614
- WebhookError: WebhookError,
741
+ signer: signer,
742
+ verifier: verifier,
743
+ ALGOS: ALGOS,
744
+ PQC_ALGORITHMS: PQC_ALGORITHMS,
745
+ HEADER: HEADER,
746
+ DEFAULTS: DEFAULTS,
747
+ WebhookError: WebhookError,
615
748
  };