@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,725 @@
1
+ # The dashboard
2
+
3
+ The operator dashboard: what it shows, what it refuses to do, and every option it takes.
4
+
5
+ ← [Documentation](../index.md) · [Operations](index.md)
6
+
7
+ ---
8
+
9
+ Counters tell you *how much*. The dashboard tells you **which requests, and why** —
10
+ every assessment as it lands, and, on any row you open, the individual pieces of
11
+ evidence with their certainty tier and, for proven ones, the written basis.
12
+
13
+ ```ts
14
+ const dashboard = await botHandler.serveDashboard({
15
+ port: 9674,
16
+ auth: { username: "ops", password: process.env.DASHBOARD_PASSWORD! },
17
+ });
18
+
19
+ console.log(dashboard.url); // http://127.0.0.1:9674/
20
+ ```
21
+
22
+ That is the whole integration. It listens on a port of its own, subscribes to the
23
+ handler you called it on, and returns a handle with the URL and a `close()`.
24
+
25
+ ### Four screens
26
+
27
+ **Live feed** — one row per request: when it happened, method and path, the actor, the
28
+ User-Agent, the verdict, the score, the action and the rule that chose it. Filter by
29
+ proven, suspected, human, guard stops, denied, mitigated or served; or type into the
30
+ search box.
31
+
32
+ The search takes fields, and negation, because the two cases people actually reach for
33
+ it are an address that also appears inside a User-Agent and a path that is a prefix of
34
+ ten others:
35
+
36
+ ```text
37
+ actor:203.0.113.4 -path:/health that address, except its health checks
38
+ rule:no-scrapers action:tag the rule that fired, and what it settled on
39
+ score:>70 -certain probabilistic traffic close to the line
40
+ "GET /api/v2/orders" a phrase, spaces and all
41
+ ```
42
+
43
+ Fields: `path` `actor` `ua` `verdict` `action` `rule` `detector` `identity` `method`
44
+ `class` `id` `bypass` `outcome` `certain` `score`. Every term must match — narrowing
45
+ means `AND` — and anything that is not a field term is matched against the whole
46
+ request, so a plain word behaves as it always did. **The filter and the search are in
47
+ the URL**, along with the view, so a screen is a link rather than a set of
48
+ instructions, and a refresh keeps your place.
49
+
50
+ The rows are updated in place rather than redrawn. That is invisible until you try to
51
+ read a feed that is moving: a rebuilt row takes your text selection with it, so
52
+ copying a User-Agent out of a live feed used to be impossible without pressing Pause
53
+ first.
54
+
55
+ Open a row and you get the case for the verdict: every piece of evidence with its
56
+ tier, its family, and — for proven ones — the written basis; the **request headers in
57
+ wire order**, credentials replaced, which is what half the detectors are actually
58
+ reading; the query parameters, values masked; and any detector that failed. Three
59
+ buttons turn the row into something you can keep:
60
+
61
+ | | |
62
+ | --- | --- |
63
+ | **Copy replay line** | The request as a JSONL line for `bothandlerjs replay`. |
64
+ | **Copy corpus case** | The same request as a traffic-corpus fixture, ready to paste. |
65
+ | **Draft a rule** | Starts a rule from this request, in the policy editor. |
66
+ | **Show this actor** | Opens the drill-down. |
67
+
68
+ That first pair is the loop this library cares about: a verdict you disagree with on
69
+ screen becomes a fixture you re-run offline, and then a corpus case that stops it
70
+ coming back. **Export** in the toolbar is the same thing for the whole window at once
71
+ — every request matching the current filter, as replay JSONL.
72
+
73
+ **Draft a rule** is the other loop, the one that used to have no help at all: you saw
74
+ an actor worth acting on, then went to a different tab and hand-wrote a rule, guessing
75
+ at which field would catch it. It matches on the strongest thing the request actually
76
+ proves — a verified identity, else the detectors whose evidence was proven, else the
77
+ verdict with a score floor — and it does three things deliberately:
78
+
79
+ - **The action is always `tag`.** Never `block`, whatever the request looked like. A
80
+ drafted rule has been reviewed by nobody, and the dashboard picking a terminal action
81
+ for a request that annoyed you is the reflex this library exists to interrupt.
82
+ - **It goes last**, which is the only position that cannot change what an existing rule
83
+ does. If it is shadowed, the preview says so as "never matched".
84
+ - **Nothing is applied.** It fills the editor and previews itself, exactly like an
85
+ import.
86
+
87
+ Click any **actor** and the drill-down opens above the feed: what the engine knows
88
+ about them — requests in the window, distinct paths, prior confirmations, whether they
89
+ hold clearance, first seen, mean gap between requests — with their verdict and action
90
+ mix. It is the same picture `cadence` and `crawl-breadth` are reasoning about.
91
+
92
+ Beside the feed, **Test a request**: paste a User-Agent, a `curl` command out of
93
+ devtools, or a raw header block, and see the verdict, the evidence and the rule that
94
+ would fire — without waiting for that client to come back. It runs a **dry run** on the
95
+ server ([`assess(facts, { record: false })`](../detection/index.md#asking-about-a-request-that-is-not-happening)),
96
+ so nothing is recorded: no counter moves, no actor state changes, no row appears in the
97
+ feed it is sitting next to. It says what it had to assume, every time, including the one
98
+ that matters most — a dry run has no history, so what it answers is *what would this look
99
+ like as a first request*. Which is what a support ticket is asking anyway.
100
+
101
+ **Actors** — everyone the *registry* is holding, busiest first, which is a much larger
102
+ population than the feed's. The ring holds a few hundred requests; on a busy origin that
103
+ is a few seconds. The registry holds up to `maxActors` clients, each with the rate
104
+ series, path breadth and confirmation count that `cadence`, `crawl-breadth` and
105
+ `rate-anomaly` are reading — so "who is hitting me hardest right now" is a question only
106
+ this screen can answer. Requests, requests per minute, distinct paths, cadence
107
+ regularity (near zero is a metronome, which no person is), prior confirmations, and
108
+ whether they hold clearance. **In feed** sends one to the live feed as an `actor:` filter,
109
+ which makes it a shareable URL like every other view.
110
+
111
+ **Statistics** — a traffic timeline (1m/5m/15m/1h) split by outcome, which says how
112
+ much history the window actually holds rather than drawing a flat line through time it
113
+ never had; the assessment-latency histogram with mean, p95 and max; and the **score
114
+ distribution with your `suspectThreshold` marked on it**, which is the chart that
115
+ answers "how close does ordinary traffic run to the line?" before you move it.
116
+
117
+ That chart takes a scope, and the reason is worth stating: half this screen counts the
118
+ few hundred requests still in the feed's ring and half counts since the process
119
+ started. Those are different populations — on a busy server the ring can be ninety
120
+ seconds of a three-week run — and they used to wear the same grey subtitle. Every
121
+ window-scoped panel now says how much window there is ("last 500 requests · 4 min"),
122
+ and the score distribution defaults to **since start**, drawn from the same counters
123
+ the Prometheus endpoint exposes, with **this window** one click away.
124
+
125
+ Then the attribution: **guard stops broken down by the rule that overreached**, **rule
126
+ hit counts including the rules that never fire** (a rule matching nothing is either
127
+ dead configuration or a rule sitting behind a broader one, and both are invisible in a
128
+ chart that only draws what happened), identities seen split into verified and merely
129
+ claimed, busiest and most-denied paths, what bypassed detection and why, the challenge
130
+ funnel with its solve rate, detector failures, and every installed detector with its
131
+ firing count and — when timing is on — what it costs.
132
+
133
+ Beside them, the **audit panel**: the window against its baseline, measure by measure,
134
+ with the ratio between them and the checks that are installed. See
135
+ [The audit](audit.md).
136
+
137
+ **Policy** — the rules as JSON, the guard settings (read-only unless you opted in — see
138
+ [Changing the guard](#changing-the-guard-behind-its-own-flag)), a `robots.txt` preview
139
+ generated from the rules that decline crawlers, and the notices panel: startup
140
+ warnings, audit anomalies, and anything else the engine has raised, which otherwise
141
+ scroll past in a log nobody reads.
142
+
143
+ The counters come from the same `metrics()` snapshot as the Prometheus endpoint, so
144
+ the numbers on the screen and the numbers in your alerting agree by construction.
145
+
146
+ ### Trying a policy before you mean it
147
+
148
+ The Policy screen previews a candidate rule set against the traffic still in the
149
+ window, and reports what would change:
150
+
151
+ ```
152
+ 44 of 151 requests would be treated differently.
153
+ 43 request(s) that are served today would be denied. Read the samples before applying.
154
+ ```
155
+
156
+ Any of the shipped presets can be previewed by name — "what would `protect-data` have
157
+ done to *my* traffic?" answered from your traffic rather than from the documentation —
158
+ and so can anything you type into the editor. It works because `decide()` is pure: a
159
+ candidate policy can be run over the window as many times as you like and nothing
160
+ about the running system moves.
161
+
162
+ Rule *matching* is exact — every field the matcher reads travels on the feed entry —
163
+ and so is the safety guard. What a preview cannot tell you is what the action would
164
+ have *done*: a challenge might have been solved, a rate limit might not have been
165
+ reached. It answers "which rule, and which action", which is what an edit is about.
166
+
167
+ ### Live policy editing, behind a flag
168
+
169
+ ```ts
170
+ await botHandler.serveDashboard({
171
+ auth: { username: "ops", password: process.env.DASHBOARD_PASSWORD! },
172
+ controls: { editPolicy: true }, // off by default
173
+ });
174
+ ```
175
+
176
+ With it on, the Policy screen's editor can **apply** a rule set to the running handler.
177
+ The rules are validated first and swapped atomically, so a rejected edit leaves the
178
+ running policy exactly as it was, and every apply is announced through the handler's
179
+ `warning` event and the `policy-change` hook — it lands wherever your startup warnings
180
+ land, which is the audit trail this deserves.
181
+
182
+ The editor is a form, not a text box. Each rule is a row showing its position, its id,
183
+ what it matches and the action it asks for; open one and every field of the match is a
184
+ control — verdicts, classes, categories and detectors as toggles, evidence as
185
+ any/proven/unproven, score as a range, paths and identities as lists, and the action's
186
+ parameters appearing to suit whichever action you picked. The dropdowns are built from
187
+ the engine's own vocabularies and the detector ids actually installed on *that*
188
+ handler, so the editor cannot offer you a verdict the library does not have. **Order
189
+ is the semantics** — first match wins — so the rows move with ↑ and ↓ and are numbered.
190
+ A JSON view is one click away for anything faster to type than to click.
191
+
192
+ ### Changing the guard, behind its own flag
193
+
194
+ ```ts
195
+ await botHandler.serveDashboard({
196
+ auth: { authorize: (req) => roleOf(req) === "admin" },
197
+ controls: { editGuard: true }, // off by default, and separate from editPolicy
198
+ });
199
+ ```
200
+
201
+ With it on, the Guard panel becomes a form: `falsePositivePolicy`, `fallbackAction`,
202
+ `defaultAction`, `terminalScoreThreshold` and `suspectThreshold`, applied to the
203
+ running handler.
204
+
205
+ **It is a separate flag from `editPolicy`, and that is the whole design.** They are
206
+ different powers. A rule editor can write a rule that overreaches, and the guard stops
207
+ it; the worst it can do is produce a downgrade. This changes the guard itself — whether
208
+ an unproven verdict may deny anybody at all — and the people it turns away first are
209
+ the ones with the most unusual and most legitimate setups. So a dashboard that hands
210
+ out the first does not thereby hand out the second, and you can put a different role in
211
+ front of each.
212
+
213
+ Everything else is the same contract the rule editor has. A change is validated before
214
+ anything moves, applied whole or not at all, previewed against real traffic first
215
+ ("what would `balanced` have done to *my* traffic?" is a question the preview answers
216
+ from your window), and announced twice — through the handler's `warning` event, which
217
+ lands wherever your startup warnings land and in the notices panel, and as a
218
+ `guard-change` event carrying both the before and the after.
219
+
220
+ Two things stay impossible whatever the flag says, because both would leave the guard
221
+ switched on and doing nothing:
222
+
223
+ - **A terminal `fallbackAction`.** The fallback is what a downgraded decision *becomes*.
224
+ Set it to `block` and every downgrade blocks — the exact outcome the downgrade exists
225
+ to prevent — and the decision would still be recorded as a guard stop, so the metric
226
+ that is supposed to catch this would report success.
227
+ - **A `terminalScoreThreshold` outside 1–100.** Zero would let balanced mode deny on
228
+ any score at all.
229
+
230
+ The form says what each mode means where the choice is made, rather than in
231
+ documentation somebody would have to go and find, and Preview sits next to Apply. If
232
+ you turn this on, the reason should include who is allowed to press it — and
233
+ `guard-change` is the event to alert on.
234
+
235
+ ### Acting on one client
236
+
237
+ ```ts
238
+ await botHandler.serveDashboard({
239
+ auth: { authorize: (req) => roleOf(req) === "admin" },
240
+ controls: { editRanges: true }, // off by default
241
+ });
242
+ ```
243
+
244
+ Three operations, on the actor drill-down and on every row of the Actors screen. They
245
+ share one flag because they are one job — acting on a specific client rather than on a
246
+ class of request — and because the first is consequential enough to carry the other two.
247
+
248
+ | | |
249
+ | --- | --- |
250
+ | **Allowlist** | Adds the address to the `allowlist` range set. Asks twice, and the second button says the address it is about to exempt. |
251
+ | **Forget** | Discards that actor's behavioural memory. |
252
+ | **Clear as human** | Grants clearance for an hour, as though a challenge had been solved. |
253
+
254
+ **An allowlisted address is not judged leniently — it is not judged at all.** Detection
255
+ does not run on it, no evidence is produced, no rule sees it. That is the right answer
256
+ for your own monitoring and the wrong answer for anything that might one day be somebody
257
+ else's, which is why the button states the address and waits for a second click rather
258
+ than putting up a dialog: a confirmation you can dismiss without reading is a click with
259
+ extra steps.
260
+
261
+ **Forget** is the mild one and the reason the flag exists at all. A person whose actor
262
+ key collected a `confirmed-bot` — a shared office address, a phone that reused an IP —
263
+ carries `priorConfirmations` for the rest of the window, and every rule reading
264
+ `minPriorConfirmations` keeps matching them. Until this existed the only cure was Reset,
265
+ which throws away every actor's history to fix one person's. It is not an allowlist: the
266
+ next request from that actor is assessed exactly as any first request would be.
267
+
268
+ The **Range sets** panel on the Policy screen shows what is actually in each set rather
269
+ than how many entries it has, and adds or removes one at a time. It is read-only without
270
+ the flag.
271
+
272
+ Unavailable when `redact.maskIp` is on, and that is not a UI decision: a masked key names
273
+ a `/24` while the registry is keyed by the address, so the buttons would act on the wrong
274
+ key. The whole control goes rather than half of it.
275
+
276
+ ### Who did it
277
+
278
+ The dashboard has no user model — `authorize` answers one question, and roles are yours.
279
+ But an audit trail that can say the guard was changed and cannot say by whom is half an
280
+ audit trail, so the check may name the viewer:
281
+
282
+ ```ts
283
+ auth: { authorize: (req) => sessionFrom(req)?.email ?? false }
284
+ ```
285
+
286
+ Anything truthy admits them; a **string** additionally says who they are, and that name
287
+ travels into the `warning` the handler raises, into `policy-change`, `guard-change`,
288
+ `range-change` and `actor-change`, and into the marker this change leaves on the traffic
289
+ timeline. Basic auth supplies it without being asked, since a basic credential names
290
+ itself. A bearer token names nobody, and inventing a name would be worse than admitting
291
+ the gap. An empty string is a refusal rather than an anonymous admission, so a lookup
292
+ returning `""` for "no such user" fails closed.
293
+
294
+ ### Sections: what a listener shows
295
+
296
+ `controls` says what a viewer may **do**. `sections` says what a viewer may **see**.
297
+ Everything defaults to on; turning something off removes it from the page *and* from
298
+ the server — the tab is gone, the panel is gone, the endpoint behind it answers `403`,
299
+ and the fields it would have shown are dropped before they leave the process. A viewer
300
+ with devtools open sees exactly what the page sees.
301
+
302
+ ```ts
303
+ await botHandler.serveDashboard({
304
+ port: 9684,
305
+ auth: { authorize: (req) => roleOf(req) === "analyst" },
306
+ sections: { evidence: false, policy: false },
307
+ redact: { maskIp: true },
308
+ });
309
+ ```
310
+
311
+ | Section | Off means |
312
+ | ------- | --------- |
313
+ | `feed` | No Live tab; `/api/feed` and `/api/stream` answer 403. |
314
+ | `evidence` | No evidence list, headers, query parameters, detector failures, replay or corpus export — and none of it on the wire either. |
315
+ | `actors` | No actor drill-down, no busiest-actors panel, and `actorStats` blanked on every entry. Takes `registry` with it. |
316
+ | `registry` | No Actors screen; `/api/actors` answers 403. |
317
+ | `tester` | No request tester; `/api/test` answers 403. |
318
+ | `statistics` | No Statistics tab and no counter tiles; the snapshot carries no `metrics`. |
319
+ | `audit` | No audit panel. |
320
+ | `notices` | No notices panel. |
321
+ | `changes` | No changes panel — the runtime audit list. The timeline markers go with it. |
322
+ | `policy` | No Policy tab; `/api/policy`, `/api/settings`, the preview and the editor all answer 403. Takes `guard` and `robots` with it. |
323
+ | `guard` | No guard panel, and `/api/guard` answers 403. |
324
+ | `ranges` | No range sets panel, and `/api/ranges` answers 403 — which takes the allowlist button with it. |
325
+ | `robots` | No `robots.txt` preview. |
326
+
327
+ `evidence` is the one to think about before sharing a dashboard widely. It is the half
328
+ of the page that says *which detector fired and why*, which is exactly what somebody
329
+ building a scraper against you needs in order to know what to fix next. Switching it
330
+ off leaves the feed — what happened, to whom, and what was done about it — which is
331
+ usually what a wider audience actually wants.
332
+
333
+ ### Export and import
334
+
335
+ **Export** downloads the whole settings document: the rules, plus a record of the
336
+ guard, the thresholds, the installed detectors, the range sets, the audit checks and
337
+ the dashboard's own controls. It is the file you want when comparing two deployments
338
+ six months from now.
339
+
340
+ **Import** takes that file back — or a bare array of rules — by button or by dropping
341
+ it on the editor. It loads into the editor and applies *nothing*: an import that took
342
+ effect on drop would be a policy change made by a mis-drag. Review it, preview it
343
+ against real traffic, then apply.
344
+
345
+ Only the rules half is importable, and the file says so. The guard, the detectors and
346
+ the ranges come from the code that constructed the handler; a settings file that
347
+ appeared to carry them would be promising something it cannot deliver.
348
+
349
+ What it deliberately cannot do:
350
+
351
+ - **Relax the guard.** `falsePositivePolicy`, `fallbackAction`, `defaultAction` and
352
+ `terminalScoreThreshold` are not read from the submitted document, and `editPolicy`
353
+ confers no power over them. Write `action: "block"` on a probabilistic rule through
354
+ the editor and the guard downgrades it exactly as it would have downgraded it in your
355
+ config file. Changing the guard is a *different* permission with a different flag and
356
+ a different endpoint — see below — and it is off unless somebody turned it on.
357
+ - **Delete a rule it cannot see.** A rule whose `match` is a predicate function cannot
358
+ be serialised; those are shown read-only and spliced back into their original
359
+ positions on save. Order is the whole semantics of a first-match policy.
360
+ - **Exist unauthenticated on a public address.** `editPolicy` with `auth: false` on a
361
+ non-loopback bind is refused at startup: that combination is not a feature, it is a
362
+ stranger's bot policy.
363
+
364
+ Leave it off in production unless you have a reason, and the reason should include who
365
+ is allowed to press it.
366
+
367
+ ### Reconnecting costs a handful of frames
368
+
369
+ Every frame a viewer can miss carries an id, and `EventSource` hands the last one it
370
+ saw back as `Last-Event-ID` when it reconnects, unasked. So a laptop lid, a proxy
371
+ timing out an idle stream or a five-second blip costs the frames that were actually
372
+ missed — not the whole ring, which is five hundred entries with their headers, evidence
373
+ and actor history attached, per viewer, per blip. A cursor the server cannot honour
374
+ (a restarted process, a cleared feed, a gap longer than the ring) gets the backlog and
375
+ an instruction to replace what the page is holding, because leaving a viewer with rows
376
+ nothing will ever correct is worse than resending.
377
+
378
+ Pressing **Reset** tells every viewer, not just the browser that pressed it.
379
+
380
+ A busy origin is a firehose — every assessment to every open browser — so the stream is
381
+ capped at `maxEventsPerSecond` (100 by default, `0` to remove it). What is capped is the
382
+ *stream*: the ring keeps everything, so the preview, the export and anyone reconnecting
383
+ still see every request, and the feed says how many were not streamed. A thinned feed
384
+ must never look like a quiet one.
385
+
386
+ **A viewer that stops reading is not allowed to cost you memory.** A socket that has
387
+ stopped draining — a laptop that slept with the tab open, a phone in a tunnel, a proxy
388
+ that stopped reading — used to accumulate frames in this process, one queue per viewer,
389
+ without limit; the rate cap does not help, because that bounds a rate and this is a
390
+ backlog. A stream that reports itself full now stops being sent feed entries until it
391
+ drains, the skipped frames are counted, and the viewer is told the count when it catches
392
+ up. One that never drains is ended after twenty seconds, and the browser reconnects and
393
+ resumes from its cursor — which is what makes dropping it safe.
394
+
395
+ ### What somebody did, on the same axis as what happened
396
+
397
+ Every runtime change — a policy applied, the guard moved, a range set edited, an actor
398
+ forgotten — leaves a marker on the traffic timeline, with the time, what changed and who
399
+ asked for it. That is what turns a preview from a prediction into something you can
400
+ check: *"44 of 151 requests would be treated differently"* is a claim, and a line on the
401
+ chart at the moment it was applied, with the traffic either side of it, is the answer.
402
+
403
+ ### Reachable without a mouse, and without a screen
404
+
405
+ The tab strip honours the keyboard contract its `role="tablist"` promises: arrow keys,
406
+ Home/End, one stop in the tab order. Every feed row's method-and-path is a real
407
+ disclosure button — the row used to *be* the button, with the actor link inside it,
408
+ which is a control nested in a control and leaves a screen reader with two things to
409
+ announce and no way to say which one `Enter` belongs to. Clicking anywhere in the row
410
+ still opens it, because a click is a convenience rather than a contract.
411
+
412
+ Each chart carries its data in words. `role="img"` with a name says a picture is here
413
+ and what it is called; it says nothing about what is *in* it, so every number on the
414
+ Statistics screen used to be unreachable to a reader who cannot see the bars. The
415
+ traffic chart, the score distribution and the latency histogram now each have a
416
+ description naming their totals, their bands and — for traffic — the runtime changes
417
+ marked on them.
418
+
419
+ `npm run test:browser` runs axe against all four screens on every change and fails on
420
+ anything it rates serious or critical. It is what found the two defects above.
421
+
422
+ ### One dashboard, one process — and the others
423
+
424
+ `peers` puts the sibling instances in the header:
425
+
426
+ ```ts
427
+ peers: [
428
+ { label: "web-1", href: "https://web-1.internal:9674/" },
429
+ { label: "web-2", href: "https://web-2.internal:9674/" },
430
+ ],
431
+ ```
432
+
433
+ That is the honest amount of help this page can give with a fleet: a way to reach the
434
+ other ones. It aggregates nothing, deliberately. A feed and an actor registry summed
435
+ across pods would be a different tool with a shared store behind it, and the counters —
436
+ the part of this that genuinely wants aggregating — already go to Prometheus, which is
437
+ a thing your monitoring does better than a page could.
438
+
439
+ ### It reports on one process
440
+
441
+ **This is the boundary most likely to mislead you.** A dashboard subscribes to the
442
+ handler in *its own process*. Behind a load balancer with eight pods there are eight
443
+ rings, eight sets of counters and eight actor registries, and the one you have open is
444
+ showing you an eighth of your traffic — including its Actors screen, its rate limits and
445
+ its allowlist edits, which land on that instance and nowhere else.
446
+
447
+ The header names the instance (`instance`, defaulting to the hostname) so a partial
448
+ picture does not look like a whole one. Aggregating across a fleet would mean a shared
449
+ store and a different tool; what this is for is looking at one process closely.
450
+
451
+ The feed is memory-only, too: a bounded ring of the last `feedLimit` requests, gone when
452
+ the process restarts. "What happened last night" is not a question it can answer — send
453
+ `onAssessment` or a [notification sink](notifications.md) somewhere durable
454
+ for that.
455
+
456
+ ### Mounting it on a server you already have
457
+
458
+ ```ts
459
+ import { createDashboardHandler } from "@osqd/bothandlerjs";
460
+
461
+ const dashboard = createDashboardHandler(botHandler, {
462
+ basePath: "/_bots",
463
+ auth: { authorize: (req) => sessionFrom(req)?.email ?? false },
464
+ allowedClients: ["10.0.0.0/8"],
465
+ });
466
+
467
+ https.createServer(tls, (req, res) => {
468
+ if (req.url?.startsWith("/_bots")) return dashboard(req, res);
469
+ return app(req, res);
470
+ }).listen(443);
471
+ ```
472
+
473
+ `startDashboard` opens a plain HTTP listener of its own. That is right on a laptop and
474
+ wrong in a lot of production networks: the certificate lives at an ingress, everything
475
+ has to be reachable under one hostname, or the platform exposes exactly one port.
476
+ `createDashboardHandler` is the same dashboard without the socket — a `(request,
477
+ response)` function you can mount wherever you already terminate TLS.
478
+
479
+ Two things differ, and both follow from not owning the socket:
480
+
481
+ - **`auth` is required**, including the explicit `auth: false`. The listening form may
482
+ skip it on `127.0.0.1` because the operating system is then the access control; here
483
+ there is no bind address to inspect, so nothing can be assumed and what is assumed is
484
+ "public". Same for the editors: `editPolicy`, `editGuard` and `editRanges` with
485
+ `auth: false` are refused, exactly as they are on a public bind.
486
+ - **`close()` does not close a server it does not own.** It unsubscribes from the
487
+ engine, ends every event stream and stops the timers. Your server is yours.
488
+
489
+ `basePath` is the path the page is served under, because that is what the page needs in
490
+ order to build its own URLs. Routing accepts the path with or without that prefix, so it
491
+ works whether or not your framework strips the mount point first — `app.use("/_bots",
492
+ dashboard)` in Express and a bare `if (url.startsWith("/_bots"))` both do the right
493
+ thing. The `Host` check is enforced only if you pass `allowedHosts`, since the server
494
+ that owns the socket is the thing that knows which names reach it.
495
+
496
+ What this does *not* change is the reason the dashboard is separate from the application
497
+ it reports on: **mount it on a server that does not run your bot handler.** Serving it
498
+ from inside the application means reading the dashboard shows up in the dashboard, and a
499
+ challenge served to your site can lock you out of the tool you are using to read about
500
+ it. `examples/dashboard-mounted.ts` is the whole arrangement in one file.
501
+
502
+ ### Its own listener, and why
503
+
504
+ The dashboard never mounts inside the application it reports on. That arrangement has
505
+ three separate failure modes: reading the dashboard shows up in the dashboard, a
506
+ challenge served to your site can lock you out of the tool you are using to read about
507
+ it, and the page inherits whatever authentication your public site happens to have. A
508
+ second port costs nothing and avoids all three.
509
+
510
+ ### It refuses to start in an unsafe configuration
511
+
512
+ The page lists client addresses and names the exact signal that fired on each request.
513
+ That is a *tuning guide for whoever is scraping you*: it tells them which check to fix
514
+ next. So the defaults are cautious and the unsafe combinations do not start at all.
515
+
516
+ ```ts
517
+ await botHandler.serveDashboard({ host: "0.0.0.0" });
518
+ // ConfigError: … publishes it beyond this machine, and no `auth` was configured.
519
+ // Configure auth: { username, password }, auth: { token } or
520
+ // auth: { authorize }; keep the default host: "127.0.0.1";
521
+ // or write auth: false to state that something in front of it
522
+ // already authenticates.
523
+ ```
524
+
525
+ | | |
526
+ | --- | --- |
527
+ | **Binds loopback** | `host` defaults to `127.0.0.1`. Anything else needs an explicit `auth`, including the explicit `auth: false`. |
528
+ | **Credentials compared in constant time** | Both halves of a basic credential, and the whole of a token. A wrong username is indistinguishable from a wrong password. |
529
+ | **Authentication before routing** | An unauthenticated probe gets `401` for every path, so it cannot even map the endpoints. |
530
+ | **Writes must come from this page** | `Sec-Fetch-Site` must say `same-origin` or `none`, `Origin` (when there is no fetch metadata) must name this server, and a body must be `application/json` — which an HTML form cannot send. Credentials cannot decide this: a browser attaches them to somebody else's forged form as willingly as to a real request. Clients with no browser provenance at all — curl, a deploy script — still work. |
531
+ | **Answers only to names you wrote down** | On loopback the `Host` header must be `localhost`, `127.0.0.1`, `[::1]` or something in `allowedHosts`; anything else gets `421`. This is the lock on DNS rebinding, where a name the attacker owns resolves to `127.0.0.1` and every check above agrees it is same-origin. |
532
+ | **Reset is off** | Clearing the actor registry discards real state — rate series, cadence, clearances. Opt in with `controls: { reset: true }`. |
533
+ | **The editor is off** | And when on, it can change which rules exist and nothing about how far one may go. `controls: { editPolicy: true }`. |
534
+ | **A wrong password is slowed down** | Five failures from an address and the next attempt is refused for a delay that doubles each time, up to five minutes. A success clears it. Under a silent `refusal` the lockout is silent too, because a `429` would confirm there is a credential here worth guessing. |
535
+ | **Acting on a client is off** | `controls: { editRanges: true }`, refused unauthenticated on a public bind and unavailable under `redact.maskIp`. |
536
+ | **The guard editor is off, separately** | `controls: { editGuard: true }` is a different flag for a different power, refused unauthenticated on a public bind like the other one. A terminal `fallbackAction` is refused whatever it is set to. |
537
+ | **Strict CSP, fresh nonce per response** | `default-src 'none'`, no remote script, style, font or image, `frame-ancestors 'none'`, `no-store`. |
538
+ | **No `innerHTML`, anywhere** | Every value on the page — User-Agents, paths, evidence summaries — is client-written text, and reaches the document through `textContent`. A test asserts the string never appears. |
539
+
540
+ ### Options
541
+
542
+ | Option | Default | |
543
+ | ------ | ------- | --- |
544
+ | `port` | `9674` | `0` binds an ephemeral port; read the real one back from `dashboard.port`. |
545
+ | `host` | `"127.0.0.1"` | See above. |
546
+ | `auth` | none on loopback | `{ username, password }`, `{ token }`, `{ authorize(req) }`, or `false`. |
547
+ | `basePath` | `"/"` | Mount under a prefix, e.g. `"/_bots"`. |
548
+ | `title` / `links` | `"bothandlerjs"` | Header name, and links back to your site or runbook. |
549
+ | `feedLimit` | `500` | Requests kept in the ring, capped at 5000. |
550
+ | `maxClients` | `16` | Concurrent viewers; beyond it the stream answers `503`. |
551
+ | `maxEventsPerSecond` | `100` | Entries per second pushed to each viewer; `0` removes the cap. The surplus stays in the window and the page says how much it was. |
552
+ | `feedTtlMs` | `3600000` | How long a request may stay in the feed. A retention promise, where `feedLimit` is a capacity bound; `0` keeps them until the ring evicts them. |
553
+ | `allowedClients` | none | Addresses or CIDRs that may reach this dashboard at all, checked before authentication. A layer on top of `auth`, not a replacement. |
554
+ | `authThrottle` | on | Backoff after repeated failed credentials, per address: `{ maxAttempts: 5, lockoutMs: 1000, maxLockoutMs: 300000 }`, or `false`. |
555
+ | `peers` | none | Sibling instances, as `{ label, href }`, offered in the header. Aggregates nothing; see below. |
556
+ | `instance` | hostname | Which process this is, shown in the header. See below. |
557
+ | `controls.reset` | `false` | Enables the Reset button. |
558
+ | `controls.editPolicy` | `false` | Enables applying rule changes to the running handler. See above. |
559
+ | `controls.editGuard` | `false` | Enables changing the guard on the running handler. Separate from `editPolicy` on purpose. See above. |
560
+ | `controls.editRanges` | `false` | Enables allowlisting an address, forgetting an actor, and granting clearance. Unavailable under `redact.maskIp`. See above. |
561
+ | `sections` | all on | Which parts of the page this listener has, enforced on the server as well as the page. See above. |
562
+ | `basePath` | `"/"` | Also what a mounted dashboard tells the page it is served under. |
563
+ | `refusal` | `"unauthorized"` | What a caller this dashboard will not serve is told: `"unauthorized"` (401), `"not-found"` (404, identical to an unknown path), `"close"` (drop the connection), or `{ redirect, status? }`. See below. |
564
+ | `allowedHosts` | loopback names | Extra `Host` values a loopback dashboard answers to, for reaching it through a name of your own — an SSH tunnel aliased in `/etc/hosts`, say. `"*"` turns the check off. Ignored on a public bind. |
565
+ | `redact.maskIp` | `false` | Show `203.0.113.0/24` instead of the address. Turn it on when the dashboard is shared more widely than your logs. |
566
+ | `redact.truncateUserAgent` | `false` | Keep only the first 48 characters. |
567
+ | `redact.maskQuery` | `true` | Show query parameter names, not values. Reset tokens live in query strings. |
568
+ | `redact.headers` | `true` | Show the request headers in the row detail. Credentials are always stripped. |
569
+ | `exposePrometheus` | `false` | Serves `<basePath>/metrics` behind the same auth. |
570
+
571
+ `{ token }` is also accepted as `?token=…` so a link can be opened directly — which
572
+ puts the secret in browser history and in every proxy log on the way. Fine for a
573
+ laptop; use the `Authorization` header anywhere else.
574
+
575
+ ### Behind your own gateway
576
+
577
+ `auth: { authorize }` receives the raw `IncomingMessage`, so a header your gateway
578
+ sets, a session cookie, or an mTLS subject all work. A check that throws is a check
579
+ that failed — never an open door.
580
+
581
+ ```ts
582
+ await botHandler.serveDashboard({
583
+ host: "0.0.0.0", // behind an authenticating proxy
584
+ auth: { authorize: (req) => req.headers["x-forwarded-user"] !== undefined },
585
+ redact: { maskIp: true },
586
+ });
587
+ ```
588
+
589
+ ### Getting around it
590
+
591
+ | | |
592
+ | --- | --- |
593
+ | `1` `2` `3` `4` | Live feed, Actors, Statistics, Policy — indexing the tabs this listener actually has |
594
+ | `Tab` into a row | Each row's method-and-path is its disclosure button; `Enter` or `Space` opens the evidence |
595
+ | `←` `→` `Home` `End` | Move along the tab strip |
596
+ | `/` | Jump to the filter |
597
+ | `Enter` / `Space` | Open the focused request's evidence |
598
+ | `Escape` | Clear the filter, then close what is open |
599
+ | `Tab` | The first stop is a skip link past the header |
600
+
601
+ The view is in the URL, so `…:9674/#policy` opens on the policy screen, the back
602
+ button moves between views rather than leaving the page, and a refresh — the reflex
603
+ when a live feed looks stuck — keeps your place.
604
+
605
+ ### Saying less to a stranger
606
+
607
+ `401` is honest, and honest is usually right: someone who mistyped a password needs to
608
+ read that. It also confirms, to anyone sweeping a port range, that this address runs an
609
+ administrative page worth returning to. `refusal` decides which of those matters more
610
+ to you.
611
+
612
+ ```ts
613
+ await botHandler.serveDashboard({
614
+ auth: { token: process.env.DASHBOARD_TOKEN! },
615
+ refusal: "not-found", // a probe sees a server that has never heard of this path
616
+ });
617
+ ```
618
+
619
+ | | What a refused caller gets |
620
+ | --- | --- |
621
+ | `"unauthorized"` | `401`, with `WWW-Authenticate` under basic auth. The default. |
622
+ | `"not-found"` | `404`, byte-identical to the answer for a path that does not exist here. |
623
+ | `"close"` | Nothing. The connection is destroyed, the way a dropping firewall behaves. |
624
+ | `{ redirect, status? }` | `302` (or `303`/`307`/`308`) to wherever you send people to sign in. |
625
+
626
+ Anything other than the default also **collapses the three pre-routing refusals into
627
+ one answer** — a failed `auth`, a `Host` outside `allowedHosts`, a cross-site write.
628
+ That is the property that makes concealment worth anything: a probe that can tell
629
+ "wrong host" from "wrong password" has just learned a password exists. Under the
630
+ default they keep their distinct statuses (`401`, `421`, `403`) on purpose, because the
631
+ likelier reader is an operator debugging their own deployment and three statuses are
632
+ three diagnoses.
633
+
634
+ Two things to be clear about.
635
+
636
+ **This is concealment, not access control.** A dashboard answering `404` to the wrong
637
+ credentials is exactly as reachable by someone holding the right ones, and exactly as
638
+ exposed if those leak. It raises the cost of *finding*, which is worth something
639
+ against indiscriminate scanning and close to nothing against somebody who already knows
640
+ where to look. It is a layer on top of `auth`, never a replacement for it.
641
+
642
+ **A silent refusal and basic auth cannot both work.** A browser prompts for a password
643
+ because a `401` asked it to; answer `404` and no prompt ever appears, so the credential
644
+ the server is waiting for can never be typed. That combination is refused at startup
645
+ rather than at the moment somebody needs the page — use `{ token }` or `{ authorize }`
646
+ with a link people already hold. A redirect is fine alongside basic auth, since a
647
+ prompt is still reachable at the other end.
648
+
649
+ ### Roles
650
+
651
+ `authorize` is deliberately a *binary* question — may this request touch the dashboard
652
+ at all? There is no role model in here, no user table, no session store, because every
653
+ one of those already exists in your application and a second copy that disagrees with
654
+ the first is worse than none. What you supply is a predicate; what it reads is up to
655
+ you.
656
+
657
+ That gets you "only admins may open it" in one line:
658
+
659
+ ```ts
660
+ auth: { authorize: (req) => sessionFrom(req)?.role === "admin" }
661
+ ```
662
+
663
+ Two roles with *different powers* — or different **views** — is a second listener
664
+ rather than a cleverer predicate, because `controls` and `sections` are fixed when the
665
+ listener starts and not evaluated per request. That is the trade deliberately: no
666
+ per-request role evaluation, no session store in here, no second copy of your user
667
+ table to disagree with the first. You bring the roles; each listener is the surface one
668
+ role gets.
669
+
670
+ ```ts
671
+ // Analysts look, and not at everything. `controls` is absent, so the server answers
672
+ // 403 to the editor and the reset endpoint — not a hidden button, a closed door — and
673
+ // `sections` takes the evidence and the policy off this listener entirely.
674
+ const viewer = await botHandler.serveDashboard({
675
+ port: 9684,
676
+ auth: { authorize: (req) => ["analyst", "admin"].includes(sessionFrom(req)?.role) },
677
+ sections: { evidence: false, policy: false },
678
+ redact: { maskIp: true },
679
+ });
680
+
681
+ // Operators tune the rules. The guard is still fixed for them.
682
+ const operator = await botHandler.serveDashboard({
683
+ port: 9685,
684
+ auth: { authorize: (req) => ["operator", "admin"].includes(sessionFrom(req)?.role) },
685
+ controls: { editPolicy: true, reset: true },
686
+ });
687
+
688
+ // Admins can also change what a rule is allowed to do.
689
+ const admin = await botHandler.serveDashboard({
690
+ port: 9686,
691
+ auth: { authorize: (req) => sessionFrom(req)?.role === "admin" },
692
+ controls: { editPolicy: true, editGuard: true, reset: true },
693
+ });
694
+ ```
695
+
696
+ `serveDashboard` keeps no singleton state, so all three stay live against the same
697
+ engine, each reporting on it and each with its own surface.
698
+
699
+ `npm run demo:roles` is this, working, with a signed session cookie and an operator
700
+ console to pick a role at — see [Try it](../testing/try-it.md#role-gated-dashboards).
701
+
702
+ Three things that demo exists to show you before you build it yourself:
703
+
704
+ - **A custom `authorize` sends no `WWW-Authenticate`,** so the browser puts up no login
705
+ prompt. The 401 is bare, and it is bare on every path, since authentication runs
706
+ before routing. Send people to your own sign-in page; do not expect the browser to.
707
+ - **Cookies are scoped by host, not by origin.** One sign-in on `localhost:9683`
708
+ covers `:9684` and `:9685` because the port is not part of a cookie's identity.
709
+ Convenient on a laptop, and the reason a stray service on the same host can read a
710
+ session cookie your app set.
711
+ - **A session cookie is not what stops a forged write.** The browser attaches it to a
712
+ cross-site request as willingly as to a real one, which is why the dashboard checks
713
+ `Sec-Fetch-Site` and insists on a JSON body independently of who you are.
714
+
715
+ The demo runs exactly this dashboard — `npm run demo` calls `serveDashboard()` — so
716
+ what you see at `localhost:9674` is what you get in your own application.
717
+
718
+ ---
719
+
720
+ ## Related
721
+
722
+ - [Operations](index.md) — events, metrics, the audit
723
+ - [Runtime changes](runtime-changes.md) — what the editor and the guard panel actually call
724
+ - [Try it](../testing/try-it.md) — the demo, including the role-gated dashboards
725
+