@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,211 @@
1
+ # Lesson 12 — Going live
2
+
3
+ **Goal:** mount Serif behind a real framework, and get the one setting right that is
4
+ dangerous to get wrong.
5
+
6
+ ← [Course](index.md) · Prev: [The challenge](11-the-challenge.md) · Next: [Operating it](13-operating-it.md)
7
+
8
+ ---
9
+
10
+ ## Three lines
11
+
12
+ ```js
13
+ import { BotHandler } from "@osqd/bothandlerjs";
14
+ import { botHandler } from "@osqd/bothandlerjs/adapters";
15
+
16
+ const detector = new BotHandler({ preset: "monitor-only" });
17
+ app.use(botHandler(detector));
18
+ ```
19
+
20
+ Four adapters ship:
21
+
22
+ ```js
23
+ import { botHandler, fastifyBotHandler, koaBotHandler, withBotHandler } from "@osqd/bothandlerjs/adapters";
24
+
25
+ app.use(botHandler(detector)); // Express / Connect / node:http
26
+ fastify.addHook("onRequest", fastifyBotHandler(detector));
27
+ app.use(koaBotHandler(detector));
28
+ export default { fetch: withBotHandler(detector, myHandler) }; // Workers / Deno / Bun / Edge
29
+ ```
30
+
31
+ **Hono and Next.js need no adapter of their own.** Both speak the platform's `Request` and
32
+ `Response`, which is what `createFetchAdapter` takes and returns:
33
+
34
+ ```js
35
+ const guard = createFetchAdapter(detector, { ipHeaders: ["cf-connecting-ip"] });
36
+
37
+ // Hono
38
+ app.use(async (c, next) => {
39
+ const decision = await guard(c.req.raw, c.env);
40
+ if (decision.response) return decision.response;
41
+ await next();
42
+ });
43
+
44
+ // Next.js — middleware.ts
45
+ export async function middleware(request) {
46
+ const decision = await guard(request);
47
+ if (decision.response) return decision.response;
48
+ return NextResponse.next({ request: { headers: decision.request.headers } });
49
+ }
50
+ ```
51
+
52
+ Passing `decision.request.headers` on is the part worth doing: it carries the verdict to
53
+ routes further in, so they need not assess anything themselves.
54
+
55
+ ## Why the engine never touches a response
56
+
57
+ `handle` returns an `ActionOutcome` — `continue`, `respond`, or `drop` — and the adapter
58
+ applies it. That is why the same policy behaves identically on Express and on a Worker, why
59
+ `assess` is safe to run over a log file, and why writing your own adapter is about thirty
60
+ lines.
61
+
62
+ It also **fails open**. An unexpected failure inside detection serves the request; the error
63
+ goes to your `onError` and the visitor gets their page. A bot filter that fails closed is an
64
+ outage with extra steps.
65
+
66
+ ## Now the important part
67
+
68
+ ### The client IP
69
+
70
+ Stop here if Serif sits behind anything — a load balancer, a CDN, nginx, a service mesh.
71
+
72
+ The client address becomes the [actor key](07-actors.md). Every rate limit, every allowlist
73
+ entry, every behavioural signal depends on it.
74
+
75
+ `X-Forwarded-For` is a **client-supplied header**. Trust it without knowing how many proxies
76
+ sit in front of you and anyone can prepend a fake hop and choose the address you rate-limit,
77
+ allowlist and block on. **The failure is silent** — nothing errors, nothing logs, and every
78
+ per-actor mechanism becomes an attacker input.
79
+
80
+ There is deliberately no convenient default.
81
+
82
+ ```js
83
+ proxy: { trustProxy: true, trustedProxies: ["10.0.0.0/8"] } // recommended
84
+ proxy: { trustProxy: true, hops: 2 } // correct only while the count is
85
+ proxy: { trustProxy: false } // default — header ignored entirely
86
+ ```
87
+
88
+ **Use `trustedProxies`.** The chain is walked from the right, discarding your own
89
+ infrastructure, and the first address outside it is the client — robust against an extra hop
90
+ appearing when somebody adds a CDN and forgets to tell you.
91
+
92
+ `hops` is right until the topology changes, and then it is wrong in the direction that lets
93
+ clients choose their own address.
94
+
95
+ Three details worth knowing:
96
+
97
+ **Addresses are compared as bytes.** `::ffff:127.0.0.1`, `0177.0.0.1` and `127.0.0.001` are
98
+ the same address, and all slip past an allowlist that compares strings. Invalid CIDRs throw
99
+ at construction rather than matching nothing silently.
100
+
101
+ **The connecting peer is checked too.** On a server reachable both through the load balancer
102
+ and directly, a request arriving from outside your trusted ranges did not come through your
103
+ proxies — so its forwarded header is not evidence, and the socket address is used. Without
104
+ that check, anyone who finds the origin address picks their own client IP.
105
+
106
+ **Do not allowlist loopback.** The moment you sit behind nginx or beside a sidecar, every
107
+ request in the world arrives from `127.0.0.1`.
108
+
109
+ ### On Fetch runtimes
110
+
111
+ There is no socket, so the address comes from a header. Only `cf-connecting-ip` and
112
+ `x-real-ip` are trusted by default — both single-valued, written by the edge that terminated
113
+ the connection. `x-forwarded-for` is **not**, because a proxy *appends* to it.
114
+
115
+ Better still, ask the platform:
116
+
117
+ ```js
118
+ withBotHandler(detector, myHandler, {
119
+ clientIp: (request, env) => env.cf?.connectingIp,
120
+ });
121
+ ```
122
+
123
+ If no address can be found, every visitor is tracked under one empty actor key — which
124
+ applies your rate limits to the whole site at once. The adapter warns the first time.
125
+
126
+ On Workers, enable `nodejs_compat`; the engine uses `node:crypto`.
127
+
128
+ ## Tell it about your own people
129
+
130
+ The only conclusive human signal in this library is your assertion:
131
+
132
+ ```js
133
+ new BotHandler({
134
+ isHuman: (facts) => Boolean(sessions.get(facts.session ?? "")?.authenticated),
135
+ });
136
+ ```
137
+
138
+ That produces `certain` human evidence, which is what the `cleared-human-allow` rule at the
139
+ top of most presets is for. Without it that rule never fires, and your signed-in customers
140
+ are judged like strangers.
141
+
142
+ ## Serif, mounted
143
+
144
+ ```js
145
+ import express from "express";
146
+ import { BotHandler } from "@osqd/bothandlerjs";
147
+ import { botHandler } from "@osqd/bothandlerjs/adapters";
148
+
149
+ const site = new BotHandler({
150
+ preset: "monitor-only", // week one. Enforce later.
151
+ proxy: { trustProxy: true, trustedProxies: ["10.0.0.0/8"] },
152
+ allowlist: ["10.0.0.0/8"], // monitors and CI
153
+ ignorePaths: ["/healthz", "/metrics"],
154
+ isHuman: (facts) => Boolean(sessions.get(facts.session ?? "")?.authenticated),
155
+ challenge: { secrets: [process.env.SERIF_CHALLENGE_SECRET], contactHtml: "<p>…</p>" },
156
+ onWarning: (message) => console.warn("[bot]", message),
157
+ onDowngrade: ({ decision }) => console.warn(`[bot] guard stopped rule ${decision.rule}`),
158
+ });
159
+
160
+ const auth = new BotHandler({ preset: "protect-auth", proxy: { trustProxy: true, trustedProxies: ["10.0.0.0/8"] } });
161
+
162
+ const app = express();
163
+ app.use(botHandler(site));
164
+ app.use("/login", botHandler(auth));
165
+ app.use("/checkout", botHandler(auth));
166
+ ```
167
+
168
+ Note `allowlist` and `ignorePaths` are different things. An **ignored path** is not
169
+ assessed. An **allowlisted address** is not judged *at all* — not judged leniently, not
170
+ judged. It is the strongest setting in the library, and the one most worth alerting on when
171
+ it changes.
172
+
173
+ ## Exercise
174
+
175
+ Serif runs on Cloudflare in front of a Node origin. A colleague suggests
176
+ `proxy: { trustProxy: true, hops: 1 }`. What is wrong with it, and what breaks?
177
+
178
+ <details>
179
+ <summary>Answer</summary>
180
+
181
+ `hops: 1` takes the **last** entry in `X-Forwarded-For`, which is what Cloudflare appended —
182
+ so today it is right. It breaks the moment anything is added in front: another CDN, a
183
+ regional load balancer, a WAF. Then the last entry is that hop's, and Cloudflare's entry —
184
+ the real client — is one further left.
185
+
186
+ Worse, the failure is silent and the wrong direction: the address you rate-limit becomes one
187
+ your own infrastructure controls, so all traffic collapses onto one actor.
188
+
189
+ ```js
190
+ proxy: { trustProxy: true, trustedProxies: [...cloudflareRanges, "10.0.0.0/8"] }
191
+ ```
192
+
193
+ Walking from the right and discarding known infrastructure survives an extra hop appearing.
194
+ And on the Cloudflare edge itself, prefer `clientIp: (req, env) => env.cf?.connectingIp` —
195
+ ask the platform, not a header.
196
+ </details>
197
+
198
+ ## What you learned
199
+
200
+ - Four adapters, plus Hono and Next.js for free; the engine never touches a response
201
+ - It fails open, on purpose
202
+ - `trustedProxies` over `hops`, always; the failure mode of getting it wrong is silent
203
+ - `isHuman` is the only conclusive human signal that exists
204
+ - Allowlisting stops detection entirely — it is not leniency
205
+
206
+ ## Reference
207
+
208
+ - [Adapters](../integration/adapters.md) · [The client IP](../integration/client-ip.md)
209
+ - [Configuration](../reference/configuration.md)
210
+
211
+ Next: [Operating it](13-operating-it.md).
@@ -0,0 +1,238 @@
1
+ # Lesson 13 — Operating it
2
+
3
+ **Goal:** see what the policy is doing to real traffic, and know which two numbers to alert
4
+ on.
5
+
6
+ ← [Course](index.md) · Prev: [Going live](12-going-live.md) · Next: [Scaling](14-scaling.md)
7
+
8
+ ---
9
+
10
+ ## The dashboard
11
+
12
+ Counters tell you *how much*. The dashboard tells you **which requests, and why** — every
13
+ assessment as it lands, and on any row you open, the individual evidence with its certainty
14
+ tier and, for proven ones, the written basis.
15
+
16
+ ```js
17
+ const dashboard = await detector.serveDashboard({
18
+ port: 9674,
19
+ title: "serif",
20
+ auth: { username: "ops", password: process.env.DASHBOARD_PASSWORD },
21
+ });
22
+ console.log(dashboard.url);
23
+ ```
24
+
25
+ That is the whole integration: it listens on a port of its own, subscribes to the handler
26
+ you called it on, and returns a handle with a `close()`.
27
+
28
+ Four screens — the live feed, actors, statistics, and the policy editor. Open a row and you
29
+ see the evidence that produced the verdict; that is the view that answers "why was this
30
+ customer challenged?".
31
+
32
+ ### Before it goes anywhere real
33
+
34
+ **It reports on one process.** Behind a load balancer with eight pods you are looking at an
35
+ eighth of your traffic. The page names the instance, which is the honest amount of help it
36
+ can give.
37
+
38
+ **Its feed is memory-only** and bounded. It is a live view, not a log.
39
+
40
+ **Everything it can *do* is behind a flag**, and each flag is separate:
41
+
42
+ ```js
43
+ controls: { reset: true, editPolicy: true, editGuard: true, editRanges: true }
44
+ ```
45
+
46
+ `editGuard` is separate from `editPolicy` deliberately: a rule that overreaches is stopped
47
+ by the guard, and this changes whether anything stops it. They read like similar powers and
48
+ are not.
49
+
50
+ **What it *shows* is separate again** — `sections` — and enforced on the server, so a
51
+ switched-off section's endpoint answers 403 and its fields never leave the process:
52
+
53
+ ```js
54
+ // What an analyst gets: the shape of the traffic, without naming individuals.
55
+ await detector.serveDashboard({
56
+ port: 9684,
57
+ sections: { evidence: false, policy: false },
58
+ redact: { maskIp: true },
59
+ });
60
+ ```
61
+
62
+ That combination is worth understanding. The dashboard people watch all day need not be the
63
+ one that names individuals, or the one that explains your detection to whoever is scraping
64
+ you.
65
+
66
+ ## Metrics
67
+
68
+ On by default; a handful of integer increments per request.
69
+
70
+ ```js
71
+ app.get("/internal/metrics", (_req, res) => res.type("text/plain").send(detector.prometheus()));
72
+ ```
73
+
74
+ **Serve it where only you can reach it.** The detector-firing series describe how your
75
+ detection behaves, which is exactly what somebody tuning a scraper would like to read.
76
+
77
+ ### The two to alert on
78
+
79
+ **`bothandler_downgrades_total`** — rules that asked to deny and were refused for lack of
80
+ proof. You met it in [lesson 4](04-the-guard.md). A rising count means your policy is asking
81
+ for something the evidence does not support.
82
+
83
+ **`bothandler_verdicts_total{verdict="unknown"}`** — ordinary traffic. If this falls, either
84
+ your traffic changed or your detection did, and you want to know which.
85
+
86
+ ### The one to read before moving a threshold
87
+
88
+ **`bothandler_score_bucket`** — how suspicion is distributed across everything scored, in
89
+ ten buckets of ten points. Proven assessments are counted separately by
90
+ `bothandler_proven_total`; their score is 100 by definition and would put a meaningless
91
+ spike at the top.
92
+
93
+ Moving `suspectThreshold` or a rule's `minScore` without looking at this is guessing. The
94
+ histogram tells you how many requests sit in the ten points you are about to cross.
95
+
96
+ ### Per-detector timing is available and off
97
+
98
+ Timing each detector costs two clock reads per detector per request — forty on a
99
+ twenty-detector set, to measure work counted in microseconds. Worth paying while you tune:
100
+
101
+ ```js
102
+ new BotHandler({ metrics: { perDetectorTiming: true } });
103
+ ```
104
+
105
+ It is how you find out that one `io` detector costs more than the other nineteen together.
106
+
107
+ ## The traffic audit
108
+
109
+ Counters cannot tell you a number is *unusual*, and bot traffic is not a level — it is an
110
+ event. The number that matters is not "12% of requests are bots" but "12% now, 2% for the
111
+ hour before".
112
+
113
+ ```js
114
+ new BotHandler({
115
+ audit: {
116
+ windowMs: 5 * 60_000,
117
+ baselineMs: 60 * 60_000,
118
+ minSamples: 50,
119
+ cooldownMs: 15 * 60_000,
120
+ },
121
+ onAnomaly: (anomaly) => pager.send(anomaly.severity, anomaly.summary),
122
+ });
123
+ ```
124
+
125
+ **The baseline ends where the window begins.** A baseline containing the window would be
126
+ partly made of the thing being measured, and a large enough spike would raise its own bar
127
+ until it stopped looking like one.
128
+
129
+ | Check | Fires when |
130
+ | ----- | ---------- |
131
+ | `bot-share-spike` | automation is a much larger share than the baseline |
132
+ | `traffic-spike` | volume far above the baseline rate |
133
+ | `denial-spike` | a much larger share is being denied — **read these before assuming they are all bots** |
134
+ | `guard-stop-spike` | the guard is refusing far more rules than usual |
135
+ | `human-share-drop` | traffic reading as human has fallen away |
136
+ | `detector-failures` | detectors erroring or timing out |
137
+ | `challenge-solve-rate` | nearly everything challenged is passing — **high is the bad direction** |
138
+
139
+ That last one is the closest to this library's thesis. Every other check asks whether the
140
+ *traffic* changed shape; this asks whether the mitigation is landing on the right
141
+ population. A proof of work is trivial for a browser and trivial for a competent scraper —
142
+ what it actually costs is a few seconds of somebody's afternoon. So when nearly everything
143
+ challenged goes on to pass, the challenges are not filtering bots out, they are **taxing
144
+ people**. From every other angle a solved challenge looks like a challenge that worked,
145
+ which is why it needs saying out loud.
146
+
147
+ Every check has a **floor** as well as a ratio — a quiet site at 3am produces "800% more
148
+ bots" from four requests, and an alerting system that cries wolf at 3am gets muted — and a
149
+ **cooldown**, because a spike lasting an hour is one event, not sixty.
150
+
151
+ ## Events
152
+
153
+ Everything the engine concludes is available as a callback or a subscription:
154
+
155
+ ```js
156
+ const stop = detector.on("denial", ({ assessment, decision }) =>
157
+ log.info({ actor: assessment.actor.key, rule: decision.rule }),
158
+ );
159
+ ```
160
+
161
+ | Event | Fires when |
162
+ | ----- | ---------- |
163
+ | `assessment` | every assessment. The firehose |
164
+ | `decision` | every decision |
165
+ | `denial` | a request was actually refused |
166
+ | `downgrade` | the guard replaced a terminal action |
167
+ | `challenge` | one was issued, solved or rejected |
168
+ | `detector-failure` | a detector threw or timed out |
169
+ | `policy-change` / `guard-change` / `range-change` / `actor-change` | somebody changed something at runtime |
170
+ | `anomaly` | the audit noticed a change of shape |
171
+ | `warning` / `error` | misconfiguration; a component failed |
172
+
173
+ **None of them can hurt a request.** A handler that throws is caught and reported once
174
+ through `onError`, the rest still run, and none is awaited — returning a promise is fine and
175
+ the response never waits for your webhook.
176
+
177
+ ## Notifications
178
+
179
+ ```js
180
+ notifications: {
181
+ sinks: [consoleNotifier(), slackNotifier({ url }), webhookNotifier({ url, secret })],
182
+ filter: { types: ["denial", "downgrade", "anomaly"], minScore: 70 },
183
+ redaction: { maskIp: true },
184
+ dedupeWindowMs: 60_000,
185
+ maxPerWindow: 200,
186
+ }
187
+ ```
188
+
189
+ Two properties decide whether alerting is an asset or a liability under bot load. **It never
190
+ blocks a request** — a wedged webhook slows nothing. **It has a ceiling** — repeats collapse,
191
+ a global cap catches distributed traffic where every event is genuinely distinct, and the
192
+ suppressed count is reported when the window rolls, so a quiet channel is never mistaken for
193
+ quiet traffic.
194
+
195
+ Addresses are masked to a `/24` or `/64` on the way *out*, so detection still sees
196
+ everything.
197
+
198
+ ## Exercise
199
+
200
+ Serif has been live on `monitor-only` for a week. What do you look at, in what order, to
201
+ decide whether to enforce?
202
+
203
+ <details>
204
+ <summary>Answer</summary>
205
+
206
+ 1. **`bothandler_verdicts_total`** — what is the actual mix? If `unknown` is not the large
207
+ majority, something is misconfigured, most likely the client IP.
208
+ 2. **The dashboard's live feed, filtered to anything not `unknown`.** Open rows and read the
209
+ evidence. You are looking for the integration you forgot: a partner's nightly sync, a
210
+ status prober, the marketing team's link checker, your own renderer.
211
+ 3. **`bothandler_score_bucket`** — where does suspicion actually sit? That tells you what
212
+ `minScore: 70` would catch on *your* traffic rather than in general.
213
+ 4. **`bothandler_downgrades_total`** — on `monitor-only` this should be zero, because
214
+ nothing asks for a terminal action. If it is not, your rules are not what you think.
215
+ 5. **`npx @osqd/bothandlerjs replay` over the week's access log** with the policy you are
216
+ considering, and read every request it would have refused.
217
+
218
+ Only then enforce — and allowlist what step 2 found *first*.
219
+
220
+ Step 5 is [lesson 16](16-proving-it.md), and it is the one that catches what the others
221
+ miss.
222
+ </details>
223
+
224
+ ## What you learned
225
+
226
+ - The dashboard shows which requests and why; it reports on one process and is bounded
227
+ - `sections` and `controls` are separate powers, enforced server-side
228
+ - Alert on `downgrades_total` and on `unknown`; read `score_bucket` before moving a threshold
229
+ - The audit compares a window against the baseline before it, with floors and cooldowns
230
+ - A high challenge solve rate is the bad direction
231
+ - Nothing in the observation path can slow or break a request
232
+
233
+ ## Reference
234
+
235
+ - [The dashboard](../operations/dashboard.md) · [Metrics](../operations/metrics.md)
236
+ - [The audit](../operations/audit.md) · [Notifications](../operations/notifications.md)
237
+
238
+ Next: [Scaling and changing it live](14-scaling.md).
@@ -0,0 +1,195 @@
1
+ # Lesson 14 — Scaling, and changing it live
2
+
3
+ **Goal:** run Serif on more than one process without breaking your rate limits, and change
4
+ a rule during an incident without a deploy.
5
+
6
+ ← [Course](index.md) · Prev: [Operating it](13-operating-it.md) · Next: [Extending it](15-extending.md)
7
+
8
+ ---
9
+
10
+ ## What breaks at two replicas
11
+
12
+ Two things, and both are correctness rather than optimisation.
13
+
14
+ **Single-use challenge nonces.** Without sharing, a scraper retries a solved nonce against
15
+ another replica until one has not seen it.
16
+
17
+ **Rate limits.** A limit of 100/minute enforced independently by four replicas is a limit of
18
+ 400/minute.
19
+
20
+ ```js
21
+ import { RedisStore } from "@osqd/bothandlerjs";
22
+ new BotHandler({ store: new RedisStore(redis) });
23
+ ```
24
+
25
+ `RedisStore` takes a client you already have. The package stays dependency-free — it uses
26
+ the handful of methods `ioredis` and `node-redis` share, and imports neither.
27
+
28
+ ## What deliberately does not scale, and why
29
+
30
+ Behavioural state — arrival rates, cadence, path breadth, User-Agent history — stays in
31
+ memory per process, on purpose.
32
+
33
+ A round trip per request would buy accuracy for signals that are only ever allowed to
34
+ **raise suspicion**, never to deny anybody: the [guard](04-the-guard.md) sees to that.
35
+ Behind four replicas each sees a quarter of an actor's traffic and is correspondingly less
36
+ sure, which is the right trade for something that cannot close a door on its own.
37
+
38
+ Spending a network round trip on the request path to sharpen a signal that cannot act alone
39
+ is a bad bargain, and the request path is where this library refuses to make bad bargains.
40
+
41
+ ## Except proof, which travels
42
+
43
+ A `confirmed-bot` verdict is *proven* — something declared itself, forged an identity, or
44
+ walked into a trap. That is a fact about the client rather than a judgement about it:
45
+
46
+ ```js
47
+ new BotHandler({ store: new RedisStore(redis), shareConfirmations: true });
48
+ ```
49
+
50
+ Without it, a client proven to be a bot on one replica is a stranger to the other seven, and
51
+ a rule reading `minPriorConfirmations: 1` fires about an eighth as often as it reads.
52
+
53
+ **Proof travels; suspicion stays home.** That sentence is the whole design.
54
+
55
+ The cost is one store read the **first time each instance sees an actor** — not one per
56
+ request — and it is never awaited, so nothing joins the request path. A store outage means
57
+ the count falls back to what that process saw itself.
58
+
59
+ ## Changing things without a deploy
60
+
61
+ Two things go stale between deploys, and both hurt.
62
+
63
+ **Published crawler ranges.** A stale list turns a verified crawler into an accused
64
+ impersonator.
65
+
66
+ **A rule set.** Sometimes a rule is wrong in a way you want to fix *now* — which is exactly
67
+ when the deploy pipeline feels longest.
68
+
69
+ ### Rules
70
+
71
+ ```js
72
+ detector.updatePolicy(
73
+ [...detector.policy.rules, { id: "allow-healthz", match: { path: "/healthz" }, action: "allow" }],
74
+ { by: "you@serif.example" },
75
+ );
76
+ ```
77
+
78
+ Validated first, swapped **atomically**, announced through `onWarning` and `policy-change`.
79
+ Invalid input throws and leaves the previous set standing.
80
+
81
+ **What it cannot change:** `falsePositivePolicy`, `fallbackAction` and
82
+ `terminalScoreThreshold`. No runtime edit can relax the guard through this door.
83
+
84
+ ### The guard
85
+
86
+ Its own method, its own event, its own dashboard flag:
87
+
88
+ ```js
89
+ detector.updateGuard({ falsePositivePolicy: "balanced" }, { by: "you@serif.example" });
90
+ // warning: Guard settings changed at runtime by you@serif.example:
91
+ // falsePositivePolicy strict → balanced.
92
+ ```
93
+
94
+ `onGuardChange` carries the before and the after. **If you page on one thing in this
95
+ lesson, page on this.**
96
+
97
+ ### Ranges and actors
98
+
99
+ ```js
100
+ detector.updateCrawlerRanges("gptbot", await fetchRanges());
101
+ detector.updateRanges("allowlist", [...current, "198.51.100.0/24"]);
102
+ detector.listRanges(); // [{ name: "crawler:gptbot", size: 42 }, …]
103
+
104
+ detector.forgetActor(key, { by: "you@serif.example" });
105
+ detector.clearActor(key, 60 * 60_000, { by: "you@serif.example" });
106
+ ```
107
+
108
+ `range-change` is worth alerting on: the allowlist is the one list that stops detection
109
+ *running*.
110
+
111
+ ### `by`, and the audit trail
112
+
113
+ Every mutating method takes `{ by }`. The dashboard fills it from its own `auth` — a basic
114
+ credential names itself, and a custom `authorize` can return an identity instead of `true`.
115
+
116
+ The library has no user model and does not want one. It carries the name it was given into
117
+ the warning and the event, so your trail can say *who* rather than only *what*:
118
+
119
+ ```js
120
+ detector.on("policy-change", ({ by, rules }) => auditLog.write({ who: by, count: rules.length }));
121
+ ```
122
+
123
+ Nothing verifies the name. It is exactly as trustworthy as whatever supplied it — which is
124
+ your authentication, the same thing that decides whether the change is allowed at all.
125
+
126
+ ## The incident runbook
127
+
128
+ Put this somewhere you can find it at 3am.
129
+
130
+ ```js
131
+ // 1. See it. dashboard → live feed, filter to denials
132
+ // 2. Slow it, uniformly.
133
+ detector.updatePolicy([
134
+ { id: "incident-ceiling", match: {}, action: "rate-limit",
135
+ params: { limit: { max: 30, windowMs: 60_000 } } },
136
+ ...detector.policy.rules,
137
+ ], { by: "you@serif.example" });
138
+
139
+ // 3. Only if that is not enough — and read the preset's warning first.
140
+ // It refuses proven automation, so allowlist your webhooks BEFORE switching over.
141
+ ```
142
+
143
+ A uniform ceiling is the one mitigation that cannot be wrong about who somebody is. Reach
144
+ for it before you reach for anything that singles clients out.
145
+
146
+ And note what is *not* in the runbook: `falsePositivePolicy: "aggressive"`. An incident is
147
+ precisely when people reach for it, and precisely when the population getting caught is at
148
+ its most unusual.
149
+
150
+ ## Exercise
151
+
152
+ Serif runs eight pods behind a load balancer. A rule reads
153
+ `{ minPriorConfirmations: 1, action: "block" }`. It fires far less than expected. Why, and
154
+ what are the two fixes?
155
+
156
+ <details>
157
+ <summary>Answer</summary>
158
+
159
+ Each pod only knows about the confirmations *it* saw. A client proven on pod 3 is a stranger
160
+ to the other seven, so with traffic spread evenly the rule fires roughly one time in eight.
161
+
162
+ **Fix one — share the proof:**
163
+
164
+ ```js
165
+ new BotHandler({ store: new RedisStore(redis), shareConfirmations: true });
166
+ ```
167
+
168
+ This is what that flag is for. Proof is a fact about the client, so it can cross replicas;
169
+ suspicion stays home.
170
+
171
+ **Fix two — give the actors a better key.** If the load balancer has session affinity, or if
172
+ you key on a session id rather than an address, the same client lands where its history is:
173
+
174
+ ```js
175
+ actorKey: (facts) => facts.session ?? facts.ip
176
+ ```
177
+
178
+ Both are worth doing, and the second helps every behavioural detector as well — see
179
+ [lesson 7](07-actors.md).
180
+ </details>
181
+
182
+ ## What you learned
183
+
184
+ - Nonces and rate limits need a shared store; that is correctness, not tuning
185
+ - Behavioural state stays process-local because it can never deny anybody alone
186
+ - `shareConfirmations` lets *proof* cross replicas, unawaited and once per actor
187
+ - `updatePolicy` cannot relax the guard; `updateGuard` is a separate, alertable power
188
+ - A uniform ceiling is the safest first move in an incident
189
+
190
+ ## Reference
191
+
192
+ - [Stores](../integration/stores.md) · [Runtime changes](../operations/runtime-changes.md)
193
+ - [Presets](../policy/presets.md#under-attack)
194
+
195
+ Next: [Extending it](15-extending.md).