@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
@@ -59,6 +59,7 @@ var { AuthError } = require("../framework-error");
59
59
 
60
60
  var httpClient = lazyRequire(function () { return require("../http-client"); });
61
61
  var cache = lazyRequire(function () { return require("../cache"); });
62
+ var auditFwk = lazyRequire(function () { return require("../audit"); });
62
63
 
63
64
  // ---- constants ----
64
65
 
@@ -250,6 +251,22 @@ async function verifyExternal(token, opts) {
250
251
 
251
252
  // Decode header + payload.
252
253
  var parts = token.split(".");
254
+ // CVE-2026-29000 / CVE-2026-23993 / CVE-2026-22817 / CVE-2026-34950 —
255
+ // JWE-bypass + alg-confusion. A 5-segment compact serialization is a
256
+ // JWE (RFC 7516); accepting it on a JWS verifier is the canonical
257
+ // confused-deputy shape. verifyExternal is JWS-only; refuse JWE
258
+ // outright. Operators with JWE need a separate handler wired to
259
+ // their KMS — never a defaulted JWE path on the JWS verifier.
260
+ if (parts.length === 5) {
261
+ try { auditFwk().safeEmit({
262
+ action: "jwt.jwe.refused",
263
+ outcome: "denied",
264
+ metadata: { reason: "jwe-on-jws-verifier" },
265
+ }); } catch (_e) { /* audit best-effort */ }
266
+ throw new AuthError("auth-jwt-external/jwe-refused",
267
+ "5-segment JWE token refused — verifyExternal only handles JWS " +
268
+ "(JWE bypass class — CVE-2026-29000 / CVE-2026-23993 / CVE-2026-22817 / CVE-2026-34950)");
269
+ }
253
270
  if (parts.length !== 3) {
254
271
  throw new AuthError("auth-jwt-external/malformed-jwt",
255
272
  "token does not have 3 parts");
package/lib/auth/oauth.js CHANGED
@@ -461,6 +461,13 @@ function create(opts) {
461
461
  async function authorizationUrl(uopts) {
462
462
  uopts = uopts || {};
463
463
  var endpoint = await _resolveEndpoint("authorizationEndpoint");
464
+ // CVE-2026-34511 — PKCE verifier leak via state. The state token is
465
+ // an opaque CSPRNG output; the PKCE verifier is generated separately
466
+ // and returned in its own field for the caller to store. The
467
+ // `code_verifier` is NEVER concatenated into `state` and `state`
468
+ // never carries operator-supplied PII. PKCE-S256 is the default
469
+ // (pkce: false throws above); _generatePkce() emits
470
+ // base64url(SHA-256(verifier)) per RFC 7636.
464
471
  var state = uopts.state || _generateRandomToken(STATE_NONCE_BYTES);
465
472
  var nonce = uopts.nonce || (isOidc ? _generateRandomToken(STATE_NONCE_BYTES) : null);
466
473
  var pkceVals = pkce ? _generatePkce() : null;
@@ -0,0 +1,505 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.authBotChallenge
4
+ * @nav Identity
5
+ * @title Auth Bot Challenge
6
+ *
7
+ * @intro
8
+ * Adaptive bot-challenge gate for authentication paths. Composes
9
+ * `b.middleware.botGuard` + `b.auth.lockout` + an operator-supplied
10
+ * challenge function (captcha / email confirmation / second-factor
11
+ * prompt) into a deterministic staircase that escalates protection
12
+ * as failed-auth attempts accumulate.
13
+ *
14
+ * Staircase: below `threshold` failures, requests flow through
15
+ * unchanged. At `threshold`, bot-guard heuristics gate the session.
16
+ * After bot-guard passes but failures keep accumulating, the
17
+ * operator's `challengeFn(req, res)` runs (returning `true` clears
18
+ * the challenge). Past `escalationThreshold`, `escalationFn(req)`
19
+ * runs (typically `b.auth.atoKillSwitch.trigger`) and the middleware
20
+ * answers 423 Locked.
21
+ *
22
+ * Session state is operator-storage — pass a `b.cache`-shaped
23
+ * sessionStore (any backend) and the gate persists per-key
24
+ * (stage, failures, challengedAt, passedAt). The lockout primitive
25
+ * stays the cluster-shared counter authority; this primitive layers
26
+ * the human-vs-bot ladder above it.
27
+ *
28
+ * Audit emissions: `auth.bot_challenge.required` /
29
+ * `.passed` / `.failed` / `.escalated` / `.cleared`. Validation
30
+ * policy: `create()` throws on bad opts at boot; `middleware()`
31
+ * never throws (staircase failures audit and answer 401/423);
32
+ * `recordFailure` / `recordSuccess` / `check` / `reset` throw on
33
+ * bad keys.
34
+ *
35
+ * @card
36
+ * Adaptive bot-challenge gate for authentication paths.
37
+ */
38
+
39
+ var C = require("./constants");
40
+ var lazyRequire = require("./lazy-require");
41
+ var requestHelpers = require("./request-helpers");
42
+ var validateOpts = require("./validate-opts");
43
+ var { AuthBotChallengeError } = require("./framework-error");
44
+
45
+ var observability = lazyRequire(function () { return require("./observability"); });
46
+
47
+ var DEFAULT_THRESHOLD = 3;
48
+ var DEFAULT_ESCALATION_THRESHOLD = 6;
49
+ var DEFAULT_CHALLENGE_TTL_MS = C.TIME.minutes(30);
50
+
51
+ var STATE_NEW = "new";
52
+ var STATE_CHALLENGED = "challenged";
53
+ var STATE_PASSED = "passed";
54
+ var STATE_LOCKED = "locked";
55
+
56
+ var ALLOWED_OPTS = [
57
+ "botGuard", "lockout", "sessionStore", "threshold", "escalationThreshold",
58
+ "challengeFn", "escalationFn", "audit", "challengeTtlMs", "keyExtractor",
59
+ "observability", "clock",
60
+ ];
61
+
62
+ function _requireFunction(name, val) {
63
+ if (typeof val !== "function") {
64
+ throw new AuthBotChallengeError("auth-bot-challenge/bad-opt",
65
+ name + ": expected function, got " + typeof val);
66
+ }
67
+ }
68
+
69
+ function _requirePositiveInt(name, val) {
70
+ if (typeof val !== "number" || !isFinite(val) || val < 1 || Math.floor(val) !== val) {
71
+ throw new AuthBotChallengeError("auth-bot-challenge/bad-opt",
72
+ name + ": expected positive integer, got " + JSON.stringify(val));
73
+ }
74
+ }
75
+
76
+ function _requireNonNegFinite(name, val) {
77
+ if (typeof val !== "number" || !isFinite(val) || val < 0) {
78
+ throw new AuthBotChallengeError("auth-bot-challenge/bad-opt",
79
+ name + ": expected non-negative finite number, got " + JSON.stringify(val));
80
+ }
81
+ }
82
+
83
+ function _requireKey(key) {
84
+ if (typeof key !== "string" || key.length === 0) {
85
+ throw new AuthBotChallengeError("auth-bot-challenge/bad-key",
86
+ "key must be a non-empty string, got " + typeof key + " " + JSON.stringify(key));
87
+ }
88
+ }
89
+
90
+ function _requireSessionStore(store) {
91
+ if (!store || typeof store !== "object" ||
92
+ typeof store.get !== "function" ||
93
+ typeof store.set !== "function" ||
94
+ typeof store.del !== "function") {
95
+ throw new AuthBotChallengeError("auth-bot-challenge/bad-opt",
96
+ "sessionStore must be a b.cache-shaped object (get/set/del)");
97
+ }
98
+ }
99
+
100
+ function _requireBotGuard(bg) {
101
+ if (typeof bg !== "function") {
102
+ throw new AuthBotChallengeError("auth-bot-challenge/bad-opt",
103
+ "botGuard must be a connect-style middleware function (got " + typeof bg + ")");
104
+ }
105
+ }
106
+
107
+ function _requireLockout(lk) {
108
+ if (!lk || typeof lk !== "object" ||
109
+ typeof lk.recordFailure !== "function" ||
110
+ typeof lk.recordSuccess !== "function" ||
111
+ typeof lk.check !== "function") {
112
+ throw new AuthBotChallengeError("auth-bot-challenge/bad-opt",
113
+ "lockout must be a b.auth.lockout-shaped instance " +
114
+ "(recordFailure/recordSuccess/check)");
115
+ }
116
+ }
117
+
118
+ function _defaultKeyExtractor(req) {
119
+ // Default key strategy: prefer user-supplied identifier (req.body.email,
120
+ // req.body.username), fall back to client IP. Operators override via
121
+ // opts.keyExtractor for OAuth flows / passkey ceremonies.
122
+ if (req && req.body && typeof req.body === "object") {
123
+ if (typeof req.body.email === "string" && req.body.email.length > 0) {
124
+ return req.body.email.toLowerCase();
125
+ }
126
+ if (typeof req.body.username === "string" && req.body.username.length > 0) {
127
+ return req.body.username.toLowerCase();
128
+ }
129
+ }
130
+ try { return requestHelpers.clientIp(req); }
131
+ catch (_e) { return "<unknown>"; }
132
+ }
133
+
134
+ /**
135
+ * @primitive b.authBotChallenge.create
136
+ * @signature b.authBotChallenge.create(opts)
137
+ * @since 0.8.48
138
+ * @status stable
139
+ * @related b.middleware.botGuard, b.auth.lockout, b.auth.atoKillSwitch
140
+ *
141
+ * Build an adaptive bot-challenge gate. Returns
142
+ * `{ middleware, recordFailure, recordSuccess, check, reset }`.
143
+ * `middleware()` is the connect-style entry point; `recordFailure(key)`
144
+ * and `recordSuccess(key)` advance / clear the ladder from the
145
+ * operator's post-verify code path.
146
+ *
147
+ * @opts
148
+ * botGuard: Function, // connect-style (req, res, next) middleware
149
+ * lockout: Object, // b.auth.lockout instance (recordFailure / recordSuccess / check)
150
+ * sessionStore: Object, // b.cache-shaped store (get / set / del)
151
+ * threshold: number, // failures before challenge stage (default 3)
152
+ * escalationThreshold: number, // failures before lockout (default 6; must exceed threshold)
153
+ * challengeFn: Function, // async (req, res) → boolean | thrown
154
+ * escalationFn: Function, // async (req) → void; runs at lockout
155
+ * audit: Object, // b.audit instance (safeEmit-shaped)
156
+ * challengeTtlMs: number, // session-mark TTL (default 30 minutes)
157
+ * keyExtractor: Function, // (req) → string; default body.email / body.username / clientIp
158
+ * observability: Object, // observability sink (event-shaped)
159
+ * clock: Function, // () → number; testing override (default Date.now)
160
+ *
161
+ * @example
162
+ * var gate = b.authBotChallenge.create({
163
+ * botGuard: botGuardMiddleware,
164
+ * lockout: lockoutInstance,
165
+ * sessionStore: cacheInstance,
166
+ * threshold: 3,
167
+ * escalationThreshold: 6,
168
+ * challengeFn: async function (req, res) {
169
+ * return req.body && req.body.captchaToken === "verified";
170
+ * },
171
+ * escalationFn: async function (req) {
172
+ * // Kill session, lock account, page on-call.
173
+ * },
174
+ * audit: auditInstance,
175
+ * });
176
+ *
177
+ * // Mount on the login route — the gate decides 200 / 401 / 423.
178
+ * var loginRoute = [gate.middleware(), function (req, res) { res.end("ok"); }];
179
+ *
180
+ * // After verifying the credential, advance the ladder explicitly:
181
+ * var advanced = await gate.recordFailure("user@example.com");
182
+ * advanced.stage; // → "new" | "challenged" | "locked"
183
+ *
184
+ * var status = await gate.check("user@example.com");
185
+ * status.failures; // → 1
186
+ */
187
+ function create(opts) {
188
+ opts = opts || {};
189
+ validateOpts(opts, ALLOWED_OPTS, "authBotChallenge.create");
190
+
191
+ _requireBotGuard(opts.botGuard);
192
+ _requireLockout(opts.lockout);
193
+ _requireSessionStore(opts.sessionStore);
194
+
195
+ var threshold = opts.threshold !== undefined ? opts.threshold : DEFAULT_THRESHOLD;
196
+ _requirePositiveInt("threshold", threshold);
197
+ var escalationThreshold = opts.escalationThreshold !== undefined
198
+ ? opts.escalationThreshold : DEFAULT_ESCALATION_THRESHOLD;
199
+ _requirePositiveInt("escalationThreshold", escalationThreshold);
200
+ if (escalationThreshold <= threshold) {
201
+ throw new AuthBotChallengeError("auth-bot-challenge/bad-opt",
202
+ "escalationThreshold (" + escalationThreshold + ") must exceed threshold (" + threshold + ")");
203
+ }
204
+
205
+ var challengeTtlMs = opts.challengeTtlMs !== undefined
206
+ ? opts.challengeTtlMs : DEFAULT_CHALLENGE_TTL_MS;
207
+ _requireNonNegFinite("challengeTtlMs", challengeTtlMs);
208
+
209
+ if (opts.challengeFn !== undefined) _requireFunction("challengeFn", opts.challengeFn);
210
+ if (opts.escalationFn !== undefined) _requireFunction("escalationFn", opts.escalationFn);
211
+ if (opts.keyExtractor !== undefined) _requireFunction("keyExtractor", opts.keyExtractor);
212
+
213
+ validateOpts.auditShape(opts.audit, "authBotChallenge.create", AuthBotChallengeError);
214
+
215
+ var botGuard = opts.botGuard;
216
+ var lockout = opts.lockout;
217
+ var sessionStore = opts.sessionStore;
218
+ var challengeFn = opts.challengeFn || null;
219
+ var escalationFn = opts.escalationFn || null;
220
+ var keyExtractor = opts.keyExtractor || _defaultKeyExtractor;
221
+ var auditInst = opts.audit || null;
222
+ var obsInst = opts.observability || null;
223
+ var clock = opts.clock || Date.now;
224
+
225
+ function _emitObs(name, labels) {
226
+ var sink = obsInst || _safeGlobalObs();
227
+ if (!sink) return;
228
+ try { sink.event(name, 1, labels); } catch (_e) { /* drop-silent */ }
229
+ }
230
+
231
+ function _safeGlobalObs() {
232
+ try { return observability(); } catch (_e) { return null; }
233
+ }
234
+
235
+ function _emitAudit(action, key, outcome, metadata, req) {
236
+ if (!auditInst) return;
237
+ try {
238
+ var event = {
239
+ action: action,
240
+ outcome: outcome,
241
+ resource: { kind: "auth.bot_challenge", id: key },
242
+ metadata: metadata || {},
243
+ };
244
+ if (req) event.actor = requestHelpers.extractActorContext(req);
245
+ auditInst.safeEmit(event);
246
+ } catch (_e) { /* audit best-effort */ }
247
+ }
248
+
249
+ async function _readState(key) {
250
+ try {
251
+ var raw = await sessionStore.get(key);
252
+ return raw || null;
253
+ } catch (_e) { return null; }
254
+ }
255
+
256
+ async function _writeState(key, state, ttlMs) {
257
+ try { await sessionStore.set(key, state, { ttlMs: ttlMs }); }
258
+ catch (_e) { /* drop-silent: store transient */ }
259
+ }
260
+
261
+ async function _deleteState(key) {
262
+ try { await sessionStore.del(key); }
263
+ catch (_e) { /* drop-silent */ }
264
+ }
265
+
266
+ // Run the bot-guard middleware in a captured-response harness — bot-
267
+ // guard is a (req, res, next) middleware shape. The challenge gate
268
+ // does NOT block here; it only inspects whether bot-guard's
269
+ // heuristics flagged the request.
270
+ function _runBotGuardCheck(req) {
271
+ return new Promise(function (resolve) {
272
+ var capturedRes = {
273
+ statusCode: 200, // allow:raw-byte-literal — HTTP 200 status code, not bytes
274
+ writableEnded: false,
275
+ writeHead: function (status) { capturedRes.statusCode = status; },
276
+ end: function () { capturedRes.writableEnded = true; },
277
+ };
278
+ var settled = false;
279
+ function done(passed, reason) {
280
+ if (settled) return;
281
+ settled = true;
282
+ resolve({ passed: passed, reason: reason || null });
283
+ }
284
+ try {
285
+ botGuard(req, capturedRes, function () {
286
+ // If bot-guard tagged the request, surface that. The default
287
+ // botGuard mode is "block"; in tag mode req.suspectedBot
288
+ // gets set. Either way: flagged = challenge required.
289
+ if (req.suspectedBot) return done(false, req.suspectedBot);
290
+ return done(true, null);
291
+ });
292
+ // If middleware terminated by writing a response, treat as flagged.
293
+ if (capturedRes.writableEnded) {
294
+ done(false, "bot-guard-blocked");
295
+ return;
296
+ }
297
+ } catch (_e) {
298
+ done(false, "bot-guard-exception");
299
+ return;
300
+ }
301
+ });
302
+ }
303
+
304
+ // ---- Internal staircase advance ----
305
+
306
+ async function _advanceFailure(key, req) {
307
+ var now = clock();
308
+ var state = await _readState(key) || {
309
+ stage: STATE_NEW, failures: 0, challengedAt: null, passedAt: null,
310
+ };
311
+ state.failures = (state.failures || 0) + 1;
312
+
313
+ // Lockout subscriber — propagate the failure into the lockout
314
+ // primitive so cluster-shared counters stay accurate.
315
+ try { await lockout.recordFailure(key, { req: req, reason: "auth-bot-challenge" }); }
316
+ catch (_e) { /* lockout best-effort */ }
317
+
318
+ if (state.failures >= escalationThreshold) {
319
+ state.stage = STATE_LOCKED;
320
+ await _writeState(key, state, challengeTtlMs);
321
+ _emitObs("auth.bot_challenge.escalated", { stage: STATE_LOCKED });
322
+ _emitAudit("auth.bot_challenge.escalated", key, "denied",
323
+ { failures: state.failures, threshold: escalationThreshold }, req);
324
+ if (escalationFn) {
325
+ try { await escalationFn(req); }
326
+ catch (_e) { /* escalation best-effort */ }
327
+ }
328
+ return { stage: STATE_LOCKED, failures: state.failures };
329
+ }
330
+ if (state.failures >= threshold) {
331
+ state.stage = STATE_CHALLENGED;
332
+ state.challengedAt = now;
333
+ await _writeState(key, state, challengeTtlMs);
334
+ _emitObs("auth.bot_challenge.required", { stage: STATE_CHALLENGED });
335
+ _emitAudit("auth.bot_challenge.required", key, "denied",
336
+ { failures: state.failures, threshold: threshold }, req);
337
+ return { stage: STATE_CHALLENGED, failures: state.failures };
338
+ }
339
+ await _writeState(key, state, challengeTtlMs);
340
+ return { stage: STATE_NEW, failures: state.failures };
341
+ }
342
+
343
+ // ---- Public surface ----
344
+
345
+ function middleware() {
346
+ return async function authBotChallengeMiddleware(req, res, next) {
347
+ var key;
348
+ try { key = keyExtractor(req); }
349
+ catch (_e) { key = "<unknown>"; }
350
+ if (typeof key !== "string" || key.length === 0) key = "<unknown>";
351
+
352
+ var state = await _readState(key);
353
+
354
+ if (state && state.stage === STATE_LOCKED) {
355
+ _emitAudit("auth.bot_challenge.escalated", key, "denied",
356
+ { reason: "already-locked" }, req);
357
+ return _writeLocked(res);
358
+ }
359
+
360
+ if (state && state.stage === STATE_CHALLENGED) {
361
+ // Run bot-guard heuristics first — fastest path. If those don't
362
+ // pass, defer to the operator-supplied challengeFn.
363
+ var bgVerdict = await _runBotGuardCheck(req);
364
+ if (bgVerdict.passed) {
365
+ state.stage = STATE_PASSED;
366
+ state.passedAt = clock();
367
+ await _writeState(key, state, challengeTtlMs);
368
+ _emitObs("auth.bot_challenge.passed", { stage: "bot-guard" });
369
+ _emitAudit("auth.bot_challenge.passed", key, "success",
370
+ { stage: "bot-guard" }, req);
371
+ return next();
372
+ }
373
+ if (challengeFn) {
374
+ var challengeResult;
375
+ try { challengeResult = await challengeFn(req, res); }
376
+ catch (e) {
377
+ _emitAudit("auth.bot_challenge.failed", key, "denied",
378
+ { stage: "challenge-fn", error: e && e.message }, req);
379
+ // Challenge-fn threw — treat as a failure; advance the ladder.
380
+ await _advanceFailure(key, req);
381
+ return _writeLocked(res);
382
+ }
383
+ // The challengeFn may have responded itself (e.g. rendered a
384
+ // captcha page on GET). Detect that.
385
+ if (res && res.writableEnded) return;
386
+ if (challengeResult === true) {
387
+ state.stage = STATE_PASSED;
388
+ state.passedAt = clock();
389
+ await _writeState(key, state, challengeTtlMs);
390
+ _emitObs("auth.bot_challenge.passed", { stage: "challenge-fn" });
391
+ _emitAudit("auth.bot_challenge.passed", key, "success",
392
+ { stage: "challenge-fn" }, req);
393
+ return next();
394
+ }
395
+ _emitObs("auth.bot_challenge.failed", { stage: "challenge-fn" });
396
+ _emitAudit("auth.bot_challenge.failed", key, "denied",
397
+ { stage: "challenge-fn" }, req);
398
+ await _advanceFailure(key, req);
399
+ return _writeChallengeRequired(res);
400
+ }
401
+ // No challengeFn supplied and bot-guard failed → 401.
402
+ _emitObs("auth.bot_challenge.failed", { stage: "bot-guard-only" });
403
+ _emitAudit("auth.bot_challenge.failed", key, "denied",
404
+ { stage: "bot-guard-only", reason: bgVerdict.reason }, req);
405
+ return _writeChallengeRequired(res);
406
+ }
407
+
408
+ // STATE_NEW or STATE_PASSED — flow through. Whether the wrapped
409
+ // handler counts the attempt as a failure is the operator's
410
+ // responsibility (they call gate.recordFailure(key) post-verify).
411
+ return next();
412
+ };
413
+ }
414
+
415
+ function _writeChallengeRequired(res) {
416
+ if (!res || res.writableEnded) return;
417
+ if (typeof res.writeHead === "function") {
418
+ res.writeHead(401, {
419
+ "Content-Type": "text/plain",
420
+ "WWW-Authenticate": 'Bearer error="bot_challenge_required"',
421
+ });
422
+ } else if (typeof res.statusCode !== "undefined") {
423
+ res.statusCode = 401;
424
+ }
425
+ if (typeof res.end === "function") res.end("Bot challenge required");
426
+ }
427
+
428
+ function _writeLocked(res) {
429
+ if (!res || res.writableEnded) return;
430
+ if (typeof res.writeHead === "function") {
431
+ res.writeHead(423, { "Content-Type": "text/plain" });
432
+ } else if (typeof res.statusCode !== "undefined") {
433
+ res.statusCode = 423;
434
+ }
435
+ if (typeof res.end === "function") res.end("Locked");
436
+ }
437
+
438
+ async function recordFailure(key, callOpts) {
439
+ _requireKey(key);
440
+ callOpts = callOpts || {};
441
+ return await _advanceFailure(key, callOpts.req || null);
442
+ }
443
+
444
+ async function recordSuccess(key, callOpts) {
445
+ _requireKey(key);
446
+ callOpts = callOpts || {};
447
+ var state = await _readState(key);
448
+ if (state) await _deleteState(key);
449
+ try { await lockout.recordSuccess(key, { req: callOpts.req }); }
450
+ catch (_e) { /* best-effort */ }
451
+ _emitObs("auth.bot_challenge.cleared", {});
452
+ _emitAudit("auth.bot_challenge.passed", key, "success",
453
+ { stage: "auth-success", failuresCleared: (state && state.failures) || 0 },
454
+ callOpts.req);
455
+ }
456
+
457
+ async function check(key) {
458
+ _requireKey(key);
459
+ var state = await _readState(key);
460
+ if (!state) return { stage: STATE_NEW, failures: 0 };
461
+ return {
462
+ stage: state.stage,
463
+ failures: state.failures || 0,
464
+ };
465
+ }
466
+
467
+ async function reset(key, callOpts) {
468
+ _requireKey(key);
469
+ callOpts = callOpts || {};
470
+ var state = await _readState(key);
471
+ if (state) await _deleteState(key);
472
+ try { await lockout.unlock(key, { req: callOpts.req, reason: "bot-challenge:reset" }); }
473
+ catch (_e) { /* best-effort */ }
474
+ _emitAudit("auth.bot_challenge.passed", key, "success",
475
+ { stage: "admin-reset", reason: callOpts.reason || null,
476
+ priorStage: state && state.stage || null,
477
+ priorFailures: state && state.failures || 0 },
478
+ callOpts.req);
479
+ return !!state;
480
+ }
481
+
482
+ return {
483
+ middleware: middleware,
484
+ recordFailure: recordFailure,
485
+ recordSuccess: recordSuccess,
486
+ check: check,
487
+ reset: reset,
488
+ };
489
+ }
490
+
491
+ module.exports = {
492
+ create: create,
493
+ AuthBotChallengeError: AuthBotChallengeError,
494
+ STATES: Object.freeze({
495
+ NEW: STATE_NEW,
496
+ CHALLENGED: STATE_CHALLENGED,
497
+ PASSED: STATE_PASSED,
498
+ LOCKED: STATE_LOCKED,
499
+ }),
500
+ DEFAULTS: Object.freeze({
501
+ threshold: DEFAULT_THRESHOLD,
502
+ escalationThreshold: DEFAULT_ESCALATION_THRESHOLD,
503
+ challengeTtlMs: DEFAULT_CHALLENGE_TTL_MS,
504
+ }),
505
+ };