@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,138 @@
1
+ # Adapters
2
+
3
+ Four adapters, six frameworks, and how to write a fifth.
4
+
5
+ ← [Documentation](../index.md) · [Integration](index.md)
6
+
7
+ ---
8
+
9
+ ```ts
10
+ import { botHandler, fastifyBotHandler, koaBotHandler, withBotHandler } from "@osqd/bothandlerjs/adapters";
11
+
12
+ app.use(botHandler(detector)); // Express / Connect / node:http
13
+ fastify.addHook("onRequest", fastifyBotHandler(detector));
14
+ app.use(koaBotHandler(detector));
15
+ export default { fetch: withBotHandler(detector, myHandler) }; // Workers / Deno / Bun / Edge
16
+ ```
17
+
18
+ ## Why adapters exist at all
19
+
20
+ The engine never touches a response object. It takes `RequestFacts` and returns an
21
+ `ActionOutcome` — "serve it", "respond with this", "destroy the connection" — and the
22
+ adapter is the only code that knows what a response *is* in your framework.
23
+
24
+ That separation is what makes a policy portable, makes the engine testable without a
25
+ server, and makes the [request tester](../operations/dashboard.md) and the
26
+ [corpus](../testing/corpus.md) possible.
27
+
28
+ ## Hono and Next.js need no adapter of their own
29
+
30
+ Both speak the platform's `Request` and `Response`, which is exactly what
31
+ `createFetchAdapter` takes and returns. The only difference between them is how each says
32
+ "carry on".
33
+
34
+ ```ts
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: 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: the adapter puts the verdict
53
+ on the request it hands back, so a route further in can read it without assessing anything
54
+ itself.
55
+
56
+ `examples/hono-and-next.ts` is both, executable, and `tests/adapters.test.ts` drives both
57
+ shapes — because "it probably works with X" is how a framework ends up unsupported by
58
+ accident.
59
+
60
+ ## What every adapter does for you
61
+
62
+ **Attaches the verdict to the request** — `x-bot-verdict`, `x-bot-score`, `x-bot-class`,
63
+ `x-bot-certain`, `x-bot-reason` — so your handlers can react without re-running detection.
64
+
65
+ Response-side headers are **off by default** (`exposeVerdictHeaders`): an `X-Bot-Score` in
66
+ the response is a live feedback signal for anyone tuning a scraper against you.
67
+
68
+ **Serves the [challenge](../challenge/index.md) verification endpoint.** Fastify and Koa
69
+ read the raw stream in the hook, because by the time a route handler sees the request its
70
+ body has been consumed by a parser that knows nothing about this endpoint.
71
+
72
+ **Fails open.** An unexpected failure inside detection serves the request; the error goes to
73
+ your `onError`.
74
+
75
+ ## Handing over a parsed body
76
+
77
+ The engine reads no request body — doing so would consume the stream before your own parser
78
+ saw it. So a [trap](../detection/detectors.md) field on a `method="post"` form arrives
79
+ somewhere this library cannot see, and the forms worth protecting are POSTs.
80
+
81
+ `enrich` is the hand-over:
82
+
83
+ ```ts
84
+ import { TRAP_FIELD_SOURCE, defaultDetectors, trapDetector } from "@osqd/bothandlerjs";
85
+
86
+ const detector = new BotHandler({
87
+ detectors: defaultDetectors().map((d) => (d.id === "trap" ? trapDetector({ formFields: ["company_url"] }) : d)),
88
+ });
89
+
90
+ app.use(express.urlencoded({ extended: false })); // your parser runs first
91
+ app.use(
92
+ botHandler(detector, {
93
+ enrich: (request, facts) => ({ ...facts, extra: { [TRAP_FIELD_SOURCE]: request.body } }),
94
+ }),
95
+ );
96
+ ```
97
+
98
+ A field arriving in the query string is read without any of this.
99
+
100
+ ## Fetch runtimes
101
+
102
+ The three Node adapters read the socket address and honour [`proxy`](client-ip.md). There is
103
+ no socket on a Fetch runtime, so `withBotHandler` and `createFetchAdapter` take the address
104
+ from a header instead — which is the same decision `proxy` exists to make carefully.
105
+
106
+ ```ts
107
+ export default {
108
+ fetch: withBotHandler(detector, myHandler, {
109
+ // Best: ask the platform, not a header.
110
+ clientIp: (request, env) => (env as { cf?: { connectingIp?: string } }).cf?.connectingIp,
111
+ }),
112
+ };
113
+ ```
114
+
115
+ Only `cf-connecting-ip` and `x-real-ip` are trusted by default. `x-forwarded-for` is **not**
116
+ — see [the client IP](client-ip.md) for why.
117
+
118
+ Two platform notes: on Workers, enable `nodejs_compat`, because the engine uses
119
+ `node:crypto`. And Fetch runtimes normalise header order, so `header-order` returns nothing
120
+ there — drop it.
121
+
122
+ ## Writing your own
123
+
124
+ About thirty lines. `src/adapters/node.ts` is the model. The shape is:
125
+
126
+ 1. Build `RequestFacts` with `createFacts({ method, url, headers, rawHeaders, ip })`.
127
+ 2. `await detector.handle(facts)`.
128
+ 3. Apply the returned `ActionOutcome` in your framework's terms.
129
+ 4. Route the challenge verification path to `detector.verifyChallenge`.
130
+
131
+ Pass `rawHeaders` if your framework exposes them — the `header-order` detector needs the
132
+ order as it arrived, and a normalised object has already lost it.
133
+
134
+ ## Related
135
+
136
+ - [The client IP](client-ip.md) — do this before deploying behind a proxy
137
+ - [Actions](../policy/actions.md) — the outcomes an adapter applies
138
+ - [Detectors](../detection/detectors.md) — including `trap`, which `enrich` feeds
@@ -0,0 +1,114 @@
1
+ # The client IP
2
+
3
+ The highest-consequence setting in the library, and the easiest to get wrong.
4
+
5
+ ← [Documentation](../index.md) · [Integration](index.md)
6
+
7
+ ---
8
+
9
+ ## Why it is the highest-consequence setting
10
+
11
+ The client address becomes the [actor key](../concepts/actors.md). Everything per-client
12
+ depends on it: rate limits, the allowlist, the denylist, and every behavioural detector.
13
+
14
+ `X-Forwarded-For` is a **client-supplied header**. Trust it without knowing how many proxies
15
+ sit in front of you and anyone can prepend a fake hop and choose the address you rate-limit,
16
+ allowlist and block on.
17
+
18
+ The failure is silent. Nothing errors, nothing logs, and every per-actor mechanism in this
19
+ library becomes an attacker input.
20
+
21
+ There is deliberately **no convenient default**.
22
+
23
+ ## The three settings
24
+
25
+ ```ts
26
+ proxy: { trustProxy: true, trustedProxies: ["10.0.0.0/8"] } // recommended
27
+ proxy: { trustProxy: true, hops: 2 } // correct only while the count is
28
+ proxy: { trustProxy: false } // default — header ignored entirely
29
+ ```
30
+
31
+ ### `trustedProxies` — the one to use
32
+
33
+ The chain is walked **from the right**, discarding your own infrastructure, and the first
34
+ address outside it is the client. That is robust against an extra hop appearing, which is
35
+ what makes it survive a change to your topology that nobody remembered to tell you about.
36
+
37
+ ### `hops` — correct only while the count is
38
+
39
+ Counts a fixed number of entries from the right. It is right until somebody adds a CDN,
40
+ inserts a sidecar, or moves a service behind an extra load balancer — and then it is wrong
41
+ in the direction that lets clients choose their own address.
42
+
43
+ ### `trustProxy: false` — the default
44
+
45
+ The forwarded header is ignored entirely and the socket address is used. Correct when
46
+ nothing sits in front of you, and safe everywhere else in the sense that matters: it can be
47
+ *useless* behind a proxy (every request looks like it came from the load balancer), but it
48
+ cannot be *forged*.
49
+
50
+ ## Addresses are compared as bytes
51
+
52
+ `::ffff:127.0.0.1`, `0177.0.0.1` and `127.0.0.001` are all the same address, and all of them
53
+ slip past an allowlist that compares strings. Parsing to bytes is the only comparison that
54
+ holds.
55
+
56
+ Invalid CIDRs throw at construction rather than matching silently. A range that matches
57
+ nothing is a control you believe you have and do not.
58
+
59
+ ## The peer is checked too
60
+
61
+ The connecting peer counts as the first hop and is checked the same way.
62
+
63
+ That is the part that matters on a server reachable **both** through the load balancer and
64
+ directly. A request arriving from outside your trusted ranges did not come through your
65
+ proxies, so its forwarded header is not evidence of anything and the socket address is used
66
+ instead.
67
+
68
+ Without that check, anyone who finds the origin address picks their own client IP — and
69
+ origin addresses are not secret.
70
+
71
+ ## Do not allowlist loopback
72
+
73
+ The moment you sit behind nginx or beside a sidecar, every request in the world arrives from
74
+ `127.0.0.1`.
75
+
76
+ ## On Fetch runtimes
77
+
78
+ There is no socket, so the address comes from a header. Only `cf-connecting-ip` and
79
+ `x-real-ip` are trusted by default: both are single-valued and written by the edge that
80
+ terminated the connection.
81
+
82
+ `x-forwarded-for` is **not** in that list, because a proxy *appends* to it — its leftmost
83
+ entry is whatever the client wrote. List it explicitly only if you know your edge replaces
84
+ the whole header.
85
+
86
+ Better still, ask the platform rather than a header:
87
+
88
+ ```ts
89
+ createFetchAdapter(detector, {
90
+ clientIp: (request, env) => (env as { cf?: { connectingIp?: string } }).cf?.connectingIp,
91
+ });
92
+ ```
93
+
94
+ If no address can be found, every visitor is tracked under one empty actor key — which makes
95
+ rate limits and behavioural detection apply to your whole site at once. The adapter says so
96
+ through `onWarning` the first time it happens.
97
+
98
+ ## When the address is not the right identity anyway
99
+
100
+ An address is a poor identity: shared by a whole office, changed by a phone every few
101
+ minutes. `actorKey` is the single most valuable thing to replace:
102
+
103
+ ```ts
104
+ new BotHandler({ actorKey: (facts) => facts.session ?? facts.ip });
105
+ ```
106
+
107
+ A session id, an authenticated user id, or an address plus a TLS fingerprint all make the
108
+ same detectors sharper — sharp enough that `identity-rotation` becomes worth enabling.
109
+
110
+ ## Related
111
+
112
+ - [Actors](../concepts/actors.md) — what the key is used for
113
+ - [Adapters](adapters.md) — where the address is read
114
+ - [Configuration reference](../reference/configuration.md) — `proxy`, `actorKey`, `allowlist`
@@ -0,0 +1,55 @@
1
+ # Integration
2
+
3
+ Getting it into your application: frameworks, the client address, and shared state.
4
+
5
+ ← [Documentation](../index.md)
6
+
7
+ ---
8
+
9
+ | Page | For |
10
+ | ---- | --- |
11
+ | [Adapters](adapters.md) | Express, Fastify, Koa, Fetch runtimes, Hono, Next.js — and writing your own |
12
+ | [The client IP](client-ip.md) | the highest-consequence setting in the library |
13
+ | [Stores](stores.md) | what needs to be shared across replicas, and what deliberately is not |
14
+
15
+ ---
16
+
17
+ ## The shape of it
18
+
19
+ Three lines in the common case:
20
+
21
+ ```ts
22
+ import { BotHandler } from "@osqd/bothandlerjs";
23
+ import { botHandler } from "@osqd/bothandlerjs/adapters";
24
+
25
+ const detector = new BotHandler({ preset: "protect-content" });
26
+ app.use(botHandler(detector));
27
+ ```
28
+
29
+ One `BotHandler` instance per policy, mounted where that policy applies. Most applications
30
+ have one; an application that runs [`protect-auth`](../policy/presets.md) on its login
31
+ routes has two.
32
+
33
+ ## The one thing to get right before anything else
34
+
35
+ **The client address.** It becomes the [actor key](../concepts/actors.md), which every
36
+ behavioural detector, every rate limit and every allowlist entry depends on. Behind a proxy
37
+ it has no safe default, and getting it wrong turns every per-actor mechanism here into an
38
+ attacker input.
39
+
40
+ Read [the client IP](client-ip.md) before you deploy this behind anything.
41
+
42
+ ## Two guarantees the integration relies on
43
+
44
+ **The engine never touches a response object.** It returns an `ActionOutcome` and the
45
+ adapter applies it — which is why the same policy behaves identically on Express and on a
46
+ Worker, and why writing your own adapter is about thirty lines.
47
+
48
+ **It fails open.** An unexpected failure inside detection serves the request. The error
49
+ goes to your `onError`; the visitor gets their page. A bot filter that fails closed is an
50
+ outage with extra steps.
51
+
52
+ ## Related
53
+
54
+ - [Configuration reference](../reference/configuration.md) — every option
55
+ - [First integration](../start/first-integration.md) — the walk-through
@@ -0,0 +1,76 @@
1
+ # Stores
2
+
3
+ What has to be shared across replicas, what deliberately is not, and why.
4
+
5
+ ← [Documentation](../index.md) · [Integration](index.md)
6
+
7
+ ---
8
+
9
+ ## The default
10
+
11
+ An in-memory store. Correct for a single process, and honest about what it is not.
12
+
13
+ ```ts
14
+ import { RedisStore } from "@osqd/bothandlerjs";
15
+ new BotHandler({ store: new RedisStore(redis) }); // ioredis or node-redis; no dependency added
16
+ ```
17
+
18
+ `RedisStore` takes a client you already have. The package stays dependency-free — it uses
19
+ the handful of methods both major clients share.
20
+
21
+ ## What needs a shared store
22
+
23
+ **Single-use challenge nonces.** Without sharing, a scraper retries a solved nonce against
24
+ another replica until one has not seen it. See [the challenge](../challenge/index.md).
25
+
26
+ **Rate limits.** A limit of 100/minute enforced independently by four replicas is a limit of
27
+ 400/minute. See [`rate-limit`](../policy/actions.md#rate-limit).
28
+
29
+ Those two are correctness, not optimisation. If you run more than one process and use either
30
+ feature, you need a store.
31
+
32
+ ## What deliberately stays process-local
33
+
34
+ **Behavioural state** — arrival rates, cadence, path breadth, User-Agent history — lives in
35
+ memory, on purpose.
36
+
37
+ A round trip per request would buy accuracy for signals that are only ever allowed to *raise
38
+ suspicion*, never to deny anybody: the [guard](../concepts/the-guard.md) sees to that.
39
+ Behind four replicas each one sees a quarter of an actor's traffic and is correspondingly
40
+ less sure, which is the right trade for something that cannot close a door on its own.
41
+
42
+ Spending a network round trip on the request path to sharpen a signal that cannot act alone
43
+ is a bad bargain, and the request path is the one place this library refuses to make bad
44
+ bargains.
45
+
46
+ ## Confirmations are not one of those
47
+
48
+ A `confirmed-bot` verdict is *proven* — something declared itself, forged an identity, or
49
+ walked into a trap. That is a fact about the client rather than a judgement about it, so it
50
+ can travel:
51
+
52
+ ```ts
53
+ new BotHandler({ store: new RedisStore(redis), shareConfirmations: true });
54
+ ```
55
+
56
+ Without it, a client proven to be a bot on one replica is a stranger to the other seven, and
57
+ `minPriorConfirmations: 1` fires about an eighth as often as it reads.
58
+
59
+ **Proof travels; suspicion stays home.** That single sentence is the whole design.
60
+
61
+ The cost is one store read the **first time each instance sees an actor** — not one per
62
+ request — and it is never awaited, so nothing joins the request path. A store outage means
63
+ the count falls back to what that process saw itself.
64
+
65
+ ## Writing your own
66
+
67
+ Implement `BotHandlerStore`. The interface is small — get, set, increment, an atomic
68
+ claim — and every method may fail: the engine treats a store error as "no answer" and
69
+ carries on serving, because a store outage must not become a site outage.
70
+
71
+ ## Related
72
+
73
+ - [The challenge](../challenge/index.md) — replay protection
74
+ - [Actions](../policy/actions.md#rate-limit) — the one action that needs this
75
+ - [Actors](../concepts/actors.md) — what the local state actually holds
76
+ - [The guard](../concepts/the-guard.md) — why local behavioural state is safe
@@ -0,0 +1,116 @@
1
+ # The audit
2
+
3
+ Noticing that the traffic changed shape.
4
+
5
+ ← [Documentation](../index.md) · [Operations](index.md)
6
+
7
+ ---
8
+
9
+ ## Why this exists
10
+
11
+ [Counters](metrics.md) tell you what is happening. They do not tell you it is *unusual*,
12
+ and bot traffic is not a level — it is an **event**. A scrape starts, a scanner sweeps a
13
+ range, somebody points a stuffing tool at your login form. The number that matters is not
14
+ "12% of requests are bots" but "12% now, 2% for the hour before".
15
+
16
+ So the audit keeps a short **window**, compares it against the **baseline that precedes
17
+ it**, and raises a structured anomaly when a check clears its bar.
18
+
19
+ ```ts
20
+ new BotHandler({
21
+ audit: {
22
+ windowMs: 5 * 60_000, // the stretch being judged
23
+ baselineMs: 60 * 60_000, // what it is compared against — the hour before it
24
+ intervalMs: 60_000, // how often the comparison runs
25
+ minSamples: 50, // below this, no check may speak
26
+ cooldownMs: 15 * 60_000, // one alert per check per quarter hour
27
+ },
28
+ onAnomaly: (anomaly) => pager.send(anomaly.severity, anomaly.summary),
29
+ });
30
+ ```
31
+
32
+ ```
33
+ critical — Automated traffic is 87.3% of assessed requests, against 0.0% in the
34
+ baseline — where there was none.
35
+ warning — The guard stopped 37 terminal action(s) — 35.9% of traffic. Your rules
36
+ are asking to deny requests the evidence does not prove.
37
+ critical — Traffic is 298 requests a minute, against a baseline of 1.5 (198.7x).
38
+ ```
39
+
40
+ **The baseline ends where the window begins.** A baseline that contained the window would
41
+ be partly made of the thing being measured, and a large enough spike would raise its own
42
+ bar until it stopped looking like one.
43
+
44
+ ## What ships
45
+
46
+ | Check | Fires when |
47
+ | ----- | ---------- |
48
+ | `bot-share-spike` | Automation is a large and much larger share of traffic than the baseline. A baseline of zero is the loudest case, not a reason to stay quiet. |
49
+ | `traffic-spike` | Volume is far above the baseline rate. |
50
+ | `denial-spike` | A much larger share of requests is being denied. **Read these before assuming they are all bots.** |
51
+ | `guard-stop-spike` | The [guard](../concepts/the-guard.md) is refusing far more rules than usual — your policy is asking to deny requests the evidence does not prove. |
52
+ | `human-share-drop` | Traffic that reads as human has fallen away. Either your traffic changed or your detection did. |
53
+ | `detector-failures` | Detectors are erroring or timing out. Detection is degraded — usually a resolver or a store, not the traffic. |
54
+ | `challenge-solve-rate` | Nearly everything being challenged is passing. **High is the bad direction.** |
55
+
56
+ ### Why `challenge-solve-rate` reads backwards
57
+
58
+ It is the odd one out and the closest to this library's own thesis. Every other check asks
59
+ whether the *traffic* changed shape; this one asks whether the mitigation is landing on the
60
+ right population.
61
+
62
+ A proof-of-work [challenge](../challenge/index.md) is trivial for a browser and trivial for
63
+ a competent scraper — what it actually costs is a few seconds of somebody's afternoon — so
64
+ when nearly everything challenged goes on to pass, the challenges are not filtering bots
65
+ out, they are **taxing people**. From every other angle a challenge that gets solved looks
66
+ like a challenge that worked, which is precisely why this needs saying out loud.
67
+
68
+ ### Floors and cooldowns
69
+
70
+ Every check has a **floor** as well as a ratio, because a quiet site at 3am produces "800%
71
+ more bots" from four requests, and an alerting system that cries wolf at 3am gets muted —
72
+ which is worse than not having one.
73
+
74
+ And every one has a **cooldown**: a spike lasting an hour is one event, not sixty.
75
+
76
+ ## Your own checks
77
+
78
+ ```ts
79
+ audit: {
80
+ extraChecks: [{
81
+ id: "checkout-scraping",
82
+ description: "Bots on the checkout funnel specifically",
83
+ evaluate: ({ window, baseline }) =>
84
+ window.botShare > 0.4 && window.botShare > baseline.botShare * 2
85
+ ? { id: "checkout-scraping", severity: "warning", metric: "bot share",
86
+ value: window.botShare, baseline: baseline.botShare,
87
+ summary: `Checkout is ${(window.botShare * 100).toFixed(0)}% automated.` }
88
+ : undefined,
89
+ }],
90
+ }
91
+ ```
92
+
93
+ A check that throws is skipped, not a reason for the audit to stop running. `checks`
94
+ replaces the built-in set entirely; `extraChecks` adds to it.
95
+
96
+ ## Asking it yourself
97
+
98
+ The timer is a convenience, not the mechanism. `handler.audit` is the object, and
99
+ `runAudit()` runs the checks now and emits whatever they found — which is what a health
100
+ endpoint, a cron job or a test with a manual clock wants:
101
+
102
+ ```ts
103
+ app.get("/internal/traffic", (_req, res) => res.json(detector.audit?.summary()));
104
+
105
+ const anomalies = detector.runAudit();
106
+ ```
107
+
108
+ Anomalies also reach configured [notification sinks](notifications.md) as `type: "anomaly"`
109
+ events, and land in the [dashboard's](dashboard.md) notices panel beside your startup
110
+ warnings. Switch the whole thing off with `audit: false`.
111
+
112
+ ## Related
113
+
114
+ - [Metrics](metrics.md) — the counters this reads
115
+ - [Notifications](notifications.md) — where an anomaly goes
116
+ - [The guard](../concepts/the-guard.md) — what `guard-stop-spike` is about