@osqd/bothandlerjs 0.3.0

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 (186) hide show
  1. package/CHANGELOG.md +516 -0
  2. package/LICENSE +102 -0
  3. package/README.md +308 -0
  4. package/bin/bothandlerjs.mjs +14 -0
  5. package/dist/actions/index.d.ts +22 -0
  6. package/dist/actions/types.d.ts +59 -0
  7. package/dist/adapters/fastify.d.ts +43 -0
  8. package/dist/adapters/fetch.d.ts +51 -0
  9. package/dist/adapters/index.cjs +723 -0
  10. package/dist/adapters/index.cjs.map +1 -0
  11. package/dist/adapters/index.d.ts +8 -0
  12. package/dist/adapters/index.js +692 -0
  13. package/dist/adapters/index.js.map +1 -0
  14. package/dist/adapters/koa.d.ts +27 -0
  15. package/dist/adapters/node.d.ts +29 -0
  16. package/dist/adapters/shared.d.ts +39 -0
  17. package/dist/audit.d.ts +172 -0
  18. package/dist/challenge/index.d.ts +176 -0
  19. package/dist/challenge/language.d.ts +57 -0
  20. package/dist/challenge/page.d.ts +42 -0
  21. package/dist/challenge/pow.d.ts +33 -0
  22. package/dist/challenge/token.d.ts +72 -0
  23. package/dist/cli.cjs +13388 -0
  24. package/dist/cli.cjs.map +1 -0
  25. package/dist/cli.d.ts +5 -0
  26. package/dist/cli.js +13358 -0
  27. package/dist/cli.js.map +1 -0
  28. package/dist/client/index.cjs +139 -0
  29. package/dist/client/index.cjs.map +1 -0
  30. package/dist/client/index.d.ts +63 -0
  31. package/dist/client/index.js +112 -0
  32. package/dist/client/index.js.map +1 -0
  33. package/dist/config.d.ts +316 -0
  34. package/dist/core.d.ts +443 -0
  35. package/dist/corpus/adversarial.d.ts +2 -0
  36. package/dist/corpus/advertising-email.d.ts +2 -0
  37. package/dist/corpus/ai-crawlers.d.ts +2 -0
  38. package/dist/corpus/benign-bots.d.ts +2 -0
  39. package/dist/corpus/cdn-gateways.d.ts +2 -0
  40. package/dist/corpus/cookies.d.ts +49 -0
  41. package/dist/corpus/crawlers-regional.d.ts +2 -0
  42. package/dist/corpus/crawlers-vertical.d.ts +2 -0
  43. package/dist/corpus/headers.d.ts +403 -0
  44. package/dist/corpus/humans-apps.d.ts +2 -0
  45. package/dist/corpus/humans-browsers.d.ts +2 -0
  46. package/dist/corpus/humans.d.ts +2 -0
  47. package/dist/corpus/index.cjs +4196 -0
  48. package/dist/corpus/index.cjs.map +1 -0
  49. package/dist/corpus/index.d.ts +37 -0
  50. package/dist/corpus/index.js +4132 -0
  51. package/dist/corpus/index.js.map +1 -0
  52. package/dist/corpus/infrastructure.d.ts +17 -0
  53. package/dist/corpus/libraries-extended.d.ts +2 -0
  54. package/dist/corpus/ranges.d.ts +31 -0
  55. package/dist/corpus/reputation.d.ts +21 -0
  56. package/dist/corpus/runner.d.ts +130 -0
  57. package/dist/corpus/schema.d.ts +185 -0
  58. package/dist/corpus/tooling.d.ts +2 -0
  59. package/dist/corpus/unwanted.d.ts +2 -0
  60. package/dist/crawler-ranges.d.ts +113 -0
  61. package/dist/dashboard/client.generated.d.ts +7 -0
  62. package/dist/dashboard/feed.d.ts +186 -0
  63. package/dist/dashboard/index.d.ts +5 -0
  64. package/dist/dashboard/page.d.ts +51 -0
  65. package/dist/dashboard/parse-request.d.ts +28 -0
  66. package/dist/dashboard/preview.d.ts +45 -0
  67. package/dist/dashboard/server.d.ts +70 -0
  68. package/dist/dashboard/types.d.ts +714 -0
  69. package/dist/detectors/accept-signature.d.ts +6 -0
  70. package/dist/detectors/browsing-coherence.d.ts +44 -0
  71. package/dist/detectors/cadence.d.ts +28 -0
  72. package/dist/detectors/clearance.d.ts +25 -0
  73. package/dist/detectors/client-hints.d.ts +19 -0
  74. package/dist/detectors/client-signals.d.ts +27 -0
  75. package/dist/detectors/crawl-breadth.d.ts +28 -0
  76. package/dist/detectors/crawler-verification.d.ts +39 -0
  77. package/dist/detectors/fetch-metadata.d.ts +20 -0
  78. package/dist/detectors/header-integrity.d.ts +26 -0
  79. package/dist/detectors/header-order.d.ts +27 -0
  80. package/dist/detectors/identity-rotation.d.ts +34 -0
  81. package/dist/detectors/index.d.ts +56 -0
  82. package/dist/detectors/ip-intelligence.d.ts +30 -0
  83. package/dist/detectors/known-bots.d.ts +94 -0
  84. package/dist/detectors/probe-signature.d.ts +50 -0
  85. package/dist/detectors/rate-anomaly.d.ts +36 -0
  86. package/dist/detectors/self-identified.d.ts +33 -0
  87. package/dist/detectors/session-integrity.d.ts +24 -0
  88. package/dist/detectors/tls-fingerprint.d.ts +34 -0
  89. package/dist/detectors/trap.d.ts +98 -0
  90. package/dist/detectors/types.d.ts +70 -0
  91. package/dist/detectors/ua-coherence.d.ts +32 -0
  92. package/dist/evidence.d.ts +45 -0
  93. package/dist/facts.d.ts +46 -0
  94. package/dist/index.cjs +9600 -0
  95. package/dist/index.cjs.map +1 -0
  96. package/dist/index.d.ts +63 -0
  97. package/dist/index.js +9444 -0
  98. package/dist/index.js.map +1 -0
  99. package/dist/internal/async.d.ts +16 -0
  100. package/dist/internal/clock.d.ts +17 -0
  101. package/dist/internal/crypto.d.ts +26 -0
  102. package/dist/internal/dns.d.ts +100 -0
  103. package/dist/internal/emitter.d.ts +18 -0
  104. package/dist/internal/http.d.ts +22 -0
  105. package/dist/internal/ip.d.ts +73 -0
  106. package/dist/internal/lru.d.ts +34 -0
  107. package/dist/internal/matcher.d.ts +41 -0
  108. package/dist/internal/pattern.d.ts +14 -0
  109. package/dist/internal/ua.d.ts +92 -0
  110. package/dist/metrics.d.ts +120 -0
  111. package/dist/notify/hub.d.ts +70 -0
  112. package/dist/notify/index.d.ts +7 -0
  113. package/dist/notify/redact.d.ts +48 -0
  114. package/dist/notify/sinks.d.ts +62 -0
  115. package/dist/notify/types.d.ts +56 -0
  116. package/dist/policy/index.d.ts +7 -0
  117. package/dist/policy/match.d.ts +20 -0
  118. package/dist/policy/policy.d.ts +123 -0
  119. package/dist/policy/presets.d.ts +161 -0
  120. package/dist/policy/types.d.ts +155 -0
  121. package/dist/robots.d.ts +83 -0
  122. package/dist/state.d.ts +161 -0
  123. package/dist/stores/index.d.ts +5 -0
  124. package/dist/stores/memory.d.ts +33 -0
  125. package/dist/stores/redis.d.ts +59 -0
  126. package/dist/stores/types.d.ts +37 -0
  127. package/dist/types.d.ts +250 -0
  128. package/docs/challenge/index.md +152 -0
  129. package/docs/challenge/localisation.md +105 -0
  130. package/docs/concepts/actors.md +113 -0
  131. package/docs/concepts/evidence.md +133 -0
  132. package/docs/concepts/the-guard.md +121 -0
  133. package/docs/concepts/threat-model.md +120 -0
  134. package/docs/concepts/verdicts.md +108 -0
  135. package/docs/course/01-first-assessment.md +178 -0
  136. package/docs/course/02-proof-and-suspicion.md +164 -0
  137. package/docs/course/03-verdicts-and-scores.md +160 -0
  138. package/docs/course/04-the-guard.md +200 -0
  139. package/docs/course/05-detectors.md +178 -0
  140. package/docs/course/06-identity.md +161 -0
  141. package/docs/course/07-actors.md +178 -0
  142. package/docs/course/08-traps.md +183 -0
  143. package/docs/course/09-rules.md +224 -0
  144. package/docs/course/10-actions-and-presets.md +216 -0
  145. package/docs/course/11-the-challenge.md +227 -0
  146. package/docs/course/12-going-live.md +211 -0
  147. package/docs/course/13-operating-it.md +238 -0
  148. package/docs/course/14-scaling.md +195 -0
  149. package/docs/course/15-extending.md +259 -0
  150. package/docs/course/16-proving-it.md +266 -0
  151. package/docs/course/index.md +112 -0
  152. package/docs/design/decisions.md +216 -0
  153. package/docs/detection/client-signals.md +96 -0
  154. package/docs/detection/detectors.md +376 -0
  155. package/docs/detection/index.md +134 -0
  156. package/docs/detection/signatures.md +111 -0
  157. package/docs/detection/verification.md +123 -0
  158. package/docs/detection/writing-a-detector.md +145 -0
  159. package/docs/index.md +100 -0
  160. package/docs/integration/adapters.md +138 -0
  161. package/docs/integration/client-ip.md +114 -0
  162. package/docs/integration/index.md +55 -0
  163. package/docs/integration/stores.md +76 -0
  164. package/docs/operations/audit.md +116 -0
  165. package/docs/operations/dashboard.md +725 -0
  166. package/docs/operations/index.md +101 -0
  167. package/docs/operations/metrics.md +74 -0
  168. package/docs/operations/notifications.md +72 -0
  169. package/docs/operations/runtime-changes.md +118 -0
  170. package/docs/policy/actions.md +196 -0
  171. package/docs/policy/index.md +84 -0
  172. package/docs/policy/presets.md +191 -0
  173. package/docs/policy/robots.md +128 -0
  174. package/docs/policy/rules.md +134 -0
  175. package/docs/reference/api.md +126 -0
  176. package/docs/reference/configuration.md +175 -0
  177. package/docs/start/choosing-a-policy.md +127 -0
  178. package/docs/start/first-integration.md +127 -0
  179. package/docs/start/installation.md +63 -0
  180. package/docs/start/upgrading.md +72 -0
  181. package/docs/testing/cli.md +119 -0
  182. package/docs/testing/corpus.md +129 -0
  183. package/docs/testing/index.md +61 -0
  184. package/docs/testing/replay.md +102 -0
  185. package/docs/testing/try-it.md +189 -0
  186. package/package.json +145 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,516 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
+ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ Nothing yet.
10
+
11
+ ## [0.2.0]
12
+
13
+ ### Security
14
+
15
+ - **The dashboard accepted state-changing requests from other people's pages.**
16
+ `POST /api/policy/apply` and `POST /api/reset` checked credentials and nothing
17
+ else, and a browser attaches credentials to a cross-site request as willingly
18
+ as to a real one. Because `text/plain` is a CORS "simple request", a plain HTML
19
+ form on any page an operator visited could swap the running bot policy — to
20
+ `monitor-only`, disabling blocking site-wide — or wipe the actor registry,
21
+ with no preflight and no warning. Three locks now, checked independently:
22
+
23
+ - `Sec-Fetch-Site` must be `same-origin` or `none` when the browser sends it,
24
+ and `Origin`, when present without it, must name this same server. Requests
25
+ with no browser provenance at all — curl, a deploy script — still pass.
26
+ - Request bodies must be `application/json`. An HTML form cannot send that
27
+ media type, and anything that can must ask permission first through a
28
+ preflight this server answers for nobody.
29
+ - Reads are deliberately untouched: no `Access-Control-Allow-Origin` is sent,
30
+ so a cross-origin reader never sees the response it fetched.
31
+
32
+ - **The dashboard answered to any `Host`, which left DNS rebinding open.** A
33
+ name the attacker controls, pointed at 127.0.0.1, is *same-origin* by the
34
+ browser's reckoning, so every check above agrees with it. On a loopback bind
35
+ the `Host` header is now checked against the names a dashboard is actually
36
+ opened under (`localhost`, `127.0.0.1`, `[::1]`) plus anything listed in the
37
+ new `allowedHosts` option; unrecognised names get a 421. A public bind is
38
+ unaffected — rebinding wins nothing against an address the attacker can reach
39
+ directly, and enforcing a list there would break every reverse proxy.
40
+
41
+ - **`X-Forwarded-For` was believed even from a peer that is not one of your
42
+ proxies.** With `trustedProxies` configured, the header chain was walked but
43
+ the connecting peer never was, so a request that reached the origin *without*
44
+ passing through the load balancer — a leaked origin address, a directly
45
+ reachable pod — could name its own client IP, and with it its own actor key,
46
+ rate-limit bucket and IP reputation. The peer is now the first hop and is
47
+ checked like any other: a request from outside the trusted ranges falls back
48
+ to the socket address. This is what `proxy-addr` has always done and what
49
+ `trustedProxies` was there to promise.
50
+
51
+ ### Fixed
52
+
53
+ - **The fetch adapter did not fail open.** An error inside the engine escaped
54
+ `createFetchAdapter` to the runtime, and Workers, Deno and Bun all answer 500
55
+ — a detection bug charged to the visitor, which is the one thing every
56
+ adapter's doc comment promises not to do. It now reports through `onError` and
57
+ serves the request, as the node, Koa and Fastify adapters already did.
58
+
59
+ - **123 build-tool packages were declared as runtime dependencies.** `vite`,
60
+ `rollup`, `esbuild`, `chai`, `express` and the rest of the flattened dev tree
61
+ were listed under `dependencies`, so `npm install @osqd/bothandlerjs` pulled roughly
62
+ 134 MB into somebody else's project and handed them an esbuild advisory
63
+ (GHSA-g7r4-m6w7-qqqr) as a *production* vulnerability in a library that does
64
+ not use esbuild. The library imports nothing but `node:` builtins; the
65
+ dependency list now says so, and CI fails if that stops being true.
66
+
67
+ ### Added
68
+
69
+ - **`minUnsolvedChallenges` — a rule that reads challenges nobody answered.**
70
+ `ActorState.unsolvedChallenges` was declared and never written or read; it counts now,
71
+ rising with each challenge issued and clearing the moment one is solved. Outstanding
72
+ rather than cumulative, so it never accumulates against somebody who came back and
73
+ proved it.
74
+
75
+ A rule rather than evidence, on purpose. One abandoned challenge is a person — a slow
76
+ phone, a lost tab, a change of mind — and only repetition means anything, which is a
77
+ judgement about traffic the library cannot see. The engine counts; your policy decides.
78
+ Shown on the Actors screen and offered in the dashboard's rule editor.
79
+
80
+ - **The challenge interstitial can be written in the visitor's language.**
81
+ `challenge.translations`, keyed by language tag, chosen from `Accept-Language` with
82
+ `q` honoured and a region falling back to its primary tag — and the document's `lang`
83
+ attribute set to match, which is what decides the voice a screen reader reads it in.
84
+
85
+ This is the only page the library shows to a member of the public, and they see it
86
+ because a *probabilistic* verdict went against them: somebody who cannot read it
87
+ cannot find the contact link on it either. The library ships no translations and will
88
+ not — a machine-translated apology on a page that just turned somebody away is worse
89
+ than an honest English one.
90
+
91
+ - **A performance ratchet, in CI.** There was a coverage ratchet and nothing guarding the
92
+ hot path — the code every user of this library runs on every request — so a detector
93
+ that got ten times slower would have shipped in silence. `npm run bench:guard` budgets
94
+ `assess`, `handle` and `createFacts` as **ratios against a reference loop measured in
95
+ the same process**, because a baseline in microseconds is a statement about the machine
96
+ that produced it and fails on a CI runner for reasons unrelated to the code.
97
+
98
+ - **Published crawler ranges — `startCrawlerRangeRefresh()`.** Twelve shipped signatures
99
+ verify by address rather than by reverse DNS, every AI crawler among them, and nothing
100
+ in the library ever filled those ranges in: `updateCrawlerRanges()` was a method
101
+ waiting for a caller. The library still ships **no address data** — a range baked into
102
+ a release is wrong by the time somebody installs it — it ships the URL each operator
103
+ publishes, so the answer comes from the party entitled to give it.
104
+
105
+ Opt-in, because it makes outbound requests. Fails open per source: a publisher that is
106
+ down, has moved its file or serves something unrecognisable leaves every other
107
+ crawler's ranges as they were and that crawler's ranges as they were too. Two things
108
+ are refused outright, because these ranges do not describe a crawler but **verify**
109
+ one: a list containing a block bigger than any crawler owns, and an empty list.
110
+
111
+ - **`shareConfirmations` — proof crosses replicas; suspicion does not.** Behavioural
112
+ state is process-local by design, because a round trip per request would buy accuracy
113
+ for signals that may only raise suspicion. A confirmation is not one of those:
114
+ `confirmed-bot` is a *proven* verdict, and without sharing it a client proven to be a
115
+ bot on one replica was a stranger to the other seven — so `minPriorConfirmations: 1`
116
+ fired about an eighth as often as it read. The cost is one store read the first time
117
+ each instance sees an actor, never awaited, so nothing joins the request path.
118
+
119
+ - **An audit check for the challenge solve rate.** The audit watched bot share, traffic,
120
+ denials, guard stops, human share and detector failures — and not the one number that
121
+ says *you are challenging people*. A proof-of-work challenge is trivial for a browser
122
+ and trivial for a competent scraper; what it costs is a few seconds of somebody's
123
+ afternoon. So a solve rate near one does not mean the challenges are working, it means
124
+ they are mostly landing on people — and from every other angle a solved challenge
125
+ looks like a challenge that worked, which is why it needed saying. `AuditWindow` gains
126
+ `challengesSolved` and `challengeSolveRate`.
127
+
128
+ - **`bothandlerjs check` — your policy against 526 shapes of real traffic.** The question
129
+ the library is organised around, asked offline and before a deploy: *if I point this
130
+ configuration at the actual internet, who gets hurt?* It exits non-zero if any case
131
+ marked `human` is denied service, which is what makes it a CI step rather than a
132
+ report. DNS is controlled and the clock is manual, so it is reproducible and offline.
133
+
134
+ - **The corpus is a published entry point — `@osqd/bothandlerjs/corpus`.** Its own schema has
135
+ always said "point the runner at *your* `BotHandler`", and until now only this
136
+ repository could. It is a separate entry, so importing the library never loads a case
137
+ of it.
138
+
139
+ - **`bothandlerjs explain`** — the dashboard's request tester as a command. A User-Agent,
140
+ a `curl` command or a header block in; the verdict, the rule and every piece of
141
+ evidence out, as a dry run that records nothing.
142
+
143
+ - **Hono and Next.js, in an example and in tests.** Neither needs an adapter — both speak
144
+ `Request` and `Response`, which is what `createFetchAdapter` takes — but "it probably
145
+ works with X" is how a framework ends up unsupported by accident.
146
+
147
+ - `BotHandler.warn()` is public, so an adapter or the range refresher can raise a warning
148
+ through the handler's own channel.
149
+
150
+ - **`createDashboardHandler()` — the dashboard as a request handler, for a server you
151
+ already have.** `startDashboard` opens a plain HTTP listener of its own, which is
152
+ right on a laptop and wrong in most production networks: the certificate lives at an
153
+ ingress, everything has to be reachable under one hostname, or the platform exposes
154
+ exactly one port. This is the same dashboard without the socket, and `startDashboard`
155
+ is now a thin wrapper over it.
156
+
157
+ `auth` is required in this form, including the explicit `auth: false`: the listening
158
+ form may skip it on `127.0.0.1` because the operating system is then the access
159
+ control, and mounted there is no bind address to inspect, so nothing can be assumed.
160
+ `close()` unsubscribes and ends the streams without closing a server it does not own.
161
+ Routing accepts the path with or without `basePath` in front of it, so it works
162
+ whether or not the surrounding router strips the mount point. See
163
+ `examples/dashboard-mounted.ts`.
164
+
165
+ - **A wrong password is now slowed down.** Both halves of a basic credential were
166
+ compared in constant time, which defeats a timing attack and does nothing about the
167
+ obvious one — trying again. After five failures from an address the next attempt is
168
+ refused for a delay that doubles each time, up to five minutes; a success clears it.
169
+ Configured with `authThrottle`, and silent under a silent `refusal`, because a `429`
170
+ would tell a prober there is a credential here worth guessing.
171
+
172
+ - **`allowedClients` — which addresses may reach the dashboard at all.** The layer
173
+ `allowedHosts` is not: that checks the name in the `Host` header, this checks who is
174
+ connecting. It is what "bind `0.0.0.0`, but only the VPN can reach it" means, it is
175
+ checked before authentication, and it is a layer on top of `auth` rather than a
176
+ replacement — an address is not a person.
177
+
178
+ - **`feedTtlMs` — how long a request may stay in the feed. Default one hour.**
179
+ `feedLimit` is a capacity bound and this is a retention promise, which is a different
180
+ question: on a quiet service five hundred requests can be a fortnight, and every entry
181
+ holds somebody's address, User-Agent and header set. Eviction runs on the dashboard's
182
+ own timer as well as on arrival, so it holds on an idle process — the only kind where
183
+ it matters.
184
+
185
+ - **A Changes panel**, listing what was applied at runtime, when and by whom — the same
186
+ list the traffic timeline marks, written out, because a marker answers "was there a
187
+ change here?" and an audit wants "what were they, in order". `sections.changes`.
188
+
189
+ - **`peers` — sibling instances in the header.** The honest amount of help a page can
190
+ give with a fleet: a way to reach the other ones. It aggregates nothing, and the
191
+ counters that genuinely want aggregating already go to Prometheus.
192
+
193
+ - **Text alternatives for all three charts.** `role="img"` with a name says a picture is
194
+ here and what it is called, and nothing about what is in it — so every number on the
195
+ Statistics screen was unreachable to a reader who cannot see the bars. Each chart now
196
+ carries its totals, its bands and its markers in words.
197
+
198
+ - **An axe pass over all four screens, in both themes, at two widths**, failing on
199
+ anything it reports at any severity; a contrast matrix that measures every ink the page
200
+ can paint against every surface it can appear on; and a test that drives the dashboard
201
+ under sustained load and asserts every structure it owns stays bounded.
202
+
203
+ - **The challenge interstitial is in the browser suite too.** It is the only page in this
204
+ library a member of the public sees — somebody who was going about their day and
205
+ tripped a probabilistic verdict — so it is now audited in both themes, checked for
206
+ sideways scroll on a phone, and checked that its proof of work actually runs under the
207
+ strict CSP it is served with. A challenge that cannot start is a denial of service to
208
+ the exact population the guard exists to protect. It was clean, and now it stays that
209
+ way.
210
+
211
+ - **Acting on one client, not just reading about one — `controls.editRanges`.** Off by
212
+ default. It puts three operations on the actor drill-down and on every row of the new
213
+ Actors screen: allowlist the address, forget that actor's behavioural memory, or grant
214
+ it human clearance for an hour. All three existed in the engine and were reachable
215
+ from nothing.
216
+
217
+ **Forget** is the reason the flag exists. A person whose actor key collected a
218
+ `confirmed-bot` — a shared office address, a phone that reused an IP — carried
219
+ `priorConfirmations` for the rest of the window, and the only cure was Reset: throwing
220
+ away every actor's history to fix one person's. **Allowlist** is the consequential one
221
+ and asks twice, with the second button naming the address it is about to exempt,
222
+ because an allowlisted address is not judged leniently — it is not judged at all. The
223
+ whole control is unavailable under `redact.maskIp`, where the key on screen names a
224
+ `/24` and the registry is keyed by the address.
225
+
226
+ - **An Actors screen.** Everyone the *registry* is holding, busiest first — requests,
227
+ requests per minute, distinct paths, cadence regularity, prior confirmations,
228
+ clearance. The feed's ring holds a few hundred *requests*, which on a busy origin is a
229
+ few seconds; the registry holds up to `maxActors` *clients* with the history that
230
+ `cadence`, `crawl-breadth` and `rate-anomaly` are reading. "Who is hitting me hardest
231
+ right now" had nowhere to be asked. **In feed** sends one to the live feed as an
232
+ `actor:` filter, so it is a shareable URL like every other view.
233
+
234
+ - **A request tester.** Paste a User-Agent, a `curl` command out of devtools or a raw
235
+ header block, and see the verdict, the evidence and the rule that would fire — without
236
+ waiting for that client to come back, and without the investigation showing up in the
237
+ thing being investigated.
238
+
239
+ - **`assess(facts, { record: false })` — a dry run.** Every detector runs and the verdict
240
+ is real; no counter moves, no actor state changes, no `assessment` event fires, no
241
+ notification is sent. What backs the tester, and what to reach for anywhere else you
242
+ want the engine's opinion about a request that is not happening. It has no history by
243
+ construction, so what it answers is "what would this look like as a first request".
244
+
245
+ - **Changes are attributed.** `auth: { authorize }` may return a **string** — the
246
+ viewer's name — instead of `true`, and basic auth supplies one without being asked.
247
+ Every mutating method now takes a `{ by }`, and the name travels into the handler's
248
+ `warning`, into the change event, and onto the timeline marker. The library still has
249
+ no user model: it carries the name it was given.
250
+
251
+ - **Runtime changes are marked on the traffic timeline**, with what changed and who did
252
+ it. A preview says "44 of 151 requests would be treated differently"; the marker is
253
+ what lets you check whether it held.
254
+
255
+ - **`maxEventsPerSecond` on `DashboardOptions`** (default 100, `0` to disable). Every
256
+ assessment used to go to every open browser, so a thousand requests a second was a
257
+ couple of megabytes a second per viewer with Pause as the only lever. The cap is on the
258
+ *stream* — the ring keeps everything, so the preview, the export and anyone
259
+ reconnecting are unaffected — and the feed says how many were not streamed, because a
260
+ thinned feed must never look like a quiet one.
261
+
262
+ - **`instance` on `DashboardOptions`**, defaulting to the hostname and shown in the
263
+ header. A dashboard reports on one process; behind a load balancer with eight pods you
264
+ are looking at an eighth of your traffic. Naming the instance does not aggregate
265
+ anything, it stops a partial picture from looking like a whole one.
266
+
267
+ - `range-change` and `actor-change` events, with `onRangeChange` and `onActorChange`.
268
+ - `BotHandler.forgetActor()`, `clearActor()`, `rangeEntries()`, `ActorRegistry.top()` and
269
+ `IpRangeSet.entries()` — the engine surface behind all of the above. A range set could
270
+ report how many entries it held and not which.
271
+ - `sections.registry`, `sections.tester` and `sections.ranges`.
272
+
273
+ - **`sections` on `DashboardOptions` — what a listener shows, as opposed to what it
274
+ lets you do.** `feed`, `evidence`, `actors`, `statistics`, `audit`, `notices`,
275
+ `policy`, `guard` and `robots`, all on by default. Switching one off removes it from
276
+ the page *and* from the server: the tab is gone, the endpoint behind it answers 403,
277
+ and the fields it would have shown are dropped before they leave the process — so a
278
+ viewer with devtools open sees exactly what the page sees. `evidence` is the one to
279
+ think about before sharing a dashboard widely: it names which detector fired and why,
280
+ which is a tuning guide for whoever is scraping you.
281
+
282
+ There is still no role model in here, and that is still deliberate. `controls` says
283
+ what a viewer may do, `sections` says what a viewer may see, both are fixed when the
284
+ listener starts, and roles are yours: one listener per role, your own `authorize`
285
+ predicate in front of each. `npm run demo:roles` is now three of them.
286
+
287
+ - **`controls.editGuard` — the guard, changeable from the dashboard, behind its own
288
+ flag.** Off by default. With it on, the Guard panel becomes a form over
289
+ `falsePositivePolicy`, `fallbackAction`, `defaultAction`, `terminalScoreThreshold`
290
+ and `suspectThreshold`, applied to the running handler through a new
291
+ `POST /api/guard`.
292
+
293
+ It is a *separate* flag from `editPolicy` because they are different powers. A rule
294
+ editor can only write a rule that overreaches, and the guard stops it; this changes
295
+ whether anything stops it. Two settings stay impossible whatever the flag says: a
296
+ terminal `fallbackAction`, which would make every downgrade deny the request the
297
+ downgrade existed to protect while still recording it as a guard stop, and a
298
+ `terminalScoreThreshold` outside 1–100. Changes are validated before anything moves,
299
+ applied whole or not at all, previewable against the traffic in the window, and
300
+ announced through both `warning` and a new `guard-change` event carrying the before
301
+ and the after.
302
+
303
+ - `BotHandler.updateGuard()`, `Policy.replaceGuard()`, `Policy.describeGuard()` and the
304
+ `GuardSettings` type — the API behind the above, usable without a dashboard.
305
+ - `onGuardChange` on `BotHandlerOptions`, and `guard-change` on `BotHandlerEvents`.
306
+ - **A score histogram in the metrics.** `scores` on `MetricsSnapshot` and
307
+ `bothandler_score_bucket` in the Prometheus output: how suspicion is distributed
308
+ across everything that was scored, in ten buckets of ten points, since the process
309
+ started. Proven assessments are excluded — their score is 100 by definition and
310
+ decides nothing.
311
+ - **The dashboard's live feed can be searched by field.** `actor:203.0.113.4`,
312
+ `-path:/health`, `rule:no-scrapers`, `score:>70`, `"a quoted phrase"`; every term
313
+ must match. Anything that is not a field term still matches the whole request, so a
314
+ plain word behaves as it always did.
315
+ - **"Draft a rule" on any feed row.** Turns the request you are looking at into a rule
316
+ in the policy editor, matched on the strongest thing it actually proves — a verified
317
+ identity, else the detectors whose evidence was proven, else the verdict with a score
318
+ floor — appended last, previewed immediately, applied never. The action is always
319
+ `tag`: a drafted rule has been reviewed by nobody, and a dashboard choosing a terminal
320
+ action for a request that annoyed you is the reflex this library exists to interrupt.
321
+ - **Export the window.** The feed toolbar downloads every request matching the current
322
+ filter as replay JSONL — the bulk form of the per-row buttons.
323
+ - **The filter and the search are in the URL**, alongside the view, so a screen is a
324
+ link rather than a set of instructions and a refresh keeps your place.
325
+ - A time column on the feed, and a scope switch on the score distribution.
326
+
327
+ - `allowedHosts` on `DashboardOptions` — extra `Host` values a loopback
328
+ dashboard will answer to, for reaching it through a name of your own. `"*"`
329
+ disables the check.
330
+ - `clock` on `RedisStoreOptions`. The rate-limit window came from `Date.now()`
331
+ while every other stateful piece of the library takes an injectable clock,
332
+ which is why the window boundary could not be tested.
333
+ - `npm run demo:roles` — a second demo showing the dashboard behind roles: a signed
334
+ session cookie, an operator console to pick a role at, a read-only viewer dashboard
335
+ for analysts, and an admin dashboard with the editor and reset. It exists to make
336
+ three things visible before someone builds them wrong: a custom `authorize` sends no
337
+ `WWW-Authenticate`, cookies are scoped by host rather than by origin, and a session
338
+ cookie is not what stops a forged write.
339
+
340
+ - `refusal` on `DashboardOptions` — what a caller the dashboard will not serve is
341
+ told: `"unauthorized"` (401, the default), `"not-found"` (404, byte-identical to
342
+ an unknown path), `"close"` (drop the connection), or `{ redirect, status? }`.
343
+ Anything but the default also collapses the three pre-routing refusals — failed
344
+ auth, wrong `Host`, cross-site write — into one answer, so a probe cannot tell
345
+ which check turned it away. It is concealment rather than access control, and
346
+ combining a silent refusal with basic auth is refused at startup: a browser
347
+ prompts for a password only when a 401 asks it to.
348
+ - The dashboard page is navigable by keyboard. The tab strip now honours the
349
+ keyboard contract its `role="tablist"` was already promising (arrow keys,
350
+ Home/End, a roving tabindex), feed rows are focusable controls that open on Enter
351
+ or Space rather than click alone, `/` jumps to the filter, `1`–`3` switch views,
352
+ Escape backs out of whatever is open, and there is a skip link past the header.
353
+ - The current view is in the URL, so a screen can be linked to, the back button
354
+ moves between views instead of leaving the page, and a refresh keeps your place.
355
+
356
+ ### Changed (the dashboard)
357
+
358
+ - **The live feed updates in place instead of being redrawn.** It used to empty the
359
+ table and rebuild up to three hundred rows on every frame that carried a request. The
360
+ expensive part was not the visible problem: a rebuilt row takes your text selection
361
+ with it, so copying a User-Agent out of a live feed was impossible without pressing
362
+ Pause first. Rows are now keyed by request id and rebuilt only when the entry behind
363
+ them changed.
364
+ - **Only the visible screen is drawn.** Every incoming request used to rebuild eight
365
+ statistics panels into a hidden tab, at the frame rate, on top of the feed.
366
+ - **The Statistics screen says which window each panel is counting.** Half of it counts
367
+ the retained ring and half counts since the process started — different populations
368
+ wearing the same grey subtitle, on the screen people read before moving a threshold.
369
+ Window-scoped panels now say how much window there is ("last 500 requests · 4 min"),
370
+ and the score distribution defaults to the whole run, drawn from the same counters the
371
+ Prometheus endpoint exposes, with the window one click away.
372
+ - **A reconnecting viewer is sent what it missed, not the whole ring.** Every frame
373
+ carries an id and the server honours `Last-Event-ID`, so a laptop lid or a proxy
374
+ timeout costs a handful of frames rather than five hundred entries with their headers,
375
+ evidence and actor history attached. A cursor the server cannot honour gets the
376
+ backlog and an instruction to replace what the page holds.
377
+ - **One stats timer for every viewer** rather than one per viewer: sixteen browsers on
378
+ the same dashboard meant sixteen identical snapshot walks every two seconds.
379
+ - **Reset tells every viewer**, not just the browser that pressed it.
380
+ - **The dashboard's browser code is a real TypeScript module** under
381
+ `src/dashboard/client/`, type-checked, linted, unit-tested and bundled into the page
382
+ at build time by `npm run client:build`. It used to be two thousand lines of
383
+ JavaScript inside a template literal, which is how a call to a function nobody had
384
+ written shipped and blanked the whole Statistics tab: the type-checker could not see
385
+ it, Biome globs `*.ts` and this was a string, and no test could import a function out
386
+ of it. The pure parts — the search, the outcome classification, the rule drafting, the
387
+ replay formats — now have unit tests that run without a browser.
388
+ - **The header no longer dresses status as controls.** Five bordered pills carrying
389
+ configuration facts sat beside five identical-looking pills that were real buttons,
390
+ which is not a toolbar but a guessing game. The facts moved to the tab strip's
391
+ empty right-hand side as plain text, label first and value second — "suspect at
392
+ 60", not "60 suspect at" — the links and actions are grouped and divided, and
393
+ Reset, which discards the actor registry, is finally styled as the destructive
394
+ action it is.
395
+
396
+ ### Fixed (dashboard page)
397
+
398
+ - **The Statistics tab threw on every render.** `draw()` called `drawAudit()`, which
399
+ was never written — so the Audit panel had never once rendered, and the
400
+ `ReferenceError` aborted `drawStats()` on the very next line, leaving the panels
401
+ below it empty too. Implemented: the audit's window and its baseline side by side,
402
+ because a bot share of 60% is a number and 60% against a baseline of 12% is an
403
+ incident. Found by the browser suite on its first run.
404
+
405
+ - **The sticky column headers never stuck.** Two faults on top of each other. They
406
+ were pinned to a hard-coded `54px` — the top row alone, with the tab strip
407
+ unaccounted for — and the panel around them was `overflow: hidden`, which makes it
408
+ a scroll container and therefore the containing block for anything sticky inside
409
+ it. The offset is measured at runtime now, and the panel clips instead of hiding.
410
+
411
+ - **The header overlapped itself on a narrow window.** Its top row was a fixed 54px
412
+ with children that refused to shrink, so below about 900px the title and the live
413
+ indicator drew on top of one another. It wraps now, and nothing in the header has a
414
+ fixed height.
415
+
416
+ - **The feed table was amputated below about 900px** — the panel simply clipped the
417
+ right-hand columns. The duration column now goes first, and below that the table
418
+ scrolls inside its own box rather than losing data. The scroll container is scoped
419
+ to those widths on purpose: it would otherwise become the containing block for the
420
+ sticky column headers and cost them on every screen.
421
+
422
+ - **The skip link was not reachable by Tab.** Parked at `top: -60px`, its whole
423
+ border box sat outside the viewport, and sequential focus navigation drops those —
424
+ so the one control that exists purely for keyboard users was the one control a
425
+ keyboard could not get to. Clipped rather than moved now.
426
+
427
+ - **A slow viewer could grow this process without limit.** `response.write()` returns
428
+ false when the socket's buffer is full and nothing looked at it, so the frames for a
429
+ viewer that had stopped reading — a laptop that slept with its tab open, a phone in a
430
+ tunnel, a proxy that stopped draining — accumulated in memory, one queue per viewer.
431
+ `maxEventsPerSecond` does not help: it bounds a rate and this is a backlog. A stream
432
+ that reports itself full is now sent no feed entries until it drains, is told how many
433
+ it missed when it catches up, and is ended after twenty seconds of never draining —
434
+ after which the browser reconnects and resumes from its cursor.
435
+
436
+ - **Accessibility defects nobody had noticed by looking**, found by the new axe pass and
437
+ then by widening it. Solid buttons put white text on the series blue at about 3.9:1,
438
+ under the 4.5 floor — they have a darker accent of their own now, and the charts keep
439
+ the validated series colour. Feed rows were `role="button"` with the actor link inside
440
+ them, which is a control nested in a control; each row's method-and-path is a real
441
+ disclosure button now, and clicking anywhere in the row still opens it.
442
+
443
+ The first version of that pass ran in light mode at one width and passed, while the
444
+ dark palette had a serious contrast failure on every piece of weak evidence. Widened
445
+ to both themes and both widths, it also found that the page had **no `h1` at all** —
446
+ "jump to the heading" had nothing to jump to — and an unlabelled column header. Four
447
+ more inks failed once measured in the contexts they actually appear in: a suspected
448
+ badge is an amber ink on a 16%-amber chip on a row that may itself be tinted, which is
449
+ three surfaces deep and passes at every stage but the last. The amber and the muted
450
+ inks are darker now, and a test measures **every** badge, action, tier and caption
451
+ against **every** surface it can land on, in both themes — because axe can only audit
452
+ the badges the traffic happened to produce that afternoon.
453
+
454
+ - **A half-pressed confirmation could be reset under the operator's cursor.** The Actors
455
+ screen refreshes every couple of seconds and a refresh rebuilds its buttons, so
456
+ somebody who clicked "Allowlist", read the address it offered back and reached for the
457
+ second click could find the second click had merely armed it again. Which teaches
458
+ people that the way through a confirmation is to click it twice, quickly — the exact
459
+ habit a confirmation exists to prevent. The list holds still while somebody is
460
+ deciding.
461
+
462
+ - **The guard form's labels were not attached to anything.** Four controls with visible
463
+ captions and no association, which reads as "edit, blank" to a screen reader. Both
464
+ form builders use one helper now that either points the label at its control or gives
465
+ the group an accessible name.
466
+
467
+ - **Warnings raised outside the engine never reached `warning` subscribers.** The fetch
468
+ adapter's "I cannot determine a client address" — the one that means every visitor
469
+ collapses into a single actor — called `config.onWarning` directly, which reaches the
470
+ callback and not the event. So it never appeared in the dashboard's notices, where an
471
+ operator would actually see it. Both paths go through `warn()` now.
472
+
473
+ - **A CLI flag's value could be read as the request.** `bothandlerjs explain --json
474
+ curl/8.4.0` treated the User-Agent as the value of `--json`, found no positional
475
+ argument, and sat waiting on a pipe nobody was writing to — a command that looks hung,
476
+ and in a script is. Flags that take a value are named explicitly now, and only an
477
+ *absent* argument reads stdin.
478
+
479
+ - **Every warning reached `onWarning` twice.** `warn()` emits the `warning` event *and*
480
+ calls `config.onWarning`, and `onWarning` was also registered as a listener for that
481
+ event — so the one channel most likely to be wired to a pager was the one that
482
+ double-fired. Found while adding attribution to the runtime-change warnings.
483
+
484
+ - **A User-Agent beginning with `curl` was read as a curl command.** In the new request
485
+ tester, `curl/8.4.0` — the single most likely thing anybody pastes, since it is what
486
+ the feed shows next to the request they came to ask about — parsed as a command with
487
+ no arguments, producing a request with no User-Agent and a verdict of "unknown". Which
488
+ is the most misleading answer available, because it looks like an answer.
489
+
490
+ - **The feed table was still being clipped, on most desktops.** The breakpoints that
491
+ drop a column when it will not fit were media queries, and the box the table has to
492
+ fit inside is the panel — one column of a two-column grid, about 880px wide at a
493
+ 1400px viewport. So the 980px breakpoint never fired where it was needed and the
494
+ right-hand columns were simply not drawn. They are container queries now, measuring
495
+ the panel, and a test walks ten widths asserting the table either fits or scrolls and
496
+ is never clipped.
497
+
498
+ - **A title or link label containing `$&` would have rewritten the page around it.**
499
+ The page template used string replacements, and `String.prototype.replace` reads
500
+ `$&`, `` $` `` and `$'` out of a string replacement and substitutes match context for
501
+ them. Function replacements throughout now.
502
+
503
+ ### Changed
504
+
505
+ - Test coverage of the adapters went from 47% to 94%. They are the only code
506
+ here that every user runs, and the Fastify adapter had never executed in a
507
+ test at all.
508
+ - Linting with Biome (`npm run lint`), matching the configuration used across
509
+ the sibling projects, and a coverage ratchet (`npm run test:coverage`).
510
+ - CI now lints, runs on Node 24 as well as 20 and 22, and checks the shape of
511
+ the published package: zero runtime dependencies, no non-`node:` runtime
512
+ imports, no advisories reaching a consumer, and a bounded tarball.
513
+
514
+ ## [0.1.0]
515
+
516
+ - Initial release.
package/LICENSE ADDED
@@ -0,0 +1,102 @@
1
+ OSQD Non-Resale License, Version 1.0
2
+
3
+ Copyright (c) 2026 Michał Płatosz
4
+
5
+ Permission is hereby granted, free of charge, to any person or organization
6
+ obtaining a copy of this software and associated documentation files (the
7
+ "Software"), to use, copy, modify, merge, publish, and distribute the Software
8
+ and to create and distribute Derivative Works, subject to the conditions below.
9
+
10
+ "Derivative Work" means any work based on the Software, including a modified
11
+ copy, a translation, or a work that incorporates any substantial portion of it.
12
+
13
+ "You" means the person or organization exercising the permissions granted here.
14
+
15
+
16
+ 1. NOTICE
17
+
18
+ You must include the above copyright notice and this entire License in all
19
+ copies or substantial portions of the Software and in every Derivative Work
20
+ you distribute, whether or not you have modified it.
21
+
22
+ You must not remove, obscure, or alter any copyright, attribution, or license
23
+ notice contained in the Software.
24
+
25
+
26
+ 2. NO SALE
27
+
28
+ You must not Sell the Software.
29
+
30
+ "Sell" means charging any fee, of any kind, where the Software or a Derivative
31
+ Work is what is being paid for. This includes, without limitation:
32
+
33
+ (a) distributing copies of the Software or a Derivative Work for a fee;
34
+
35
+ (b) offering the Software or a Derivative Work to third parties as a hosted,
36
+ managed, or software-as-a-service product for a fee;
37
+
38
+ (c) including the Software or a Derivative Work in any product or service you
39
+ charge for, where the Software forms a substantial part of the value you
40
+ are offering; or
41
+
42
+ (d) sublicensing or relicensing the Software for a fee.
43
+
44
+
45
+ 3. WHAT IS EXPLICITLY PERMITTED
46
+
47
+ For the avoidance of doubt, none of the following is Selling, and all of it is
48
+ permitted, including by a commercial business and in production:
49
+
50
+ (a) using the Software, or a Derivative Work of it, to operate, monitor, or
51
+ protect your own systems — including the systems of the business you work
52
+ for, and including systems you charge your own customers to access, so
53
+ long as the Software itself is not part of what they are paying for;
54
+
55
+ (b) charging for your own professional services — consulting, integration,
56
+ deployment, operation, or support — provided the fee is for your services
57
+ and not for the Software, and you supply any copy of the Software to the
58
+ customer under this License at no charge;
59
+
60
+ (c) modifying the Software for any purpose, internal or otherwise, and keeping
61
+ those modifications private;
62
+
63
+ (d) distributing the Software or a Derivative Work at no charge; and
64
+
65
+ (e) using the Software for personal, educational, academic, research, or
66
+ non-profit purposes of any kind.
67
+
68
+
69
+ 4. SEPARATE COMMERCIAL TERMS
70
+
71
+ If you want to do something this License does not permit, the copyright holder
72
+ can grant you a separate license on different terms. Ask.
73
+
74
+
75
+ 5. TERMINATION
76
+
77
+ If you breach this License, the permissions granted to you terminate
78
+ automatically. If the breach is curable and you cure it within 30 days of
79
+ becoming aware of it, your permissions are reinstated retroactively. Any
80
+ permissions granted to those who received the Software from you in compliance
81
+ with this License are unaffected by your termination.
82
+
83
+
84
+ 6. THIRD-PARTY COMPONENTS
85
+
86
+ This License covers only the Software itself. Dependencies distributed
87
+ alongside or installed with it remain under their own licenses.
88
+
89
+
90
+ 7. NO WARRANTY
91
+
92
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
93
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
94
+ FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
95
+
96
+
97
+ 8. LIMITATION OF LIABILITY
98
+
99
+ IN NO EVENT SHALL THE AUTHOR OR COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM,
100
+ DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR
101
+ OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE
102
+ OR OTHER DEALINGS IN THE SOFTWARE.