@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,175 @@
1
+ # Configuration reference
2
+
3
+ Every option `BotHandler` takes, grouped by what it is for.
4
+
5
+ ← [Documentation](../index.md)
6
+
7
+ ---
8
+
9
+ ```ts
10
+ new BotHandler({
11
+ // Detection
12
+ detectors, extraDetectors, signatures, extraSignatures,
13
+ suspectThreshold: 60, strictEvidence, detectorTimeoutMs: 300,
14
+
15
+ // Policy
16
+ preset, rules, defaultAction: "allow", defaultActionParams,
17
+ falsePositivePolicy: "strict", fallbackAction, terminalScoreThreshold: 85,
18
+
19
+ // Addresses — invalid CIDRs throw at construction, never match silently
20
+ allowlist, denylist, datacenterRanges, crawlerRanges: { gptbot: ["1.2.3.0/24"] },
21
+
22
+ // Identity and scope
23
+ proxy: { trustProxy, trustedProxies, hops, header },
24
+ actorKey: (facts) => facts.ip,
25
+ ignorePaths, isHuman,
26
+
27
+ // Observation
28
+ audit: { windowMs: 300_000, baselineMs: 3_600_000, minSamples: 50 },
29
+ onAssessment, onDecision, onDenial, onDowngrade, onChallenge,
30
+ onDetectorFailure, onPolicyChange, onGuardChange, onRangeChange, onActorChange, onAnomaly,
31
+
32
+ // Infrastructure
33
+ challenge, store, notifications, handlers, resolver, clock,
34
+ shareConfirmations: false, // proof crosses replicas; suspicion does not
35
+ actorWindowMs: 900_000, maxActors: 20_000, exposeVerdictHeaders: false,
36
+ metrics: true, // or { perDetectorTiming: true }
37
+ onError, onWarning,
38
+ });
39
+ ```
40
+
41
+ ---
42
+
43
+ ## Detection
44
+
45
+ | Option | Default | |
46
+ | ------ | ------- | - |
47
+ | `detectors` | the default set | replaces it **entirely** |
48
+ | `extraDetectors` | — | appended; ignored when `detectors` is given |
49
+ | `signatures` | `BOT_SIGNATURES` | replaces the [signature database](../detection/signatures.md) |
50
+ | `extraSignatures` | — | appended to it |
51
+ | `suspectThreshold` | `60` | score at or above which an unproven request is `suspected-bot` |
52
+ | `strictEvidence` | `true` outside production | reject `certain` evidence carrying no `deterministicBasis` |
53
+ | `detectorTimeoutMs` | `300` | budget per `io` detector; exceeding it drops the detector, not the request |
54
+
55
+ `strictEvidence` downgrades to a warning when `NODE_ENV` is `production`, so a third-party
56
+ detector with a missing basis cannot take a live site down. See
57
+ [evidence](../concepts/evidence.md).
58
+
59
+ ## Policy
60
+
61
+ | Option | Default | |
62
+ | ------ | ------- | - |
63
+ | `preset` | — | a named [starting policy](../policy/presets.md) |
64
+ | `rules` | — | your own [rules](../policy/rules.md), evaluated **before** any preset's |
65
+ | `defaultAction` | `"allow"` | when nothing matches |
66
+ | `defaultActionParams` | — | params for it |
67
+ | `falsePositivePolicy` | `"strict"` | [the guard's](../concepts/the-guard.md) mode |
68
+ | `fallbackAction` | `"challenge"` | substituted when the guard stops a terminal action; **cannot itself be terminal** |
69
+ | `terminalScoreThreshold` | `85` | score needed for a terminal action under `balanced` |
70
+
71
+ ## Addresses
72
+
73
+ | Option | |
74
+ | ------ | - |
75
+ | `allowlist` | exempt from detection **entirely** — your monitors, your office, your CI |
76
+ | `denylist` | treated as proven automation; an explicit local decision |
77
+ | `datacenterRanges` | hosting-provider ranges; none ships with the library |
78
+ | `crawlerRanges` | published ranges keyed by signature id — see [verification](../detection/verification.md) |
79
+
80
+ Invalid CIDRs **throw at construction**. A range that silently matches nothing is a control
81
+ you believe you have and do not.
82
+
83
+ `allowlist` is the strongest thing here: an address on it is not judged leniently, it is not
84
+ judged at all. Do not put loopback on it — see [the client IP](../integration/client-ip.md).
85
+
86
+ ## Identity and scope
87
+
88
+ | Option | Default | |
89
+ | ------ | ------- | - |
90
+ | `proxy` | `{ trustProxy: false }` | **read [the client IP](../integration/client-ip.md) first** |
91
+ | `actorKey` | the client address | the single most valuable thing to replace |
92
+ | `ignorePaths` | — | health checks, your own polling endpoints, static assets |
93
+ | `isHuman` | — | your application declaring a request human |
94
+
95
+ `isHuman` produces `certain` human evidence — the only conclusive human signal available,
96
+ because it comes from you and not from the client. An authenticated session, a completed
97
+ payment, whatever bar you set:
98
+
99
+ ```ts
100
+ isHuman: (facts) => Boolean(facts.session && sessions.get(facts.session)?.authenticated)
101
+ ```
102
+
103
+ ## Observation
104
+
105
+ `audit` takes [`AuditOptions`](../operations/audit.md) or `false`. Every `onX` hook mirrors
106
+ an [event](../operations/index.md#events-and-hooks) — the config form is convenient, the
107
+ emitter form can be subscribed to later and removed.
108
+
109
+ ## Infrastructure
110
+
111
+ | Option | Default | |
112
+ | ------ | ------- | - |
113
+ | `challenge` | — | enables the [challenge action](../challenge/index.md); without it those rules degrade to `tag` |
114
+ | `store` | in-memory | see [stores](../integration/stores.md) |
115
+ | `shareConfirmations` | `false` | proof crosses replicas; suspicion does not. Needs a shared store |
116
+ | `notifications` | — | [sinks, filtering, redaction](../operations/notifications.md) |
117
+ | `handlers` | — | [`custom` action](../policy/actions.md#custom) handlers |
118
+ | `resolver`, `clock` | system | injected for tests and the [corpus](../testing/corpus.md) |
119
+ | `actorWindowMs` | `900_000` | how long an idle [actor](../concepts/actors.md) is remembered |
120
+ | `maxActors` | `20_000` | a memory budget; past it the least recently seen actor is evicted |
121
+ | `exposeVerdictHeaders` | `false` | **leave it off** — see below |
122
+ | `metrics` | `true` | or `{ perDetectorTiming: true }` |
123
+ | `onError`, `onWarning` | — | |
124
+
125
+ ### `exposeVerdictHeaders`
126
+
127
+ An `X-Bot-Score` in the *response* is a live feedback signal for anyone tuning a scraper
128
+ against you: change one header, watch the number fall, iterate. Request-side tagging gives
129
+ your application the same information and tells the client nothing. It is off by default and
130
+ should stay off.
131
+
132
+ ---
133
+
134
+ ## Performance and memory
135
+
136
+ Detection on a clean browser request costs about **9 µs** to assess and **12 µs** end to
137
+ end, and allocates a few hundred bytes. `npm run bench` reports the median of several
138
+ rounds; `npm run bench:guard` enforces a ratio-based budget so a regression fails the build
139
+ rather than shipping quietly.
140
+
141
+ Five things got it there, each a trap the next person will meet too:
142
+
143
+ - `header-order` computed an HMAC fingerprint on every request before knowing whether it had
144
+ anything to report — it cost more than the other twelve detectors combined.
145
+ - `randomBytes` was called per request for the request id; it is now drawn from a pooled
146
+ buffer.
147
+ - The User-Agent tokeniser ran eagerly, when a mainstream browser string never needs it.
148
+ - The notification event — an ISO timestamp included — was built before checking whether any
149
+ sink was listening.
150
+ - Per-detector timing allocated a closure per detector per request *whether or not it was
151
+ switched on*: 8 µs a request to support a feature that is off by default. The benchmark
152
+ caught that one, which is what a benchmark is for.
153
+
154
+ Signatures compile once into an Aho–Corasick automaton with a flat transition table for the
155
+ root node, so matching is O(input length) regardless of how many signatures exist, and text
156
+ that matches nothing allocates nothing at all.
157
+
158
+ DNS runs only when a request actually claimed a verifiable identity, and results are cached:
159
+ successes for an hour, transient failures for a minute, definitive absences for an hour.
160
+
161
+ Per-actor state is a fixed budget: 32 arrival timestamps, 64 path *hashes* (not strings), up
162
+ to 4 User-Agents. At 20,000 tracked actors that is tens of megabytes, not hundreds. Every
163
+ structure keyed by anything a client controls is a bounded LRU — an unbounded map here would
164
+ be a remote OOM.
165
+
166
+ The trade-off: `requestsWithin` saturates at 32, so a very heavy actor is reported as "at
167
+ least 32" with `undercounted: true` rather than at its true rate. Deliberate — that series
168
+ exists to describe an actor cheaply, and exact counting belongs to the
169
+ [`rate-limit`](../policy/actions.md#rate-limit) action, which uses the store.
170
+
171
+ ## Related
172
+
173
+ - [API](api.md) — the methods and the exported names
174
+ - [The guard](../concepts/the-guard.md) — the four options that decide how far a rule may go
175
+ - [Runtime changes](../operations/runtime-changes.md) — what can be changed without a restart
@@ -0,0 +1,127 @@
1
+ # Choosing a policy
2
+
3
+ The decision, start to finish.
4
+
5
+ ← [Documentation](../index.md) · [Getting started](../index.md)
6
+
7
+ ---
8
+
9
+ ## Start with the question, not the preset
10
+
11
+ Not "how do I block bots" but: **what is this site for, and what would it cost me to be
12
+ wrong?**
13
+
14
+ | If being wrong about a *person* costs you… | and being wrong about a *bot* costs you… | start with |
15
+ | --- | --- | --- |
16
+ | a reader | some bandwidth | [`allow-crawlers`](../policy/presets.md#allow-crawlers) |
17
+ | a customer | your content, scraped | [`protect-content`](../policy/presets.md#protect-content) |
18
+ | a customer | your pricing, in a competitor's spreadsheet | [`protect-data`](../policy/presets.md#protect-data) |
19
+ | a broken integration | an API bill | [`protect-api`](../policy/presets.md#protect-api) |
20
+ | a lost signup | a compromised account | [`protect-auth`](../policy/presets.md#protect-auth), on those routes |
21
+ | your search traffic | your work in a training corpus | [`decline-ai-training`](../policy/presets.md#decline-ai-training) |
22
+
23
+ Whatever the answer, **the first week is [`monitor-only`](../policy/presets.md#monitor-only)**.
24
+
25
+ ## The four questions worth answering explicitly
26
+
27
+ ### 1. Which crawlers do you actually want?
28
+
29
+ This is a business decision, not a security one, and it deserves to be a visible rule you
30
+ can point at rather than a threshold somebody tuned.
31
+
32
+ Search and social crawlers bring readers. AI training crawlers take work and return nothing
33
+ you can measure. SEO crawlers serve your competitors. Feed readers and link unfurlers are
34
+ somebody's actual reading habit.
35
+
36
+ If you decline any of them, **say so in [`robots.txt`](../policy/robots.md)** — the ones
37
+ worth declining honour it, and a rule that blocks a crawler nobody told is load with no
38
+ compliance.
39
+
40
+ ### 2. What happens to suspicion?
41
+
42
+ The [guard](../concepts/the-guard.md) settles the top of the range: unproven never means
43
+ denied. Below that, you choose between `tag`, `delay`, `rate-limit` and `challenge`, and the
44
+ right answer depends on who your unusual visitors are.
45
+
46
+ A `challenge` needs a browser. On an API it breaks your customers' integrations and stops
47
+ nobody, which is why [`protect-api`](../policy/presets.md#protect-api) uses rate limits
48
+ instead. A `delay` excludes nobody at all and is the quiet choice on a form.
49
+
50
+ Look at [`bothandler_score_bucket`](../operations/metrics.md) before you move a threshold. It
51
+ tells you how many requests sit in the ten points you are about to cross.
52
+
53
+ ### 3. Where does the policy *not* apply?
54
+
55
+ `ignorePaths` for health checks and static assets. `allowlist` for your monitors, your
56
+ office and your CI — and remember that an allowlisted address is not judged leniently, it is
57
+ not judged at all.
58
+
59
+ And the routes that need a *different* policy get their own handler:
60
+
61
+ ```ts
62
+ app.use(botHandler(siteDetector));
63
+ app.use("/login", botHandler(authDetector));
64
+ app.use("/checkout", botHandler(authDetector));
65
+ ```
66
+
67
+ ### 4. Who is allowed to change it, and how fast?
68
+
69
+ An incident is when you will want to change a rule without a deploy. Decide now whether
70
+ [the dashboard's editor](../operations/runtime-changes.md) is enabled, who can reach it, and
71
+ whether the [guard panel](../concepts/the-guard.md) is enabled separately — it is a different
72
+ power with different consequences.
73
+
74
+ ---
75
+
76
+ ## Check it before it meets anybody
77
+
78
+ ```bash
79
+ npx @osqd/bothandlerjs check --preset protect-content --audience human
80
+ npx @osqd/bothandlerjs replay /var/log/nginx/access.log --preset protect-content
81
+ ```
82
+
83
+ The first is [526 shapes of real traffic](../testing/corpus.md); the second is *yours*. Both
84
+ print every request the policy would have refused, with the evidence.
85
+
86
+ For your own configuration rather than a preset:
87
+
88
+ ```ts
89
+ import { runCorpus } from "@osqd/bothandlerjs/corpus";
90
+ const scorecard = await runCorpus({
91
+ create: ({ resolver, clock }) => new BotHandler({ ...myConfig, resolver, clock }),
92
+ assertActions: false,
93
+ });
94
+ if (scorecard.falsePositives.length > 0) throw new Error("this policy turns people away");
95
+ ```
96
+
97
+ ## Owning it rather than naming it
98
+
99
+ A preset is an array of [rules](../policy/rules.md). Once you have changed one thing, take
100
+ the whole set:
101
+
102
+ ```ts
103
+ import { protectContent } from "@osqd/bothandlerjs";
104
+
105
+ const rules = protectContent()
106
+ .filter((rule) => rule.id !== "http-client-challenge") // our partners use curl
107
+ .concat({ id: "partner-allow", match: { path: "/api/partner" }, action: "allow" });
108
+ ```
109
+
110
+ Your own rules are evaluated **before** a preset's, so `rules` plus `preset` is a valid way
111
+ to prepend exceptions without copying anything.
112
+
113
+ ## What to watch once it is live
114
+
115
+ | Signal | Means |
116
+ | ------ | ----- |
117
+ | `bothandler_downgrades_total` rising | your rules ask for more than the evidence supports |
118
+ | `challenge-solve-rate` high | the challenges are taxing people, not filtering bots |
119
+ | `verdicts_total{verdict="unknown"}` falling | your traffic changed, or your detection did |
120
+ | a `denial-spike` anomaly | read the denials before assuming they are all bots |
121
+
122
+ ## Next
123
+
124
+ - [Presets](../policy/presets.md) — all eight, in detail
125
+ - [Rules](../policy/rules.md) — the grammar
126
+ - [Actions](../policy/actions.md) — ordered by what each costs a person
127
+ - [Threat model](../concepts/threat-model.md) — what none of this can do
@@ -0,0 +1,127 @@
1
+ # Your first integration
2
+
3
+ Working in five minutes, safe in one week.
4
+
5
+ ← [Documentation](../index.md) · [Getting started](../index.md)
6
+
7
+ ---
8
+
9
+ ## The five minutes
10
+
11
+ ```ts
12
+ import { BotHandler } from "@osqd/bothandlerjs";
13
+ import { botHandler } from "@osqd/bothandlerjs/adapters";
14
+
15
+ const detector = new BotHandler({
16
+ preset: "monitor-only", // watch first; enforce later
17
+ allowlist: ["10.0.0.0/8"], // your monitors and CI
18
+ ignorePaths: ["/healthz", "/metrics"],
19
+ });
20
+
21
+ app.use(botHandler(detector));
22
+
23
+ await detector.serveDashboard({ port: 9674 });
24
+ ```
25
+
26
+ Open **http://localhost:9674/**. Every request lands in the feed with its verdict, and any
27
+ row opens to show the [evidence](../concepts/evidence.md) behind it.
28
+
29
+ Nothing is being withheld from anybody — [`monitor-only`](../policy/presets.md#monitor-only)
30
+ never acts. That is deliberate, and it is step one of four.
31
+
32
+ ## The four steps
33
+
34
+ ### 1. Watch, for a week
35
+
36
+ Run `monitor-only` on real traffic and look at the dashboard. You are looking for the
37
+ integration you forgot about: the partner's nightly sync, the status-page prober, the
38
+ marketing team's link checker, your own server-side renderer.
39
+
40
+ Every bot policy that has caused an outage was deployed straight to enforcement by someone
41
+ who was sure they knew what their traffic looked like.
42
+
43
+ ### 2. Fix the client address
44
+
45
+ If anything sits in front of your process — a load balancer, a CDN, nginx, a service mesh —
46
+ this is the setting that matters most:
47
+
48
+ ```ts
49
+ proxy: { trustProxy: true, trustedProxies: ["10.0.0.0/8"] }
50
+ ```
51
+
52
+ The client address becomes the [actor key](../concepts/actors.md), which every rate limit,
53
+ allowlist entry and behavioural signal depends on. Getting it wrong is silent. Read
54
+ [the client IP](../integration/client-ip.md) — it is short and it is the page most worth
55
+ reading twice.
56
+
57
+ ### 3. Check the policy you are about to enable
58
+
59
+ ```bash
60
+ npx @osqd/bothandlerjs check --preset protect-content
61
+ npx @osqd/bothandlerjs replay /var/log/nginx/access.log --preset protect-content
62
+ ```
63
+
64
+ The first runs your policy against [526 shapes of real traffic](../testing/corpus.md); the
65
+ second runs it against yours. Read the list of would-be-denied requests. If any of them is a
66
+ person, the policy is wrong — and you found out from a log file.
67
+
68
+ Put the `check` in CI. It exits non-zero when a case marked as a person is denied.
69
+
70
+ ### 4. Enable it
71
+
72
+ ```ts
73
+ const detector = new BotHandler({
74
+ preset: "protect-content",
75
+ proxy: { trustProxy: true, trustedProxies: ["10.0.0.0/8"] },
76
+ challenge: {
77
+ secrets: [process.env.BOT_CHALLENGE_SECRET!],
78
+ contactHtml: '<p>Locked out? Email <a href="mailto:support@example.com">support@example.com</a>.</p>',
79
+ },
80
+ allowlist: ["10.0.0.0/8"],
81
+ ignorePaths: ["/healthz", "/metrics"],
82
+ onDowngrade: ({ decision }) => log.warn(`rule ${decision.rule} asked for more than its evidence`),
83
+ });
84
+ ```
85
+
86
+ Then watch `bothandler_downgrades_total` — see [metrics](../operations/metrics.md).
87
+
88
+ ## What a working configuration does
89
+
90
+ | Request | Result |
91
+ | ------- | ------ |
92
+ | A real Chrome navigation | `200` — served, untouched |
93
+ | `curl https://yoursite/` | challenge page — proven `http-client` |
94
+ | A `Googlebot` UA from an address DNS refutes | `403` — proven `impersonator` |
95
+ | `sqlmap` | `403` — self-identified scanner |
96
+ | A hit on a [trap](../detection/detectors.md) link | `403` — no person can reach it |
97
+ | `GET /healthz` | `200` — never assessed |
98
+ | Real Googlebot, confirmed by DNS | `200` — explicitly allowed |
99
+
100
+ ## Three things people wish they had done sooner
101
+
102
+ **Replace `actorKey`.** An address is a poor identity — shared by an office, changed by a
103
+ phone every few minutes. A session id makes every behavioural detector sharper:
104
+
105
+ ```ts
106
+ actorKey: (facts) => facts.session ?? facts.ip
107
+ ```
108
+
109
+ **Add `isHuman`.** Your authenticated session is the only conclusive human signal that
110
+ exists. Telling the library about it stops it second-guessing your own customers:
111
+
112
+ ```ts
113
+ isHuman: (facts) => Boolean(sessions.get(facts.session ?? "")?.authenticated)
114
+ ```
115
+
116
+ **Mount [`protect-auth`](../policy/presets.md#protect-auth) separately** on login, signup and
117
+ checkout — and *only* there. Site-wide it refuses your webhooks and your renderer.
118
+
119
+ ```ts
120
+ app.use("/login", botHandler(authDetector));
121
+ ```
122
+
123
+ ## Next
124
+
125
+ - [Choosing a policy](choosing-a-policy.md)
126
+ - [Adapters](../integration/adapters.md) — Fastify, Koa, Hono, Next.js, Workers
127
+ - [The dashboard](../operations/dashboard.md) — before you expose it anywhere real
@@ -0,0 +1,63 @@
1
+ # Installation
2
+
3
+ ← [Documentation](../index.md) · [Getting started](../index.md)
4
+
5
+ ---
6
+
7
+ ```bash
8
+ npm install @osqd/bothandlerjs
9
+ ```
10
+
11
+ **Node 18 or later.** ESM and CJS builds, TypeScript declarations included.
12
+
13
+ ## Zero runtime dependencies
14
+
15
+ The library imports nothing but `node:` builtins, and CI fails if that ever stops being
16
+ true. Nothing here can hand your project a transitive advisory, an install script, or a
17
+ version conflict with something you already run.
18
+
19
+ Redis, if you use it, is your client passed in: `RedisStore` describes the five commands it
20
+ needs structurally and imports neither `ioredis` nor `node-redis`.
21
+
22
+ ```ts
23
+ import { RedisStore } from "@osqd/bothandlerjs";
24
+ new BotHandler({ store: new RedisStore(redis) });
25
+ ```
26
+
27
+ ## The entry points
28
+
29
+ | Import | Contains |
30
+ | ------ | -------- |
31
+ | `@osqd/bothandlerjs` | the engine, detectors, presets, robots, stores, notifiers, challenge, dashboard |
32
+ | `@osqd/bothandlerjs/adapters` | [Express, Fastify, Koa, Fetch](../integration/adapters.md) |
33
+ | `@osqd/bothandlerjs/client` | the browser-side [client signals](../detection/client-signals.md) script |
34
+ | `@osqd/bothandlerjs/corpus` | [`runCorpus`](../testing/corpus.md) and all 526 cases |
35
+ | `@osqd/bothandlerjs/cli` | the [command line](../testing/cli.md) |
36
+
37
+ ## Runtimes
38
+
39
+ | Runtime | Notes |
40
+ | ------- | ----- |
41
+ | Node 18+ | everything works |
42
+ | Bun, Deno | use the [Fetch adapter](../integration/adapters.md) |
43
+ | Cloudflare Workers | enable `nodejs_compat` — the engine uses `node:crypto` |
44
+ | Vercel Edge, Netlify Edge | Fetch adapter; set `clientIp` explicitly |
45
+
46
+ On every Fetch runtime, two things differ. There is no socket, so the client address comes
47
+ from a header — read [the client IP](../integration/client-ip.md) before you deploy. And
48
+ header order is normalised, so `header-order` returns nothing there; drop it.
49
+
50
+ ## Without installing anything
51
+
52
+ Every CLI command runs through `npx`:
53
+
54
+ ```bash
55
+ npx @osqd/bothandlerjs explain "curl/8.4.0"
56
+ npx @osqd/bothandlerjs check --preset protect-content
57
+ npx @osqd/bothandlerjs replay /var/log/nginx/access.log
58
+ ```
59
+
60
+ ## Next
61
+
62
+ - [Your first integration](first-integration.md)
63
+ - [Choosing a policy](choosing-a-policy.md)
@@ -0,0 +1,72 @@
1
+ # Upgrading
2
+
3
+ ← [Documentation](../index.md) · [Getting started](../index.md)
4
+
5
+ ---
6
+
7
+ ## Versioning
8
+
9
+ Semantic versioning, with one clarification that matters for a library like this:
10
+
11
+ **A new detector, or a signature added to the database, is a minor release.** It can change
12
+ what your policy does to a request that was previously unrecognised — that is the point of
13
+ installing it — but it cannot change the [guard's](../concepts/the-guard.md) guarantee, and
14
+ it cannot turn a probabilistic verdict into a proven one.
15
+
16
+ **A change to how a tier is assigned is a major release.** Moving something from `strong` to
17
+ `certain` changes what may be denied, which is exactly the boundary this library exists to
18
+ hold still.
19
+
20
+ ## Before you upgrade
21
+
22
+ Run your own configuration against the corpus and compare:
23
+
24
+ ```bash
25
+ npx @osqd/bothandlerjs check --preset protect-content --json > before.json
26
+ npm install @osqd/bothandlerjs@latest
27
+ npx @osqd/bothandlerjs check --preset protect-content --json > after.json
28
+ ```
29
+
30
+ Or, for a configuration that is not a preset:
31
+
32
+ ```ts
33
+ import { runCorpus } from "@osqd/bothandlerjs/corpus";
34
+ const scorecard = await runCorpus({
35
+ create: ({ resolver, clock }) => new BotHandler({ ...myConfig, resolver, clock }),
36
+ assertActions: false,
37
+ });
38
+ ```
39
+
40
+ The invariant — nothing marked as a person is denied — holds across versions and is checked
41
+ on every release. What can move is which *bots* a policy catches, and the scorecard shows
42
+ that as a diff rather than a surprise.
43
+
44
+ ## 0.x
45
+
46
+ The library is pre-1.0. The engine, the evidence model, the guard and the policy grammar are
47
+ stable and are what everything else is built on; the surfaces most likely to change before
48
+ 1.0 are the dashboard's options and the shape of the corpus scorecard.
49
+
50
+ Pin an exact version if you depend on either:
51
+
52
+ ```json
53
+ { "dependencies": { "@osqd/bothandlerjs": "0.2.0" } }
54
+ ```
55
+
56
+ ## When a signature changes hands
57
+
58
+ Not a library upgrade, but the same class of problem: a crawler's published ranges go stale,
59
+ and a stale list turns a verified crawler into an accused impersonator.
60
+
61
+ ```ts
62
+ import { startCrawlerRangeRefresh } from "@osqd/bothandlerjs";
63
+ const stop = startCrawlerRangeRefresh(botHandler); // twice a day
64
+ ```
65
+
66
+ See [verification](../detection/verification.md) and
67
+ [runtime changes](../operations/runtime-changes.md).
68
+
69
+ ## Related
70
+
71
+ - [The corpus](../testing/corpus.md) — the regression suite you can run yourself
72
+ - [Design decisions](../design/decisions.md) — what a future change would be undoing
@@ -0,0 +1,119 @@
1
+ # The CLI
2
+
3
+ Five commands, no server, no installation.
4
+
5
+ ← [Documentation](../index.md) · [Testing](index.md)
6
+
7
+ ---
8
+
9
+ ```
10
+ bothandlerjs replay <file> [options] Replay an access log and report what would have happened
11
+ bothandlerjs check [--preset <p>] Run a policy against the traffic corpus: who would it hurt?
12
+ bothandlerjs explain [request] Assess one request and show the evidence behind the verdict
13
+ bothandlerjs robots [options] Generate a robots.txt from a policy preset
14
+ bothandlerjs detectors [--preset <p>] List the detectors a configuration installs
15
+ ```
16
+
17
+ ---
18
+
19
+ ## `explain`
20
+
21
+ The question that arrives by ticket rather than by traffic.
22
+
23
+ ```bash
24
+ bothandlerjs explain "curl/8.4.0"
25
+ pbpaste | bothandlerjs explain --preset protect-data
26
+ bothandlerjs explain --ip 203.0.113.9 --url /checkout "Mozilla/5.0 ..."
27
+ ```
28
+
29
+ It takes a User-Agent, a `curl` command copied out of devtools, or a raw header block — from
30
+ an argument or from stdin — and prints the verdict, the rule that would fire, and every
31
+ piece of [evidence](../concepts/evidence.md) behind it.
32
+
33
+ It runs as a **[dry run](../detection/index.md)**, so nothing is recorded and asking does
34
+ not change the answer. It has no history by construction, so what it answers is *what would
35
+ this look like as a first request* — which is what a ticket is asking anyway.
36
+
37
+ ## `check`
38
+
39
+ The CI step.
40
+
41
+ ```bash
42
+ npx @osqd/bothandlerjs check --preset protect-content
43
+ ```
44
+
45
+ ```
46
+ protect-content against 522 shapes of real traffic
47
+
48
+ human 181 cases 3 tag, 175 allow, 3 challenge
49
+ benign-bot 144 cases 6 allow, 86 tag, 48 block, 2 challenge, 2 rate-limit
50
+ declared-bot 32 cases 18 tag, 2 rate-limit, 3 allow, 9 block
51
+ unwanted-bot 105 cases 8 tag, 11 rate-limit, 3 block, 70 challenge, 13 allow
52
+ hostile 27 cases 6 challenge, 17 block, 4 allow
53
+ infrastructure 33 cases 7 allow, 12 tag, 14 challenge
54
+
55
+ No case marked as a person was denied service.
56
+ ```
57
+
58
+ The question this library is organised around, asked before a deploy rather than after one:
59
+ *if I point this configuration at the actual internet, who gets hurt?*
60
+
61
+ **It exits non-zero if any case marked `human` is denied service**, which is what makes it a
62
+ CI step rather than a report.
63
+
64
+ | Option | |
65
+ | ------ | - |
66
+ | `--preset <name>` | which policy to test (default `protect-content`) |
67
+ | `--audience <a>` | `human` \| `benign-bot` \| `declared-bot` \| `unwanted-bot` \| `hostile` \| `infrastructure` |
68
+ | `--json` | the scorecard, for a pipeline |
69
+ | `--strict` | also fail on differing actions, not only the invariants |
70
+
71
+ `--audience human` is the one to reach for first: it shows only the part that matters.
72
+
73
+ See [the corpus](corpus.md) for what it is running against.
74
+
75
+ ## `replay`
76
+
77
+ Your policy against your own traffic. See [log replay](replay.md) — including the reason
78
+ JSON Lines is much better than CLF.
79
+
80
+ ```bash
81
+ npx @osqd/bothandlerjs replay /var/log/nginx/access.log --preset protect-content
82
+ ```
83
+
84
+ | Option | |
85
+ | ------ | - |
86
+ | `--preset <name>` | default `protect-content` |
87
+ | `--limit <n>` | stop after n parsed lines |
88
+ | `--show <n>` | how many would-be-denied requests to print in full (default 10) |
89
+ | `--format <f>` | `clf` \| `json` (detected from the first line by default) |
90
+ | `--json` | the report, machine-readable |
91
+
92
+ ## `robots`
93
+
94
+ ```bash
95
+ bothandlerjs robots --preset decline-ai-training --sitemap https://example.com/sitemap.xml > public/robots.txt
96
+ ```
97
+
98
+ Generates a `robots.txt` from a preset's rules (default preset: `protect-data`), with the
99
+ trap paths disallowed for you.
100
+
101
+ Notes about rules that could not be reflected go to **stderr**, so a redirect into the file
102
+ stays clean while the reasoning still reaches whoever ran the command. Read them — see
103
+ [robots.txt](../policy/robots.md) for why an unreflected rule matters.
104
+
105
+ ## `detectors`
106
+
107
+ ```bash
108
+ bothandlerjs detectors --preset protect-api
109
+ ```
110
+
111
+ Lists what a configuration actually installs, with each detector's cost and stage. Useful
112
+ for the question "is `identity-rotation` on?", which has caused more confusion than any
113
+ other single setting.
114
+
115
+ ## Related
116
+
117
+ - [The corpus](corpus.md) · [Log replay](replay.md)
118
+ - [Detectors](../detection/detectors.md) — what `detectors` is listing
119
+ - [robots.txt](../policy/robots.md) — what `robots` is generating