@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,134 @@
1
+ # How detection works
2
+
3
+ The pipeline a request goes through, what it costs, and what comes out the other end.
4
+
5
+ ← [Documentation](../index.md)
6
+
7
+ ---
8
+
9
+ ```
10
+ facts → bypass checks → cheap detectors ─┐
11
+ io detectors ────┼→ combine → assessment → policy → decision
12
+ confirming ──────┘
13
+ ```
14
+
15
+ Three calls, and you can stop after any of them:
16
+
17
+ ```ts
18
+ const facts = createFacts({ method, url, headers, ip }); // normalise
19
+ const assessment = await detector.assess(facts); // what is this?
20
+ const decision = detector.decide(assessment); // what do we do?
21
+ const { outcome } = await detector.handle(facts); // all three, plus the action
22
+ ```
23
+
24
+ `assess` never decides and `decide` never inspects. That separation is what makes the
25
+ [replay](../testing/replay.md), the [corpus](../testing/corpus.md) and the dashboard's
26
+ policy preview possible: `decide` is **pure**, so a candidate policy can be run over
27
+ recorded assessments as many times as you like and nothing about the running system moves.
28
+
29
+ ## Before any detector runs
30
+
31
+ Two checks short-circuit everything:
32
+
33
+ | | |
34
+ | --- | --- |
35
+ | `ignorePaths` | Health checks, static assets, your own instrumentation. |
36
+ | `allowlist` | Addresses that are **not judged at all** — see [design decisions](../design/decisions.md). |
37
+
38
+ Either produces an assessment with `bypass` set and no evidence. It is counted (so the
39
+ dashboard can say how much traffic detection actually ran on) and nothing else happens.
40
+
41
+ ## The three stages
42
+
43
+ **Cheap detectors** are synchronous by contract and run *without* an `await`. That is not
44
+ micro-optimisation: `await` on a non-promise still yields a microtask turn, so awaiting
45
+ each of a dozen detectors put a dozen scheduler round-trips on every request to your site
46
+ — which dominated the cost of a clean browser request. A detector that returns a promise
47
+ anyway is collected and awaited with the rest, so the contract is enforced by behaviour
48
+ rather than by trust.
49
+
50
+ **`io` detectors** run concurrently under `detectorTimeoutMs` (default 300 ms). Mislabelling
51
+ one as `cheap` would put an unbounded await on the request path, so the engine times out
52
+ any promise a `cheap` detector returns as well.
53
+
54
+ **Confirming detectors** run only when a signature matched. With no claimed identity there
55
+ is nothing to confirm and no lookup to make, which is why a normal browser request never
56
+ touches DNS.
57
+
58
+ ## Failure is not the visitor's problem
59
+
60
+ A detector that throws or times out is recorded as a `DetectorFailure`, reported through
61
+ `onDetectorFailure`, and the assessment continues with the evidence it has. A resolver
62
+ being down degrades detection; it must never take down the site the detection protects.
63
+
64
+ ```ts
65
+ assessment.failures; // [{ detector, reason: "timeout" | "error", message }]
66
+ ```
67
+
68
+ The same rule holds one level up: an unexpected failure inside an
69
+ [adapter](../integration/adapters.md) serves the request. A bot filter that fails closed
70
+ is an outage with extra steps.
71
+
72
+ ## What comes out
73
+
74
+ ```ts
75
+ interface Assessment {
76
+ requestId: string; // random per request, safe to log and echo
77
+ verdict: Verdict; // confirmed-bot | verified-bot | suspected-bot | human | unknown
78
+ botClass: BotClass;
79
+ identity?: string; // "googlebot", when something named itself
80
+ score: number; // 0–99, or 100 for proven
81
+ confidence: number; // 0–1, unrounded
82
+ certain: boolean; // ← the field that gates terminal actions
83
+ evidence: Evidence[]; // bot-pointing, strongest first
84
+ humanEvidence: Evidence[];
85
+ actor: ActorSnapshot; // history at the time of this request
86
+ durationMs: number;
87
+ failures: DetectorFailure[];
88
+ facts: RequestFacts;
89
+ bypass?: "allowlist" | "ignored-path";
90
+ }
91
+ ```
92
+
93
+ See [verdicts, classes and scores](../concepts/verdicts.md) for which field to read when,
94
+ and [evidence and certainty](../concepts/evidence.md) for how the evidence became a score.
95
+
96
+ ## Asking about a request that is not happening
97
+
98
+ ```ts
99
+ const assessment = await detector.assess(facts, { record: false });
100
+ ```
101
+
102
+ A **dry run**. Every detector runs and the verdict is real — and nothing is written down:
103
+ no counter moves, no actor state changes, no `assessment` event fires, no notification is
104
+ sent. Asking what the engine thinks of a request does not become part of the answer to
105
+ "what is my traffic doing?".
106
+
107
+ Reach for it wherever you want an opinion about a request nobody made: a support ticket
108
+ ("why is this customer being challenged?"), a rule you are drafting, a test. It is what
109
+ `bothandlerjs explain` and the dashboard's request tester run on.
110
+
111
+ The one thing it cannot see is history. It gets an actor with no past, so `cadence`,
112
+ `crawl-breadth` and `rate-anomaly` have nothing to read. What it answers precisely is
113
+ *what would this look like as a first request* — which is what a support ticket is asking
114
+ anyway.
115
+
116
+ ## What it costs
117
+
118
+ Measured on the clean-browser path, which is the overwhelmingly common case and the one
119
+ where nothing short-circuits because nothing fires:
120
+
121
+ ```
122
+ assess — clean browser ~9 us ~104,000 ops/s
123
+ handle — clean browser ~12 us ~83,000 ops/s
124
+ ```
125
+
126
+ `npm run bench` reproduces it; `npm run bench:guard` is the CI ratchet that stops it
127
+ quietly getting worse. Turn on `metrics.perDetectorTiming` while tuning to see where the
128
+ time goes, and turn it off afterwards — it is two clock reads per detector per request.
129
+
130
+ ## Related
131
+
132
+ - [The detectors](detectors.md) — all twenty in detail
133
+ - [Writing a detector](writing-a-detector.md)
134
+ - [Policy](../policy/index.md) — what happens to an assessment next
@@ -0,0 +1,111 @@
1
+ # The signature database
2
+
3
+ How a client is recognised by name, and what a name is worth.
4
+
5
+ ← [Documentation](../index.md) · [The detectors](detectors.md)
6
+
7
+ ---
8
+
9
+ 161 signatures, 389 tokens, matched in a single Aho–Corasick pass over the lower-cased
10
+ User-Agent. One pass regardless of how many signatures there are, which is why adding to
11
+ this costs nothing measurable.
12
+
13
+ ```ts
14
+ interface BotSignature {
15
+ id: string; // stable; appears in rules, logs and metrics
16
+ name: string; // "Googlebot"
17
+ tokens: readonly string[]; // lower-case literals; any one identifies it
18
+ category: BotCategory;
19
+ benign: boolean;
20
+ robotsAgent?: string; // the name to write in robots.txt
21
+ verification: Verification;
22
+ docs?: string; // the operator's own documentation
23
+ }
24
+ ```
25
+
26
+ ## Categories
27
+
28
+ `category` is what most policies actually match on, because it carries intent in a way an
29
+ individual name does not.
30
+
31
+ | Category | Examples | Typical policy |
32
+ | -------- | -------- | -------------- |
33
+ | `search` | Googlebot, Bingbot, DuckDuckBot, Yandex, Baidu, Seznam, Naver | allow |
34
+ | `ai` | GPTBot, ClaudeBot, PerplexityBot, CCBot, Bytespider, Amazonbot | a business decision |
35
+ | `seo` | AhrefsBot, Semrush, Majestic, Moz | usually rate-limit |
36
+ | `social` | facebookexternalhit, Twitterbot, Slackbot, Discord, Bluesky | allow — these are people sharing links |
37
+ | `monitoring` | UptimeRobot, Pingdom, Checkly, Better Uptime | allow |
38
+ | `archive` | ia_archiver, Common Crawl | your call |
39
+ | `feed` | Feedly, podcast clients, RSS readers | allow |
40
+ | `scanner` | sqlmap, Nikto, Nuclei, masscan | block |
41
+ | `library` | curl, wget, python-requests, Go-http-client, okhttp | usually challenge |
42
+ | `headless` | HeadlessChrome, Playwright, Puppeteer, Selenium | usually challenge |
43
+ | `embedded` | Smart TVs, set-top boxes, game consoles | allow |
44
+
45
+ ```ts
46
+ { id: "no-ai", match: { category: "ai" }, action: "block", reason: "Not for model training." }
47
+ ```
48
+
49
+ ## Verification
50
+
51
+ What, if anything, can check the claim:
52
+
53
+ ```ts
54
+ type Verification =
55
+ | { kind: "fcrdns"; domains: readonly string[] } // reverse DNS, forward-confirmed
56
+ | { kind: "ip-ranges"; publishedAt?: string } // an address list the operator publishes
57
+ | { kind: "none" }; // no published mechanism
58
+ ```
59
+
60
+ `none` is honest rather than lazy: a great many crawlers publish nothing that can confirm
61
+ them, and for those the claim is **unfalsifiable**. The library neither confirms nor
62
+ accuses — it records what the client said and lets the policy decide what a self-declared
63
+ identity is worth. See [verifying a crawler](verification.md).
64
+
65
+ ## What a name is worth
66
+
67
+ A matched signature makes `self-identified` produce `certain` evidence, and this is the
68
+ part worth being precise about: **the certainty is about the declaration, not about the
69
+ identity**.
70
+
71
+ `Googlebot/2.1` in a User-Agent proves that something *claimed to be Googlebot*. It is
72
+ `confirmed-bot` — automation, certainly, because no person's browser sends that string —
73
+ and it becomes `verified-bot` only once `crawler-verification` confirms it. A forgery
74
+ that is refuted becomes an `impersonator`, which is the strongest thing the library ever
75
+ concludes about anybody.
76
+
77
+ So a rule matching `identity: ["googlebot"]` alone matches forgeries too. Match on
78
+ proof when it matters:
79
+
80
+ ```ts
81
+ { id: "trust-google", match: { identity: ["googlebot"], verdict: "verified-bot" }, action: "allow" }
82
+ ```
83
+
84
+ ## Adding your own
85
+
86
+ ```ts
87
+ new BotHandler({
88
+ extraSignatures: [
89
+ {
90
+ id: "acme-partner",
91
+ name: "Acme partner integration",
92
+ tokens: ["acme-partner-sync"],
93
+ category: "library",
94
+ benign: true,
95
+ verification: { kind: "ip-ranges" },
96
+ },
97
+ ],
98
+ });
99
+
100
+ // and, if you know where they call from:
101
+ detector.updateCrawlerRanges("acme-partner", ["198.51.100.0/24"]);
102
+ ```
103
+
104
+ `signatures` replaces the shipped set entirely; `extraSignatures` adds to it. Tokens are
105
+ lower-case literals rather than patterns, which is what keeps the match linear.
106
+
107
+ ## Related
108
+
109
+ - [Verifying a crawler](verification.md) — turning a claim into a verdict
110
+ - [robots.txt](../policy/robots.md) — `robotsAgent` is what makes this generatable
111
+ - [The detectors](detectors.md#self-identified)
@@ -0,0 +1,123 @@
1
+ # Verifying a crawler
2
+
3
+ Turning "I am Googlebot" into a verdict — or into a refutation.
4
+
5
+ ← [Documentation](../index.md) · [The detectors](detectors.md)
6
+
7
+ ---
8
+
9
+ A User-Agent is a claim. Two mechanisms can check one, depending on what the operator
10
+ publishes, and both are run by
11
+ [`crawler-verification`](detectors.md#crawler-verification) — which only runs at all when
12
+ a [signature](signatures.md) matched, because with no claim there is nothing to check.
13
+
14
+ ## Forward-confirmed reverse DNS
15
+
16
+ The mechanism Google, Bing, Yandex, Baidu and Apple document for their own crawlers:
17
+
18
+ 1. `PTR` the client address → `crawl-66-249-66-1.googlebot.com`
19
+ 2. Check the name ends in a domain the signature lists.
20
+ 3. Resolve that name forward → does it come back to the same address?
21
+
22
+ Step 3 is the one that matters. Reverse DNS alone is controlled by whoever owns the
23
+ address block; forward-confirming it means the *crawler's* DNS has to agree.
24
+
25
+ ```ts
26
+ { verification: { kind: "fcrdns", domains: ["googlebot.com", "google.com"] } }
27
+ ```
28
+
29
+ | Outcome | Evidence |
30
+ | ------- | -------- |
31
+ | Forward-confirmed under a listed domain | `certain` → `verified-bot` |
32
+ | Resolves, but to a name outside the domains | `certain` refutation → `impersonator` |
33
+ | No answer, timeout, resolver error | **nothing** |
34
+
35
+ That last row is the important one. A resolver having a bad afternoon must not look like
36
+ an accusation, so an unhappy lookup produces no evidence at all — not weak evidence, none.
37
+
38
+ DNS results are cached (`cachingResolver`), and the whole thing runs under
39
+ `detectorTimeoutMs`.
40
+
41
+ ```ts
42
+ new BotHandler({
43
+ resolver: cachingResolver(nodeDnsResolver(), { ttlMs: 600_000, maxEntries: 5_000 }),
44
+ });
45
+ ```
46
+
47
+ ### `treatMissingPtrAsForgery`
48
+
49
+ Off by default, and it should stay off unless you know what you are doing. Some legitimate
50
+ crawlers have no `PTR` at all; treating absence as forgery converts a gap in somebody
51
+ else's DNS into an accusation.
52
+
53
+ ## Published address ranges
54
+
55
+ Twelve shipped signatures verify by address instead — every AI crawler among them. Where
56
+ it is available this is better than reverse DNS in three ways: a lookup instead of a
57
+ network round trip on the request path, immune to somebody else's DNS having a bad
58
+ afternoon, and it works for the several crawlers that publish ranges and no useful `PTR`.
59
+
60
+ **The library ships no address data**, and that is deliberate: a range baked into a
61
+ release is wrong by the time somebody installs it, and being wrong here means verifying
62
+ whoever has since been handed the address. What it ships is the URL each operator
63
+ publishes.
64
+
65
+ ```ts
66
+ import { startCrawlerRangeRefresh } from "@osqd/bothandlerjs";
67
+
68
+ const stop = startCrawlerRangeRefresh(detector); // twice a day by default
69
+ ```
70
+
71
+ Opt-in, because it makes outbound requests, and a dependency-free package quietly fetching
72
+ URLs on a timer is not something to inherit by accident.
73
+
74
+ ### What it will and will not accept
75
+
76
+ It reads the two formats anybody publishes: a JSON document with a `prefixes` array of
77
+ `{ ipv4Prefix }` / `{ ipv6Prefix }` objects — the shape Google standardised and the AI
78
+ crawlers copied — and a plain-text list of one address or CIDR per line.
79
+
80
+ Two things are refused **whole**, because these ranges do not merely describe a crawler,
81
+ they *verify* one — and an address inside them is a `verified-bot`, which most policies
82
+ allow:
83
+
84
+ - a list containing a block bigger than any crawler owns (`/8` or wider for IPv4);
85
+ - an empty list.
86
+
87
+ A partially-parsed list is refused too. The operation replaces a set, and a set that half
88
+ arrived is worse than the one already installed.
89
+
90
+ ### It fails open, per source
91
+
92
+ One publisher being down, having moved its file, or serving something unrecognisable
93
+ leaves every other crawler's ranges as they were — and leaves *that* crawler's ranges as
94
+ they were too, which is the state it was in before you called this. Failures are raised as
95
+ warnings, so they land in the [dashboard's notices](../operations/dashboard.md).
96
+
97
+ ```ts
98
+ const result = await refreshCrawlerRanges(detector, { by: "the nightly job" });
99
+ result.updated; // [{ id: "googlebot", prefixes: 42 }]
100
+ result.failed; // [{ id: "gptbot", reason: "503 Service Unavailable" }]
101
+ ```
102
+
103
+ ### Supplying your own
104
+
105
+ ```ts
106
+ await refreshCrawlerRanges(detector, {
107
+ sources: [{ id: "gptbot", url: "https://internal.example/mirrors/gptbot.json" }],
108
+ });
109
+
110
+ // or set them directly, from any source you like
111
+ detector.updateCrawlerRanges("gptbot", ["203.0.113.0/24"]);
112
+ ```
113
+
114
+ Mirroring the lists internally is a reasonable thing to do: it removes an outbound
115
+ dependency from your servers and lets you review a change before it takes effect.
116
+ `PUBLISHED_CRAWLER_RANGES` is the shipped list of pointers, and nothing stops you pinning
117
+ all of them.
118
+
119
+ ## Related
120
+
121
+ - [The signature database](signatures.md) — what is being verified
122
+ - [Design decisions](../design/decisions.md) — why no address data ships
123
+ - [Runtime changes](../operations/runtime-changes.md) — ranges are a runtime change like any other
@@ -0,0 +1,145 @@
1
+ # Writing a detector
2
+
3
+ The contract, and the one rule you cannot bend.
4
+
5
+ ← [Documentation](../index.md) · [The detectors](detectors.md)
6
+
7
+ ---
8
+
9
+ A detector returns **evidence** and never a verdict. It does not know what will be done
10
+ with what it found, and that is deliberate: the same observation is worth a tag on a
11
+ documentation site and a challenge on a checkout.
12
+
13
+ ```ts
14
+ import type { Detector, Evidence } from "@osqd/bothandlerjs";
15
+
16
+ export function checkoutVelocity(): Detector {
17
+ return {
18
+ id: "checkout-velocity",
19
+ description: "More checkout attempts in a minute than a person makes",
20
+ cost: "cheap",
21
+ stage: "always",
22
+
23
+ inspect(ctx): Evidence | undefined {
24
+ if (!ctx.facts.path.startsWith("/checkout")) return undefined;
25
+
26
+ const attempts = ctx.state.requestsWithin(60_000, ctx.facts.timestamp);
27
+ if (attempts < 8) return undefined;
28
+
29
+ return {
30
+ detector: "checkout-velocity",
31
+ summary: `${attempts} checkout attempts in a minute`,
32
+ direction: "bot",
33
+ certainty: "strong", // not `certain` — a shared address explains it too
34
+ weight: 0.6,
35
+ botClass: "automation",
36
+ };
37
+ },
38
+ };
39
+ }
40
+
41
+ new BotHandler({ extraDetectors: [checkoutVelocity()] });
42
+ ```
43
+
44
+ ## The context
45
+
46
+ ```ts
47
+ interface DetectionContext {
48
+ facts: RequestFacts; // the normalised request
49
+ ua: ParsedUserAgent; // parsed once, shared by every detector
50
+ actor: ActorSnapshot; // history *before* this request
51
+ state: ActorState; // the live object: requestsWithin, intervalStats, …
52
+ clock: Clock;
53
+ signatures: MultiPatternMatcher;
54
+ signatureMatches: BotSignature[];
55
+ resolver: DnsResolver;
56
+ ranges: Map<string, IpRangeSet>;
57
+ shared: Map<string, unknown>; // scratch space for one request
58
+ }
59
+ ```
60
+
61
+ `shared` exists so two detectors can avoid doing the same expensive thing twice within a
62
+ single assessment. It is discarded afterwards.
63
+
64
+ ## The rule about certainty
65
+
66
+ **`certain` requires a written `deterministicBasis`.** Not a strong feeling — an
67
+ explanation of why no legitimate client produces this:
68
+
69
+ ```ts
70
+ {
71
+ certainty: "certain",
72
+ deterministicBasis:
73
+ "HTTP/2 forbids connection-specific headers outright. A compliant client cannot " +
74
+ "send one, so its presence is a protocol violation rather than an unusual choice.",
75
+ }
76
+ ```
77
+
78
+ `strictEvidence` (on by default) enforces it: evidence marked `certain` with no basis is
79
+ **rejected** and reported through `onWarning`. The mechanism is there because "is this
80
+ really proof?" is a question people answer optimistically at 2am, and a downgrade is a
81
+ much better failure than a false accusation.
82
+
83
+ If you are unsure, you want `strong`. A `strong` signal still tags, delays, rate-limits
84
+ and challenges; the only thing it cannot do is deny somebody service, and if you cannot
85
+ write the sentence then it should not be able to.
86
+
87
+ ## Cost and stage
88
+
89
+ | | |
90
+ | --- | --- |
91
+ | `cost: "cheap"` | Synchronous, in-memory. Run without an await. |
92
+ | `cost: "io"` | May touch the network or a store. Run concurrently under a timeout. |
93
+ | `stage: "always"` | Every request. |
94
+ | `stage: "confirming"` | Only when a signature matched — there is a claim to check. |
95
+
96
+ Mislabelling an `io` detector as `cheap` would put an unbounded await on the request path.
97
+ The engine times out any promise a `cheap` detector returns anyway, so the failure mode is
98
+ a timeout rather than a hung request — but label it correctly.
99
+
100
+ ## Human-pointing evidence
101
+
102
+ `direction: "human"` puts the evidence on the other side of the scale, where it
103
+ *discounts* suspicion. Use it for positive marks of a real session, and hold it to the
104
+ same standard: a `certain` human signal means somebody's own code asserted it, not that a
105
+ cookie looked plausible.
106
+
107
+ ## Families
108
+
109
+ If your detector fires on the same root cause as another, declare a `family`. Within a
110
+ family the engine takes the strongest observation rather than compounding them — see
111
+ [families](../concepts/evidence.md#families).
112
+
113
+ ## Replacing a shipped detector
114
+
115
+ `defaultDetectors()` returns the standard set as an array, so swapping one for a
116
+ configured version is a `map`:
117
+
118
+ ```ts
119
+ import { defaultDetectors, probeSignatureDetector } from "@osqd/bothandlerjs";
120
+
121
+ new BotHandler({
122
+ detectors: defaultDetectors().map((d) =>
123
+ d.id === "probe-signature" ? probeSignatureDetector({ ignore: ["/wp-login.php"] }) : d,
124
+ ),
125
+ });
126
+ ```
127
+
128
+ ## Testing it
129
+
130
+ Put your handler in front of the [corpus](../testing/corpus.md). A new detector that
131
+ scores an extra ten points on every headless browser is doing its job; one that also
132
+ scores three points on every Safari user is not, and only the corpus will tell you which
133
+ you wrote.
134
+
135
+ ```ts
136
+ import { runCorpus } from "@osqd/bothandlerjs/corpus";
137
+
138
+ const scorecard = await runCorpus({
139
+ create: ({ resolver, clock }) => new BotHandler({ extraDetectors: [mine()], resolver, clock }),
140
+ assertActions: false,
141
+ });
142
+ expect(scorecard.falsePositives).toEqual([]);
143
+ ```
144
+
145
+ See `examples/custom-detector.ts` for a complete one.
package/docs/index.md ADDED
@@ -0,0 +1,100 @@
1
+ # Documentation
2
+
3
+ **bothandlerjs** — bot traffic detection and handling for TypeScript.
4
+
5
+ ← [Back to the README](../README.md)
6
+
7
+ ---
8
+
9
+ ## Start here
10
+
11
+ **New to this?** [**The course**](course/index.md) teaches every capability in sixteen
12
+ lessons, in the order that makes each one make sense, with something to run at every step.
13
+ The pages below are the reference: they answer "how does X work?" rather than "what do I do
14
+ next?".
15
+
16
+ | | |
17
+ | --- | --- |
18
+ | [The course](course/index.md) | Sixteen lessons, one running example, from first assessment to a policy you can defend. |
19
+ | [Installation](start/installation.md) | Install it, and what it needs from your runtime. |
20
+ | [Your first integration](start/first-integration.md) | Ten lines that assess traffic, and what each one does. |
21
+ | [Choosing a policy](start/choosing-a-policy.md) | Pick a preset, prove it against your own traffic, then deploy it. |
22
+ | [Upgrading](start/upgrading.md) | What changed between versions, and what you have to do about it. |
23
+
24
+ ## The ideas the library is built on
25
+
26
+ Read these once and everything else follows from them. They are short.
27
+
28
+ | | |
29
+ | --- | --- |
30
+ | [Evidence and certainty](concepts/evidence.md) | The two tiers, why they combine by different rules, and what "proof" means here. |
31
+ | [Verdicts, classes and scores](concepts/verdicts.md) | What the engine concludes, and which field you should actually be reading. |
32
+ | [The safety guard](concepts/the-guard.md) | The thing that stops a guess closing a door. The central mechanism. |
33
+ | [Actors and behavioural memory](concepts/actors.md) | Who "the same client" is, what is remembered about them, and for how long. |
34
+ | [Threat model](concepts/threat-model.md) | What this defends against, what it does not, and what it costs to be wrong. |
35
+
36
+ ## Detection
37
+
38
+ | | |
39
+ | --- | --- |
40
+ | [How detection works](detection/index.md) | The pipeline: stages, budgets, failure, and what an assessment contains. |
41
+ | [The detectors](detection/detectors.md) | All twenty, each with what it reads, why it exists, and what it costs. |
42
+ | [The signature database](detection/signatures.md) | How a client is recognised by name, and what a name is worth. |
43
+ | [Verifying a crawler](detection/verification.md) | Reverse DNS, published address ranges, and refuting a forgery. |
44
+ | [Browser signals](detection/client-signals.md) | The optional page script, and the ceiling on anything it reports. |
45
+ | [Writing a detector](detection/writing-a-detector.md) | The contract, and the rules about certainty you have to keep. |
46
+
47
+ ## Policy
48
+
49
+ | | |
50
+ | --- | --- |
51
+ | [Policy overview](policy/index.md) | Rules, order, and how a decision is reached. |
52
+ | [Matching requests](policy/rules.md) | Every field a rule can match on, with examples. |
53
+ | [Actions](policy/actions.md) | All ten, ordered by what each costs a client that turns out to be a person. |
54
+ | [Presets](policy/presets.md) | The eight shipped policies, and what each is for. |
55
+ | [robots.txt](policy/robots.md) | Generating the file your policy implies. |
56
+
57
+ ## The challenge
58
+
59
+ | | |
60
+ | --- | --- |
61
+ | [The challenge](challenge/index.md) | Proof of work, clearance, and exactly what it buys. |
62
+ | [Languages](challenge/localisation.md) | Writing the interstitial in a language the visitor reads. |
63
+
64
+ ## Running it
65
+
66
+ | | |
67
+ | --- | --- |
68
+ | [Operations overview](operations/index.md) | What to watch, and what to do when it moves. |
69
+ | [The dashboard](operations/dashboard.md) | What it shows, what it refuses to do, and every option it takes. |
70
+ | [Metrics](operations/metrics.md) | Counters, histograms and the Prometheus exposition. |
71
+ | [The traffic audit](operations/audit.md) | Watching the shape of your traffic rather than any one request. |
72
+ | [Notifications](operations/notifications.md) | Getting told, without being told a thousand times. |
73
+ | [Runtime changes](operations/runtime-changes.md) | Changing rules, the guard, ranges and actors without a deploy. |
74
+
75
+ ## Integrating it
76
+
77
+ | | |
78
+ | --- | --- |
79
+ | [Integration overview](integration/index.md) | Where the library sits in a request. |
80
+ | [Adapters](integration/adapters.md) | Express, Fastify, Koa, Fetch, Hono, Next.js — and writing your own. |
81
+ | [Getting the client address right](integration/client-ip.md) | The highest-consequence setting in the library. |
82
+ | [Shared state](integration/stores.md) | What has to be shared between replicas, and what deliberately is not. |
83
+
84
+ ## Proving it before it meets anybody
85
+
86
+ | | |
87
+ | --- | --- |
88
+ | [Testing overview](testing/index.md) | The three ways to find out what a policy does before it does it. |
89
+ | [The command line](testing/cli.md) | `replay`, `check`, `explain`, `robots`, `detectors`. |
90
+ | [The traffic corpus](testing/corpus.md) | 526 shapes of real traffic, and how to run your own config against them. |
91
+ | [Replaying your own logs](testing/replay.md) | The most useful thing you can do before deploying anything. |
92
+ | [Try it locally](testing/try-it.md) | A demo site, a live dashboard and eighteen scripted clients. |
93
+
94
+ ## Reference
95
+
96
+ | | |
97
+ | --- | --- |
98
+ | [Configuration](reference/configuration.md) | Every option, with its default and its consequence. |
99
+ | [API](reference/api.md) | Every export, grouped by what it is for. |
100
+ | [Design decisions](design/decisions.md) | The things this library refuses to do, and why. |