@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
@@ -0,0 +1,714 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { ActionName } from "../policy/types.js";
3
+ import type { Assessment, BotClass, Certainty, EvidenceDirection, Verdict } from "../types.js";
4
+ /**
5
+ * How a viewer proves they are allowed to read the dashboard.
6
+ *
7
+ * There is no default, and that is deliberate. This page shows client addresses,
8
+ * User-Agents, the paths people asked for, and — the part that matters most — exactly
9
+ * which detector fired and why. That last item is a tuning guide for anyone building a
10
+ * scraper against you: it tells them precisely which signal to fix next. So the server
11
+ * refuses to start on a non-loopback address unless you have said something explicit
12
+ * about who may read it.
13
+ */
14
+ export type DashboardAuth =
15
+ /** HTTP Basic. Both halves are compared in constant time. */
16
+ {
17
+ username: string;
18
+ password: string;
19
+ }
20
+ /**
21
+ * A shared secret, accepted as `Authorization: Bearer <token>` or as
22
+ * `?token=<token>` so a link can be opened directly. The query form puts the secret
23
+ * in browser history and in any proxy log on the way — fine for a laptop, a poor
24
+ * idea for a shared deployment.
25
+ */
26
+ | {
27
+ token: string;
28
+ }
29
+ /**
30
+ * Your own check — a session cookie, an SSO header your gateway sets, an mTLS
31
+ * subject. Returning `false` produces a 401 with no hint about why.
32
+ *
33
+ * **Return a string to name the viewer.** Anything truthy admits them; a non-empty
34
+ * string additionally says who they are, and that name travels with every change
35
+ * they make — into the `warning` the handler raises, into `policy-change`,
36
+ * `guard-change`, `range-change` and `actor-change`, and into the dashboard's own
37
+ * notices. Without it the audit trail can say a rule set was replaced and cannot say
38
+ * by whom, which is half an audit trail.
39
+ *
40
+ * ```ts
41
+ * auth: { authorize: (req) => sessionFrom(req)?.email ?? false }
42
+ * ```
43
+ *
44
+ * An empty string is a refusal rather than an anonymous admission, so a lookup that
45
+ * returns `""` for "no such user" fails closed.
46
+ */
47
+ | {
48
+ authorize: (request: IncomingMessage) => boolean | string | Promise<boolean | string>;
49
+ }
50
+ /**
51
+ * No authentication at all. An explicit, greppable opt-out: write this when the
52
+ * dashboard is already behind something that authenticates, and never merely to
53
+ * make the startup error go away.
54
+ */
55
+ | false;
56
+ /**
57
+ * What a caller the dashboard will not serve is told.
58
+ *
59
+ * The default is honest, and honest is usually right: `401` says "there is something
60
+ * here and you are not it", which is exactly what an operator who mistyped a password
61
+ * needs to read. But it also confirms, to anybody sweeping a port range, that this
62
+ * address runs an administrative page worth coming back to.
63
+ *
64
+ * Be clear about what changing it buys. **This is concealment, not access control.**
65
+ * A dashboard that answers `404` to the wrong credentials is exactly as reachable by
66
+ * someone holding the right ones as it was before, and exactly as exposed if the
67
+ * credentials leak. It raises the cost of *finding*, which is worth something against
68
+ * indiscriminate scanning and worth nearly nothing against somebody who already knows
69
+ * where to look. It is a layer on top of `auth`, never a substitute for it — and if
70
+ * reading this sentence makes you want to reach for it instead of authentication,
71
+ * that is the opposite of what it is for.
72
+ */
73
+ export type DashboardRefusal =
74
+ /**
75
+ * `401`, with `WWW-Authenticate` when the scheme is Basic. The default: a person
76
+ * who got their password wrong is told so, and a browser can prompt.
77
+ */
78
+ "unauthorized"
79
+ /**
80
+ * `404`, byte-identical to the answer for a path that does not exist here — so a
81
+ * probe cannot tell a guarded dashboard from a server that has never heard of it.
82
+ *
83
+ * The cost is that a browser will never prompt for credentials, because nothing
84
+ * asks it to. Pair it with `{ token }` or `{ authorize }` and a link people already
85
+ * hold; Basic auth is refused with this setting, because a login you cannot reach
86
+ * is not a login.
87
+ */
88
+ | "not-found"
89
+ /**
90
+ * No response at all — the connection is destroyed. What a port behind a dropping
91
+ * firewall looks like.
92
+ *
93
+ * The quietest option and the most disruptive: some clients retry a dropped
94
+ * connection rather than give up, and you lose the ability to tell a refusal from a
95
+ * network fault in your own logs. Same Basic-auth caveat as `not-found`.
96
+ */
97
+ | "close"
98
+ /**
99
+ * Send them somewhere else. Your SSO's sign-in page is the useful case — an
100
+ * operator who is simply not logged in yet ends up where they can fix that.
101
+ *
102
+ * `status` defaults to `302`. Note that a redirect is *not* concealment: it
103
+ * announces both that something is here and where its login lives.
104
+ */
105
+ | {
106
+ redirect: string;
107
+ status?: 302 | 303 | 307 | 308;
108
+ };
109
+ /** What a viewer may do beyond looking. */
110
+ export interface DashboardControls {
111
+ /**
112
+ * Allow the policy editor: viewing the rules as JSON, previewing a change against
113
+ * recent traffic, and **applying it to the running handler**. Default false.
114
+ *
115
+ * What it can and cannot reach is the whole design. It replaces the rule list —
116
+ * which rules exist, what each matches, what action each asks for. It cannot touch
117
+ * `falsePositivePolicy`, `fallbackAction` or `terminalScoreThreshold`, so no edit
118
+ * made here can relax the guard that stops an unproven verdict from denying
119
+ * anybody. Loosening that stays a deploy.
120
+ *
121
+ * Every apply is validated first, swapped atomically, and announced through the
122
+ * handler's `warning` event so it lands wherever your startup warnings land. The
123
+ * server refuses to enable this at all on a non-loopback bind with `auth: false`:
124
+ * an unauthenticated editor on a public address is a stranger's bot policy.
125
+ */
126
+ editPolicy?: boolean;
127
+ /**
128
+ * Allow the guard editor: changing `falsePositivePolicy`, `fallbackAction`,
129
+ * `defaultAction`, `terminalScoreThreshold` and `suspectThreshold` on the running
130
+ * handler. Default false, and the default is the one to keep unless somebody has
131
+ * decided otherwise on purpose.
132
+ *
133
+ * **This is the setting that decides whether an unproven verdict can deny anybody.**
134
+ * `editPolicy` changes which rules exist; a rule that overreaches is still stopped
135
+ * by the guard, so the worst an editor can do is write a rule that gets downgraded.
136
+ * This flag changes the guard itself. Moving `falsePositivePolicy` to `aggressive`
137
+ * makes every probabilistic verdict terminal, and the people it turns away first are
138
+ * the ones with the most unusual and most legitimate setups.
139
+ *
140
+ * So it is a separate flag from `editPolicy` rather than part of it: the two are
141
+ * different powers, and a dashboard that hands out the first should not have to hand
142
+ * out the second. Grant it the way you grant any other privileged operation — to a
143
+ * listener that a role check stands in front of, and to nobody else. Two things
144
+ * remain impossible whatever this is set to: a terminal `fallbackAction`, which
145
+ * would make a downgrade deny the request it was protecting, and a
146
+ * `terminalScoreThreshold` outside 1–100.
147
+ *
148
+ * Every change is validated first, applied whole or not at all, announced through
149
+ * the handler's `warning` event and emitted as `guard-change` with both the before
150
+ * and the after — which is the audit trail this deserves. The server refuses to
151
+ * enable it at all on a non-loopback bind with `auth: false`.
152
+ */
153
+ editGuard?: boolean;
154
+ /**
155
+ * Allow the range editor and the per-actor operations: adding an address to the
156
+ * allowlist or any other range set, forgetting one actor's history, and granting an
157
+ * actor human clearance. Default false.
158
+ *
159
+ * The three of them are one flag because they are one job — acting on a specific
160
+ * client rather than on a class of request — and because the first is consequential
161
+ * enough to carry the other two. **An allowlisted address is not judged leniently; it
162
+ * is not judged at all.** Detection does not run, no evidence is produced, no rule
163
+ * sees it. That is the right answer for your own monitoring and the wrong answer for
164
+ * anything that might one day be somebody else's.
165
+ *
166
+ * Forgetting an actor is the mild one, and the reason this exists at all: a person
167
+ * whose actor key collected a `confirmed-bot` carries `priorConfirmations` for the
168
+ * rest of the window, and until now the only cure was Reset — throwing away every
169
+ * actor's history to fix one.
170
+ *
171
+ * Refused unauthenticated on a public bind, like the other editors. Unavailable when
172
+ * `redact.maskIp` is on, because a masked key names a network rather than the actor
173
+ * the registry is keyed by, and acting on the wrong key silently is worse than not
174
+ * offering the button.
175
+ */
176
+ editRanges?: boolean;
177
+ /**
178
+ * Allow the "Reset" button to clear the feed, the counters and the **actor
179
+ * registry**. Default false.
180
+ *
181
+ * Clearing the registry discards every actor's history, which is real state on a
182
+ * live system: rate observations, cadence series, prior confirmations and challenge
183
+ * clearances all go with it. That is exactly what you want while running the
184
+ * simulator and exactly what you do not want a bored browser tab doing to
185
+ * production.
186
+ */
187
+ reset?: boolean;
188
+ }
189
+ /**
190
+ * Which parts of the dashboard exist on this listener.
191
+ *
192
+ * Everything defaults to on, and turning something off removes it from the page *and*
193
+ * from the server: the tab is gone, the panel is gone, the endpoint behind it answers
194
+ * 403, and fields a switched-off section would have shown are dropped before the data
195
+ * leaves the process. A viewer with devtools open sees exactly what the page sees.
196
+ *
197
+ * **There is no role model in here, deliberately.** `controls` says what a viewer may
198
+ * *do*; this says what a viewer may *see*; and both are fixed when the listener
199
+ * starts, which is what makes them cheap to reason about — no per-request evaluation,
200
+ * no session store, no second copy of your user table quietly disagreeing with the
201
+ * first. Roles are yours: run a listener per role, put your own `auth.authorize`
202
+ * predicate in front of each, and give each the sections and controls that role should
203
+ * have. `npm run demo:roles` is that arrangement, working.
204
+ *
205
+ * ```ts
206
+ * // An analyst sees traffic, not the reasons behind it — the evidence panel names
207
+ * // the exact signal that fired, which is a tuning guide for whoever is scraping you.
208
+ * await botHandler.serveDashboard({
209
+ * port: 9684,
210
+ * auth: { authorize: (req) => roleOf(req) === "analyst" },
211
+ * sections: { evidence: false, policy: false },
212
+ * redact: { maskIp: true },
213
+ * });
214
+ * ```
215
+ */
216
+ export interface DashboardSections {
217
+ /** The Live feed tab, and the `/api/feed` and `/api/stream` endpoints behind it. Default true. */
218
+ feed?: boolean;
219
+ /**
220
+ * The case for each verdict: the evidence list with its written basis, the request
221
+ * headers, the query parameters, the detector failures, and the buttons that turn a
222
+ * row into a replay line or a corpus case. Default true.
223
+ *
224
+ * The one worth thinking about before sharing a dashboard widely. This is the half
225
+ * of the page that says *which detector fired and why*, which is precisely what
226
+ * somebody building a scraper against you needs in order to know what to fix next.
227
+ * Switching it off leaves the feed — what happened, to whom, and what was done —
228
+ * and drops the evidence from the wire, not just from the screen.
229
+ */
230
+ evidence?: boolean;
231
+ /** The actor drill-down, the busiest-actors panel, and per-request actor history. Default true. */
232
+ actors?: boolean;
233
+ /** The Statistics tab and the counter tiles above it. Default true. */
234
+ statistics?: boolean;
235
+ /** The traffic audit panel — the window against its baseline. Default true. */
236
+ audit?: boolean;
237
+ /** The notices panel: startup warnings, audit anomalies, detector errors. Default true. */
238
+ notices?: boolean;
239
+ /**
240
+ * The changes panel: what was applied at runtime, when, and by whom. Default true.
241
+ *
242
+ * The same list the traffic timeline marks. It is an audit trail rather than a log —
243
+ * bounded and in memory, gone with the process — so treat the panel as the convenient
244
+ * copy and `policy-change`, `guard-change`, `range-change` and `actor-change` as the
245
+ * durable one.
246
+ */
247
+ changes?: boolean;
248
+ /**
249
+ * The Actors screen: the busiest actors the *registry* is holding, which is a far
250
+ * larger population than the feed's ring. Default true, and it follows `actors`.
251
+ */
252
+ registry?: boolean;
253
+ /**
254
+ * The request tester: paste a User-Agent, a curl command or a raw header block and
255
+ * see what the engine would make of it. Default true.
256
+ *
257
+ * It runs a dry-run assessment — nothing is recorded, no counter moves — so it
258
+ * answers "why is this client being challenged?" without waiting for that client to
259
+ * come back. It shows evidence, so it follows the `evidence` section too.
260
+ */
261
+ tester?: boolean;
262
+ /** The Policy tab: the rules, the preview, the editor, the settings export. Default true. */
263
+ policy?: boolean;
264
+ /** The range sets panel, and the allowlist controls on it. Default true. */
265
+ ranges?: boolean;
266
+ /** The guard panel inside the Policy tab. Default true; see {@link DashboardControls.editGuard}. */
267
+ guard?: boolean;
268
+ /** The `robots.txt` the policy implies. Default true. */
269
+ robots?: boolean;
270
+ }
271
+ /** How much of each request the dashboard is allowed to show. */
272
+ export interface DashboardRedaction {
273
+ /**
274
+ * Replace client addresses with their network (`/24`, `/64`). Default false.
275
+ *
276
+ * Off by default because this is an operator's own tool and acting on a specific
277
+ * address is the point. Turn it on when the dashboard is shared more widely than
278
+ * the logs are — an address is personal data in most of the world, and the network
279
+ * is enough to recognise a pattern.
280
+ */
281
+ maskIp?: boolean;
282
+ /** Keep only the first 48 characters of each User-Agent. Default false. */
283
+ truncateUserAgent?: boolean;
284
+ /**
285
+ * Replace query-string *values* with a placeholder, keeping the names. Default true.
286
+ *
287
+ * A query string is where password-reset tokens, invitation links and email
288
+ * addresses actually live. The names are what make a pattern legible — a burst of
289
+ * `?export=` is the thing worth seeing — and the values are what nobody needs on a
290
+ * screen that gets screenshotted into tickets.
291
+ */
292
+ maskQuery?: boolean;
293
+ /**
294
+ * Show the request's headers in the row detail, credentials stripped. Default true.
295
+ *
296
+ * The header set in wire order is the single most useful thing when arguing with a
297
+ * false positive — it is what half the detectors are reading. `Cookie`,
298
+ * `Authorization` and their neighbours never appear whatever this is set to; see
299
+ * `CREDENTIAL_HEADERS`.
300
+ */
301
+ headers?: boolean;
302
+ }
303
+ export interface DashboardOptions {
304
+ /** Port to listen on. Default 9674. Pass 0 for an ephemeral port and read it back from `url`. */
305
+ port?: number;
306
+ /**
307
+ * Address to bind. Default `"127.0.0.1"` — the loopback interface only.
308
+ *
309
+ * Binding anywhere else publishes the page to the network, so it requires an
310
+ * explicit `auth` (or an explicit `auth: false`); the server refuses to start
311
+ * otherwise rather than quietly exposing your evidence trail.
312
+ */
313
+ host?: string;
314
+ auth?: DashboardAuth;
315
+ /**
316
+ * How a caller this dashboard will not serve is answered. Default `"unauthorized"`.
317
+ *
318
+ * Applies to every refusal made before routing — a failed `auth`, a `Host` outside
319
+ * `allowedHosts`, a cross-site write — so that all three look alike and none of them
320
+ * becomes an oracle. Under the default they keep their distinct, informative
321
+ * statuses (`401`, `421`, `403`), because an operator debugging their own setup is
322
+ * the likelier reader.
323
+ */
324
+ refusal?: DashboardRefusal;
325
+ /** Path the dashboard is mounted under, e.g. `"/_bots"`. Default `"/"`. */
326
+ basePath?: string;
327
+ /** Name shown in the header and the document title. Default `"bothandlerjs"`. */
328
+ title?: string;
329
+ /**
330
+ * Which process this is. Default the machine's hostname.
331
+ *
332
+ * Shown in the header, and it is there to answer a question the page otherwise
333
+ * invites you to get wrong. **A dashboard reports on one process.** Behind a load
334
+ * balancer with eight pods there are eight rings, eight sets of counters and eight
335
+ * actor registries, and the one you happen to have opened is showing you an eighth of
336
+ * your traffic. Naming the instance does not aggregate anything; it stops a partial
337
+ * picture from looking like a whole one.
338
+ */
339
+ instance?: string;
340
+ /** Links shown in the header — your site, your runbook, whatever is useful. */
341
+ links?: ReadonlyArray<{
342
+ label: string;
343
+ href: string;
344
+ }>;
345
+ /** Requests kept in the live feed. Default 500, hard-capped at 5000. */
346
+ feedLimit?: number;
347
+ /**
348
+ * How long a request may stay in the feed, in milliseconds. Default one hour; `0`
349
+ * keeps them until the ring evicts them.
350
+ *
351
+ * `feedLimit` is a capacity bound and this is a retention one, which is a different
352
+ * question with a different answer. On a quiet service five hundred requests can be a
353
+ * fortnight of traffic, and every entry holds a client address, a User-Agent and a
354
+ * header set belonging to a person. "We keep the last five hundred requests" is a
355
+ * statement about memory; "we keep nothing older than an hour" is a promise you can
356
+ * make to somebody who asks.
357
+ *
358
+ * Eviction runs when a request is recorded *and* on the dashboard's own timer, so it
359
+ * holds on an idle process too — which is the only kind of process where it matters,
360
+ * since a busy one drops entries by count long before they reach this age.
361
+ */
362
+ feedTtlMs?: number;
363
+ /** Concurrent event-stream viewers. Default 16. Beyond this the server answers 503. */
364
+ maxClients?: number;
365
+ /**
366
+ * Entries per second pushed to each viewer. Default 100; `0` removes the cap.
367
+ *
368
+ * A dashboard on a busy origin is a firehose: every assessment goes to every open
369
+ * browser, so a thousand requests a second is a couple of megabytes a second *per
370
+ * viewer*, and the only lever the page has is Pause. Above this rate the surplus is
371
+ * dropped from the stream — not from the ring, which still holds the last
372
+ * `feedLimit` requests for the preview and for anyone who reconnects — and the page
373
+ * shows how many were skipped, so a thinned feed never looks like a quiet one.
374
+ *
375
+ * The cap is per second and not smoothed: a burst inside one second is delivered
376
+ * whole up to the limit, because seeing the front of a burst is the point.
377
+ */
378
+ maxEventsPerSecond?: number;
379
+ controls?: DashboardControls;
380
+ /** Which parts of the page exist on this listener. See {@link DashboardSections}. */
381
+ sections?: DashboardSections;
382
+ redact?: DashboardRedaction;
383
+ /**
384
+ * Client addresses this dashboard will answer at all, as addresses or CIDRs.
385
+ *
386
+ * The layer `allowedHosts` is not. That one checks the name in the `Host` header —
387
+ * what the client *asked for* — and closes DNS rebinding. This checks who is
388
+ * connecting, which is the question an operator means when they say "bind `0.0.0.0`,
389
+ * but only the VPN can reach it".
390
+ *
391
+ * ```ts
392
+ * host: "0.0.0.0",
393
+ * allowedClients: ["10.0.0.0/8", "192.168.0.0/16"],
394
+ * auth: { username: "ops", password: process.env.DASHBOARD_PASSWORD! },
395
+ * ```
396
+ *
397
+ * It is checked before authentication, so an address outside the list cannot even
398
+ * attempt a credential, and it is answered through `refusal` like every other
399
+ * pre-routing check. It is a *layer*, not a replacement for `auth`: addresses are
400
+ * spoofable at the network level in ways passwords are not, and a shared office range
401
+ * is not a person.
402
+ *
403
+ * The address compared is the one the handler's own proxy configuration resolves —
404
+ * so behind a load balancer you list your users' addresses rather than the balancer's
405
+ * — and a peer that is not one of your `trustedProxies` cannot claim to be somebody
406
+ * else, because the chain is only walked for peers that are.
407
+ */
408
+ allowedClients?: readonly string[];
409
+ /**
410
+ * How a repeatedly-wrong credential is slowed down. Default: on.
411
+ *
412
+ * Both halves of a basic credential are compared in constant time, which stops one
413
+ * attack and does nothing about the obvious one — trying again. An administrative
414
+ * page reachable from anywhere with an unthrottled password prompt can be worked
415
+ * through at line rate.
416
+ *
417
+ * After `maxAttempts` failures from one address the next attempt is refused outright
418
+ * for a delay that doubles each time, up to `maxLockoutMs`. A success clears the
419
+ * count. Set `false` to switch it off, which is only sensible when something in front
420
+ * of the dashboard is already doing this.
421
+ *
422
+ * The refusal reuses whatever `refusal` says, so a dashboard configured to look like
423
+ * an empty server still looks like one: a `429` would tell a prober that there is a
424
+ * credential here worth guessing.
425
+ */
426
+ authThrottle?: {
427
+ maxAttempts?: number;
428
+ lockoutMs?: number;
429
+ maxLockoutMs?: number;
430
+ } | false;
431
+ /**
432
+ * Sibling dashboards, for the header's instance switcher.
433
+ *
434
+ * A dashboard reports on one process — see {@link DashboardOptions.instance} — and
435
+ * this is the honest amount of help the page can give with that: a way to reach the
436
+ * other ones. It aggregates nothing, and deliberately: a feed and an actor registry
437
+ * summed across pods would be a different tool, and the counters are already
438
+ * aggregatable through Prometheus, which is a thing your monitoring already does.
439
+ */
440
+ peers?: ReadonlyArray<{
441
+ label: string;
442
+ href: string;
443
+ }>;
444
+ /**
445
+ * Extra `Host` header values this dashboard will answer to.
446
+ *
447
+ * Only consulted on a loopback bind, where it defends against DNS rebinding: a name
448
+ * the attacker controls, pointed at 127.0.0.1, makes their page *same-origin* with
449
+ * this server and hands them the feed. Checking the `Host` header closes that,
450
+ * because the name they need you to send is not one you wrote down.
451
+ *
452
+ * The defaults — `localhost`, `127.0.0.1`, `[::1]` and the bound address — cover how
453
+ * a dashboard on loopback is actually opened. Add to them when you reach it through
454
+ * a name of your own, e.g. an SSH tunnel aliased in `/etc/hosts`. A non-loopback
455
+ * bind ignores this: rebinding buys an attacker nothing against an address they can
456
+ * already reach, and enforcing it there would break every reverse proxy.
457
+ *
458
+ * Entries are compared without the port. `"*"` disables the check.
459
+ */
460
+ allowedHosts?: readonly string[];
461
+ /**
462
+ * Serve the Prometheus exposition at `<basePath>/metrics`, behind the same auth.
463
+ * Default false — a scraper usually wants its own unauthenticated endpoint on a
464
+ * different port, and `handler.prometheus()` gives you the text to serve there.
465
+ */
466
+ exposePrometheus?: boolean;
467
+ }
468
+ /**
469
+ * Options for {@link createDashboardHandler}.
470
+ *
471
+ * The listening form's options minus the two that describe a socket it does not own,
472
+ * plus one requirement: `auth` is mandatory, including the explicit `auth: false`.
473
+ * `startDashboard` may skip it on `127.0.0.1` because the operating system is then the
474
+ * access control; mounted, there is no bind address to inspect, so nothing can be
475
+ * assumed and the assumption made is "public".
476
+ */
477
+ export type DashboardHandlerOptions = Omit<DashboardOptions, "port" | "host"> & {
478
+ auth: DashboardAuth;
479
+ };
480
+ /**
481
+ * The dashboard as a request handler, for a server you already have.
482
+ *
483
+ * Callable as `(request, response)`. `close()` unsubscribes from the engine and ends
484
+ * every event stream; it does not close a server it does not own.
485
+ */
486
+ export interface DashboardRequestHandler {
487
+ (request: IncomingMessage, response: ServerResponse): void;
488
+ /** Event-stream viewers currently connected. */
489
+ readonly clients: number;
490
+ close(): Promise<void>;
491
+ }
492
+ /** A running dashboard. */
493
+ export interface DashboardServer {
494
+ /** The address to open. Reflects the port actually bound, which matters when you passed 0. */
495
+ readonly url: string;
496
+ readonly port: number;
497
+ readonly host: string;
498
+ /** Event-stream viewers currently connected. */
499
+ readonly clients: number;
500
+ /** Stops listening, drops every stream, and unsubscribes from the engine. Idempotent. */
501
+ close(): Promise<void>;
502
+ }
503
+ /** One assessed request, as the dashboard shows it. */
504
+ export interface DashboardEntry {
505
+ seq: number;
506
+ requestId: string;
507
+ /** Epoch milliseconds, so the page can bucket by time without parsing anything. */
508
+ at: number;
509
+ method: string;
510
+ path: string;
511
+ actor: string;
512
+ userAgent: string;
513
+ verdict: Verdict;
514
+ botClass: BotClass;
515
+ identity?: string | undefined;
516
+ score: number;
517
+ certain: boolean;
518
+ durationMs: number;
519
+ bypass?: string | undefined;
520
+ /** Absent until the policy has decided — `assess()` on its own never produces one. */
521
+ action?: ActionName | undefined;
522
+ rule?: string | undefined;
523
+ downgradedFrom?: ActionName | undefined;
524
+ downgradeReason?: string | undefined;
525
+ evidence: DashboardEvidence[];
526
+ failures: Array<{
527
+ detector: string;
528
+ reason: string;
529
+ message: string;
530
+ }>;
531
+ /** Actor history at the time of the request. Drives the actor drill-down. */
532
+ actorStats: DashboardActor;
533
+ /** Query parameters, values masked unless `redact.maskQuery` is false. */
534
+ query: Record<string, string>;
535
+ /** Headers in wire order, credentials removed. Absent when `redact.headers` is false. */
536
+ headers?: Array<[name: string, value: string]> | undefined;
537
+ protocol?: string | undefined;
538
+ httpVersion?: string | undefined;
539
+ }
540
+ export interface DashboardEvidence {
541
+ detector: string;
542
+ summary: string;
543
+ certainty: Certainty;
544
+ direction: EvidenceDirection;
545
+ family?: string | undefined;
546
+ deterministicBasis?: string | undefined;
547
+ /**
548
+ * Identity, category and weight travel with the evidence for one reason beyond
549
+ * display: they are what the policy matcher reads, so the entry alone is enough to
550
+ * re-decide a request under a different rule set. See the policy preview.
551
+ */
552
+ identity?: string | undefined;
553
+ category?: string | undefined;
554
+ weight?: number | undefined;
555
+ }
556
+ /** Per-actor history as it stood when the request was assessed. */
557
+ export interface DashboardActor {
558
+ requests: number;
559
+ distinctPaths: number;
560
+ priorConfirmations: number;
561
+ cleared: boolean;
562
+ firstSeen: number;
563
+ sinceLastMs?: number | undefined;
564
+ }
565
+ /** One runtime change, for the timeline markers and the change list. */
566
+ export interface DashboardChange {
567
+ at: number;
568
+ kind: "policy" | "guard" | "range" | "actor";
569
+ summary: string;
570
+ /** Who asked for it, when the dashboard's `authorize` was able to say. */
571
+ by?: string | undefined;
572
+ }
573
+ /** A warning or error the engine raised, for the notices panel. */
574
+ export interface DashboardNotice {
575
+ at: number;
576
+ kind: "warning" | "error";
577
+ source?: string | undefined;
578
+ message: string;
579
+ }
580
+ /** What a candidate rule set would have done to the traffic still in the window. */
581
+ export interface PolicyPreview {
582
+ /** Requests the preview was run over — the retained window, not all time. */
583
+ evaluated: number;
584
+ changed: number;
585
+ /** Action counts under the live policy and under the candidate. */
586
+ before: Record<string, number>;
587
+ after: Record<string, number>;
588
+ /** Rule ids in the candidate, with how many of those requests each would have taken. */
589
+ ruleHits: Array<{
590
+ rule: string;
591
+ hits: number;
592
+ }>;
593
+ /** A bounded sample of requests whose action would change. */
594
+ samples: Array<{
595
+ path: string;
596
+ verdict: string;
597
+ userAgent: string;
598
+ from: string;
599
+ to: string;
600
+ fromRule: string;
601
+ toRule: string;
602
+ }>;
603
+ /** Problems found in the candidate that do not stop it loading. */
604
+ warnings: string[];
605
+ /**
606
+ * True when the candidate would deny a request the live policy served.
607
+ *
608
+ * Called out on its own because it is the one direction of change that costs
609
+ * somebody their access, and it is easy to miss in a table of counts.
610
+ */
611
+ newDenials: number;
612
+ }
613
+ /** Everything the page is given at load time, and again on every stats refresh. */
614
+ export interface DashboardSnapshot {
615
+ startedAt: number;
616
+ now: number;
617
+ /** Absent when the handler has `metrics: false`, or when the `statistics` section is off. */
618
+ metrics: import("../metrics.js").MetricsSnapshot | undefined;
619
+ detectors: Array<{
620
+ id: string;
621
+ description: string;
622
+ cost: string;
623
+ stage: string;
624
+ }>;
625
+ rules: readonly string[];
626
+ ranges: Array<{
627
+ name: string;
628
+ size: number;
629
+ }>;
630
+ /** Configuration a reader needs in order to interpret what they are looking at. */
631
+ policy: {
632
+ falsePositivePolicy: string;
633
+ fallbackAction: string;
634
+ suspectThreshold: number;
635
+ defaultAction: string;
636
+ terminalScoreThreshold: number;
637
+ challengeEnabled: boolean;
638
+ /** Whether this dashboard may change the rules. See {@link DashboardControls.editPolicy}. */
639
+ editable: boolean;
640
+ /** Whether this dashboard may change the guard. See {@link DashboardControls.editGuard}. */
641
+ guardEditable: boolean;
642
+ };
643
+ /** Which process this is, and how long it has been up. See {@link DashboardOptions.instance}. */
644
+ instance: string;
645
+ /**
646
+ * Runtime changes, newest last: policy replacements, guard edits, range updates and
647
+ * per-actor operations.
648
+ *
649
+ * Drawn as markers on the traffic timeline, which is what turns a preview from a
650
+ * prediction into something you can check. "44 of 151 requests would be treated
651
+ * differently" is a claim; a line on the chart at the moment it was applied, with the
652
+ * traffic either side of it, is the answer.
653
+ */
654
+ changes: readonly DashboardChange[];
655
+ /** Feed entries dropped from the stream by the rate cap, since start. See {@link DashboardOptions.maxEventsPerSecond}. */
656
+ skipped: number;
657
+ /** Startup warnings, audit anomalies, and anything else the engine has raised. */
658
+ notices: readonly DashboardNotice[];
659
+ /**
660
+ * The audit's two windows as they stand, and the checks installed.
661
+ *
662
+ * `undefined` when the audit was switched off with `audit: false`.
663
+ */
664
+ audit?: {
665
+ window: import("../audit.js").AuditWindow;
666
+ baseline: import("../audit.js").AuditWindow;
667
+ checks: Array<{
668
+ id: string;
669
+ description: string;
670
+ }>;
671
+ } | undefined;
672
+ }
673
+ /** The rule set as the dashboard shows it, plus what its editor needs to build itself. */
674
+ export interface PolicyDocument {
675
+ rules: Array<{
676
+ id: string;
677
+ index: number;
678
+ editable: boolean;
679
+ rule?: import("../policy/types.js").Rule;
680
+ }>;
681
+ robots: string;
682
+ robotsNotes: Array<{
683
+ rule: string;
684
+ reason: string;
685
+ }>;
686
+ editable: boolean;
687
+ /** Whether the guard form is live on this dashboard. See {@link DashboardControls.editGuard}. */
688
+ guardEditable: boolean;
689
+ /** The guard as it stands, in the shape the guard form submits back. */
690
+ guard: {
691
+ falsePositivePolicy: string;
692
+ fallbackAction: string;
693
+ defaultAction: string;
694
+ terminalScoreThreshold: number;
695
+ suspectThreshold: number;
696
+ };
697
+ vocabulary: {
698
+ verdicts: readonly string[];
699
+ botClasses: readonly string[];
700
+ categories: readonly string[];
701
+ actions: readonly string[];
702
+ /** Actions a downgrade may land on: everything except the terminal ones. */
703
+ fallbackActions: readonly string[];
704
+ falsePositivePolicies: readonly string[];
705
+ methods: readonly string[];
706
+ detectors: readonly string[];
707
+ presets: readonly string[];
708
+ };
709
+ }
710
+ /** Assessment plus the decision that followed it, as the feed records it. */
711
+ export interface DashboardRecord {
712
+ assessment: Assessment;
713
+ action?: ActionName | undefined;
714
+ }