@osqd/bothandlerjs 0.3.0 → 0.5.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 (76) hide show
  1. package/CHANGELOG.md +157 -1
  2. package/README.md +1 -1
  3. package/dist/challenge/index.d.ts +18 -0
  4. package/dist/challenge/interaction.d.ts +215 -0
  5. package/dist/challenge/page.d.ts +18 -0
  6. package/dist/cli.cjs +829 -71
  7. package/dist/cli.cjs.map +1 -1
  8. package/dist/cli.js +829 -71
  9. package/dist/cli.js.map +1 -1
  10. package/dist/config.d.ts +10 -0
  11. package/dist/core.d.ts +15 -0
  12. package/dist/corpus/index.cjs +20 -0
  13. package/dist/corpus/index.cjs.map +1 -1
  14. package/dist/corpus/index.js +20 -0
  15. package/dist/corpus/index.js.map +1 -1
  16. package/dist/dashboard/client/actions.d.ts +17 -0
  17. package/dist/dashboard/client/actor.d.ts +5 -0
  18. package/dist/dashboard/client/api.d.ts +15 -0
  19. package/dist/dashboard/client/app.d.ts +37 -0
  20. package/dist/dashboard/client/bars.d.ts +4 -0
  21. package/dist/dashboard/client/boot.d.ts +5 -0
  22. package/dist/dashboard/client/charts.d.ts +25 -0
  23. package/dist/dashboard/client/css.d.ts +11 -0
  24. package/dist/dashboard/client/dom.d.ts +55 -0
  25. package/dist/dashboard/client/draft.d.ts +46 -0
  26. package/dist/dashboard/client/feed.d.ts +22 -0
  27. package/dist/dashboard/client/format.d.ts +37 -0
  28. package/dist/dashboard/client/guard.d.ts +3 -0
  29. package/dist/dashboard/client/index.d.ts +1 -0
  30. package/dist/dashboard/client/outcome.d.ts +33 -0
  31. package/dist/dashboard/client/pager.d.ts +32 -0
  32. package/dist/dashboard/client/panels.d.ts +40 -0
  33. package/dist/dashboard/client/policy.d.ts +25 -0
  34. package/dist/dashboard/client/query.d.ts +42 -0
  35. package/dist/dashboard/client/ranges.d.ts +2 -0
  36. package/dist/dashboard/client/registry.d.ts +4 -0
  37. package/dist/dashboard/client/replay.d.ts +14 -0
  38. package/dist/dashboard/client/result.d.ts +10 -0
  39. package/dist/dashboard/client/store.d.ts +128 -0
  40. package/dist/dashboard/client/stream.d.ts +14 -0
  41. package/dist/dashboard/client/tester.d.ts +1 -0
  42. package/dist/dashboard/client/types.d.ts +69 -0
  43. package/dist/dashboard/client.generated.d.ts +1 -1
  44. package/dist/dashboard/page.d.ts +25 -0
  45. package/dist/dashboard/sections.d.ts +92 -0
  46. package/dist/dashboard/types.d.ts +2 -82
  47. package/dist/detectors/crawler-verification.d.ts +34 -1
  48. package/dist/detectors/index.d.ts +4 -1
  49. package/dist/detectors/known-bots.d.ts +49 -1
  50. package/dist/element/config.d.ts +135 -0
  51. package/dist/element/index.cjs +5150 -0
  52. package/dist/element/index.cjs.map +1 -0
  53. package/dist/element/index.d.ts +137 -0
  54. package/dist/element/index.js +5131 -0
  55. package/dist/element/index.js.map +1 -0
  56. package/dist/index.cjs +820 -71
  57. package/dist/index.cjs.map +1 -1
  58. package/dist/index.d.ts +2 -0
  59. package/dist/index.js +811 -71
  60. package/dist/index.js.map +1 -1
  61. package/dist/metrics.d.ts +27 -0
  62. package/dist/policy/index.d.ts +1 -1
  63. package/dist/policy/presets.d.ts +47 -0
  64. package/dist/state.d.ts +1 -1
  65. package/docs/challenge/index.md +1 -0
  66. package/docs/challenge/interaction.md +325 -0
  67. package/docs/course/11-the-challenge.md +36 -1
  68. package/docs/course/13-operating-it.md +20 -2
  69. package/docs/detection/signatures.md +36 -0
  70. package/docs/index.md +2 -0
  71. package/docs/operations/dashboard.md +28 -0
  72. package/docs/operations/embedding.md +348 -0
  73. package/docs/operations/index.md +1 -0
  74. package/docs/policy/presets.md +53 -0
  75. package/docs/start/choosing-a-policy.md +1 -0
  76. package/package.json +8 -3
package/dist/metrics.d.ts CHANGED
@@ -47,6 +47,18 @@ export interface MetricsSnapshot {
47
47
  solved: number;
48
48
  rejected: number;
49
49
  };
50
+ /**
51
+ * Clearance granted, by level, and why the rest were turned down.
52
+ *
53
+ * Without these an operator running the interaction challenge is tuning blind. The
54
+ * split between `pow` and `interaction` says how many clients are clearing the higher
55
+ * bar; the rejection reasons say whether a rise is bots or a population whose browsers
56
+ * cannot answer a probe — and those call for opposite responses.
57
+ */
58
+ clearances: Record<string, number>;
59
+ challengeRejections: Record<string, number>;
60
+ /** Interaction scores, in ten buckets of 0.1. The distribution `interactionAt` sits in. */
61
+ interactionScores: number[];
50
62
  /**
51
63
  * How suspicion is distributed across the traffic that was scored.
52
64
  *
@@ -91,6 +103,9 @@ export declare class Metrics {
91
103
  private challengesIssued;
92
104
  private challengesSolved;
93
105
  private challengesRejected;
106
+ private readonly clearances;
107
+ private readonly challengeRejections;
108
+ private readonly interactionScores;
94
109
  private scoreCount;
95
110
  private scoreTotal;
96
111
  private readonly scoreBuckets;
@@ -104,6 +119,18 @@ export declare class Metrics {
104
119
  recordDetectorTiming(detector: string, ms: number): void;
105
120
  recordDecision(decision: Decision): void;
106
121
  recordChallenge(event: "issued" | "solved" | "rejected"): void;
122
+ /** Which clearance a solve earned. */
123
+ recordClearance(level: string): void;
124
+ /**
125
+ * Why a challenge was turned down, bucketed by cause.
126
+ *
127
+ * Bucketed rather than recorded verbatim: the reason string carries measured numbers
128
+ * ("answered in 120ms"), and a counter keyed on those would grow without bound on a
129
+ * label an attacker controls the shape of.
130
+ */
131
+ recordChallengeRejection(reason: string): void;
132
+ /** The interaction score a solve was graded on, into ten buckets of 0.1. */
133
+ recordInteractionScore(score: number): void;
107
134
  snapshot(actorsTracked: number): MetricsSnapshot;
108
135
  }
109
136
  export interface PrometheusOptions {
@@ -1,7 +1,7 @@
1
1
  export { Policy } from "./policy.js";
2
2
  export type { GuardSettings } from "./policy.js";
3
3
  export { compileMatch, independentStrongSignals } from "./match.js";
4
- export { monitorOnly, allowCrawlers, protectContent, declineAiTraining, protectData, protectApi, protectAuth, underAttack, PRESETS } from "./presets.js";
4
+ export { monitorOnly, allowCrawlers, protectContent, declineAiTraining, protectData, protectApi, protectAuth, indexersOnly, underAttack, PRESETS } from "./presets.js";
5
5
  export type { PresetName } from "./presets.js";
6
6
  export { ACTION_NAMES, TERMINAL_ACTIONS } from "./types.js";
7
7
  export type { ActionName, ActionParams, Decision, FalsePositivePolicy, MatchSpec, PolicyOptions, Rule } from "./types.js";
@@ -111,6 +111,52 @@ export declare function declineAiTraining(): Rule[];
111
111
  * challenges one on a content site is deliberately absent.
112
112
  */
113
113
  export declare function protectApi(): Rule[];
114
+ /**
115
+ * Indexers welcome, everything else automated is not.
116
+ *
117
+ * The strictest permanent posture in this file: a crawler is served only when its
118
+ * identity has been *confirmed* against its operator's DNS or published ranges, and
119
+ * only when the job it does is bringing people to the site. Every other proven bot is
120
+ * refused, and suspicion escalates as far as the guard allows.
121
+ *
122
+ * Three things to be clear about before choosing it.
123
+ *
124
+ * **Most indexers cannot be verified at all.** Of the search and social signatures the
125
+ * library ships, twelve publish forward-confirmable DNS — Google, Bing, Yandex, Baidu,
126
+ * Apple, Sogou, Seznam, Naver, PetalBot, Cốc Cốc, Yahoo, Pinterest — and two more
127
+ * (DuckDuckBot, Facebook) are checkable only if you supply `crawlerRanges`. The other
128
+ * twenty-three, Twitterbot, LinkedInBot, Slackbot, Discord, Telegram, WhatsApp,
129
+ * Reddit, Mastodon and Bluesky among them, publish nothing to check a claim against.
130
+ * They can never reach `verified-bot`, so `unverifiable-indexer-block` refuses them and
131
+ * your pages stop getting link previews when somebody shares them. That rule is
132
+ * deliberately separate and deliberately named: change its action to `rate-limit` and
133
+ * you serve them at a ceiling instead, which is the trade every site makes differently.
134
+ *
135
+ * **A confirmed crawler outside `search` and `social` is refused too** — the AI
136
+ * crawlers, the SEO tools, the archivers, the uptime monitors, and **your own webhooks,
137
+ * health probes and server-side renderer**. That last group is how this preset breaks
138
+ * your own infrastructure on the first deploy; fifteen of the corpus's thirty-three
139
+ * infrastructure cases are refused by it. Allowlist yours first, by identity or by
140
+ * address, above everything else:
141
+ *
142
+ * ```ts
143
+ * rules: [{ id: "our-renderer", match: { identity: "our-ssr" }, action: "allow" }, ...indexersOnly()]
144
+ * ```
145
+ *
146
+ * **Suspicion is challenged, not blocked**, and no rule here asks otherwise. Under the
147
+ * default `strict` guard a `block` on a probabilistic verdict is downgraded to a
148
+ * challenge anyway, so writing one would express a strictness the engine does not have
149
+ * while reporting a guard stop on every suspicious request. If you want denial on
150
+ * suspicion, say so where it is visible — `falsePositivePolicy: "balanced"`, which
151
+ * additionally demands two independent strong signals — and add the rule that asks:
152
+ *
153
+ * ```ts
154
+ * { id: "high-suspicion-block", match: { verdict: "suspected-bot", minScore: 90 }, action: "block" }
155
+ * ```
156
+ *
157
+ * Some real people will be denied by that. Nothing in this preset does it for you.
158
+ */
159
+ export declare function indexersOnly(): Rule[];
114
160
  /**
115
161
  * For while it is happening.
116
162
  *
@@ -156,6 +202,7 @@ export declare const PRESETS: {
156
202
  readonly "protect-data": typeof protectData;
157
203
  readonly "protect-api": typeof protectApi;
158
204
  readonly "protect-auth": typeof protectAuth;
205
+ readonly "indexers-only": typeof indexersOnly;
159
206
  readonly "under-attack": typeof underAttack;
160
207
  };
161
208
  export type PresetName = keyof typeof PRESETS;
package/dist/state.d.ts CHANGED
@@ -153,7 +153,7 @@ export declare class ActorRegistry {
153
153
  * A read, and only a read: it neither records a request against an actor nor moves
154
154
  * one up the LRU, so watching the list cannot change what it lists.
155
155
  */
156
- top(limit: number, now: number): ActorSummary[];
156
+ top(limit: number, now: number, offset?: number): ActorSummary[];
157
157
  forget(key: string): void;
158
158
  clear(): void;
159
159
  /** Marks an actor as holding valid human clearance until `until`. */
@@ -146,6 +146,7 @@ and rules can read it:
146
146
 
147
147
  ## Related
148
148
 
149
+ - [The interaction challenge](interaction.md) — asking for a gesture, and probing the browser
149
150
  - [Localisation](localisation.md) — showing the page in a language the visitor reads
150
151
  - [Actions](../policy/actions.md) — where `challenge` sits on the ladder
151
152
  - [The guard](../concepts/the-guard.md) — why it is the ceiling for unproven verdicts
@@ -0,0 +1,325 @@
1
+ # The interaction challenge
2
+
3
+ A gesture and a browser examination, on top of the proof of work — and a precise account
4
+ of what that is worth.
5
+
6
+ ← [Documentation](../index.md) · [The challenge](index.md)
7
+
8
+ ---
9
+
10
+ ## What it adds
11
+
12
+ The [proof of work](index.md) demonstrates that a JavaScript engine ran. This asks for two
13
+ more things:
14
+
15
+ 1. **A deliberate gesture** — a checkbox the visitor ticks.
16
+ 2. **Evidence that a browser rendered the page** — six probes that read back things only a
17
+ real rendering engine can produce.
18
+
19
+ Turn it on with one option:
20
+
21
+ ```ts
22
+ new BotHandler({
23
+ challenge: {
24
+ secrets: [process.env.CHALLENGE_SECRET!],
25
+ contactHtml: '<p>Locked out? Email <a href="mailto:help@example.com">help@example.com</a>.</p>',
26
+ interaction: true,
27
+ },
28
+ });
29
+ ```
30
+
31
+ **Solving the puzzle alone no longer grants clearance when this is on.** The gesture is
32
+ required. Read [what it costs people](#what-it-costs-people) before you enable it.
33
+
34
+ ---
35
+
36
+ ## What is actually verifiable
37
+
38
+ Everything the page reports is client-supplied, and the client is the one place an
39
+ adversary has complete control. Being precise about this is the difference between a
40
+ security control and a decoration.
41
+
42
+ | Signal | Who says so | Can it be faked? |
43
+ | ------ | ----------- | ---------------- |
44
+ | **Elapsed time between issue and answer** | **the server**, from the signed token's `iat` | **no** |
45
+ | `isTrusted` on the activation | the client | yes, by a browser driven through CDP |
46
+ | Capability probes | the client | yes, by actually having a browser — which is the point |
47
+ | Pointer path | the client | yes, with deliberate effort |
48
+
49
+ Exactly one row is server-verified. The rest raise cost rather than establish fact.
50
+
51
+ ### The ceiling
52
+
53
+ Every probe here is answered by the client, and every value a probe asks about has to reach
54
+ the browser in order to be rendered. So anything willing to **parse the page it was served**
55
+ can answer without rendering anything. Measured, five challenges each:
56
+
57
+ | A client that… | Gets |
58
+ | -------------- | ---- |
59
+ | replays a report captured from a real browser | **0/5** |
60
+ | hardcodes a formula for the layout probe | **0/5** |
61
+ | **parses the served HTML and CSS each time** | **5/5** |
62
+ | actually renders the page | 5/5 |
63
+
64
+ Read the third row carefully. Against an adversary who writes a parser for your challenge
65
+ page, the interaction challenge adds **nothing over the plain proof of work except the
66
+ server-verified elapsed-time floor**. It is not a defence against somebody targeting you
67
+ specifically, and no client-side probe can be.
68
+
69
+ **What it does buy**, and this is worth having: it defeats every scraper that does not
70
+ bother — off-the-shelf tooling, `fetch()` in a loop, a report captured once and replayed, a
71
+ naive headless driver with a lerped mouse path. That is the overwhelming majority of
72
+ automated traffic, and for bulk extraction it is often the difference between worth doing
73
+ and not.
74
+
75
+ Nothing here is proof of humanity, and none of it is ever `certain`. If you need a bar an
76
+ adversary cannot step over by writing a parser, the bar is an account, not a challenge.
77
+
78
+ ---
79
+
80
+ ## The capability probes
81
+
82
+ Each reads back something that only exists if a rendering engine produced it. An HTTP
83
+ client that parsed the HTML has no answer to any of them.
84
+
85
+ One of them is different for every challenge. **The layout probe** asks the page to lay out
86
+ a block whose box count and box height are both drawn from the challenge's nonce **under the
87
+ signing secret**, and to report the measured height. The client cannot compute that answer;
88
+ it can only measure it.
89
+
90
+ An earlier version fixed the height at 7px in the stylesheet and derived the count from the
91
+ nonce in the page's own script, which made the answer a formula an attacker reads once and
92
+ hardcodes for ever — a client that never rendered anything answered it correctly five times
93
+ out of five.
94
+
95
+ That closes a hole the other probes leave wide open. Every other answer in a report is the
96
+ same from one challenge to the next, so a report captured once from a real browser could be
97
+ replayed against fresh challenges for ever: solve the puzzle headlessly, paste the blob,
98
+ and never run a browser again — skipping precisely the cost this feature exists to impose.
99
+ Measured before the probe existed, one captured report was accepted for **five consecutive
100
+ challenges**; with it, for one.
101
+
102
+ It is still not proof against somebody who parses the page: both numbers have to reach the
103
+ browser to be rendered, so a parser can find them. What it removes is the *formula* — there
104
+ is no longer a fixed rule to implement once and reuse — and what it stops outright is
105
+ *replay*. See [the ceiling](#the-ceiling).
106
+
107
+ | Probe | Weight | What it asks |
108
+ | ----- | -----: | ------------ |
109
+ | `cssApplied` | 0.30 | A computed `letter-spacing` that only exists if a CSSOM parsed the stylesheet and ran the cascade |
110
+ | `layout` | 0.20 | A laid-out element has a non-zero box |
111
+ | `fontMetrics` | 0.15 | The same string in two families measures differently — there is a font engine |
112
+ | `animationFrame` | 0.15 | Two frames arrive, a plausible interval apart |
113
+ | `mediaQuery` | 0.10 | A media query evaluates against a real viewport |
114
+ | `hiddenIsHidden` | 0.10 | `display: none` is honoured, not merely parsed |
115
+
116
+ `cssApplied` carries the most weight because it is the one that separates *the population
117
+ this challenge is aimed at* — HTTP-client scrapers — from browsers. A real browser passes
118
+ every probe, including a headless one; that is expected and is the cost being imposed.
119
+
120
+ ---
121
+
122
+ ## Reading the pointer
123
+
124
+ When the checkbox is ticked with a pointer, the page sends a bounded, quantised record of
125
+ the movement leading up to it — a rolling window of the **most recent** 128 samples of
126
+ `[dx, dy, dt]`, rounded to two decimal places. Most recent matters: a fixed buffer that
127
+ stopped accepting samples once full kept a reader's idle wandering and discarded the
128
+ approach to the control, which is the one movement the analysis exists to recognise. **The analysis runs on the server.** A page that scored itself would simply
129
+ be asked to report a good score.
130
+
131
+ Five properties are measured, and they are weighted by *what they cost an attacker who is
132
+ trying*:
133
+
134
+ | Property | Weight | Naive path | A hand |
135
+ | -------- | -----: | ---------- | ------ |
136
+ | Distance per sample | 0.30 | 0.00 | ~0.60 |
137
+ | Straightness | 0.25 | 1.00 | ~0.85 |
138
+ | Speed variation | 0.15 | 0.00 | ~0.98 |
139
+ | Timing jitter | 0.10 | 0.00 | ~0.50 |
140
+ | Acceleration changes | 0.10 | 0 | many |
141
+ | Sub-pixel coordinates | 0.10 | none | most samples |
142
+
143
+ **Distance per sample carries the most, and speed carries less than it looks like it
144
+ should.** Speed is distance over time, so jittering the event timing alone manufactures
145
+ speed variation for free — 0.33 on a perfectly straight constant-step path, measured — and
146
+ ragged dispatch timing is exactly what an awaited automation loop produces without trying.
147
+ Distance per sample reads only where the pointer went, so a constant-step path scores zero
148
+ however uneven its timing.
149
+
150
+ The last two terms are worth little on purpose: bolting random noise onto a straight line
151
+ maxes both out immediately and is nearly free.
152
+
153
+ A **discontinuity is excluded rather than measured**. A sample arriving more than 250 ms
154
+ after the last one is the pointer reappearing — entering the window, coming back from
155
+ another application, or simply resting — and the distance across that gap is not a distance
156
+ a hand travelled. This matters more than it sounds: a perfectly even synthetic path
157
+ measured through a real browser scored 0.76 on distance variation, indistinguishable from a
158
+ person, because of exactly one sample — the pointer's first appearance, a 60×60 jump
159
+ recorded 1.25 s after load. One outlier in twenty-six.
160
+
161
+ ### How much this actually separates
162
+
163
+ Fed paths directly, the analysis discriminates sharply:
164
+
165
+ | Path | Movement score |
166
+ | ---- | -------------: |
167
+ | Constant-velocity interpolation | **0.00** |
168
+ | The same line with random jitter | **0.38** |
169
+ | A short, quick human move | **0.69** |
170
+ | A full human move | **0.93** |
171
+
172
+ Driven through a **real browser**, it discriminates much less:
173
+
174
+ | Through Chromium | Movement | Composite |
175
+ | ---------------- | -------: | --------: |
176
+ | A person-like move | 0.75 | 0.90 |
177
+ | Evenly-stepped automation | 0.56 | 0.83 |
178
+
179
+ That gap is not wide enough to sit a threshold in, and raising `interactionAt` to catch the
180
+ second row would start refusing the first. **So be clear about what the movement analysis
181
+ is for:** it raises the bar for a client that *fabricates* a report without a browser —
182
+ where a lazy path scores 0.00 — and it is only a weak tiebreaker between two clients that
183
+ both really are browsers. The signals doing the load-bearing work against a real browser are
184
+ the server-verified elapsed time and the capability probes.
185
+
186
+ ---
187
+
188
+ ## How the clearance is graded
189
+
190
+ ```
191
+ score = capabilities × 0.6 + movement × 0.4
192
+ ```
193
+
194
+ | Outcome | Result |
195
+ | ------- | ------ |
196
+ | No interaction reported | **refused** |
197
+ | `isTrusted` false | **refused** |
198
+ | Answered faster than `minElapsedMs` | **refused** |
199
+ | Client claims it took longer than the challenge has existed | **refused** |
200
+ | The path describes more movement than the challenge lasted | **refused** |
201
+ | The layout probe is unanswered, or answers a different challenge | **refused** |
202
+ | Score below `refuseBelow` (0.2) | **refused** |
203
+ | Score at or above `interactionAt` (0.75) | `interaction` clearance |
204
+ | Anything in between | `pow` clearance |
205
+
206
+ `bothandler_interaction_score_bucket` counts refusals as well as successes. A distribution
207
+ fed only from what already passed has everything below the threshold cut out of it, which
208
+ is the part of the shape the threshold decision actually turns on.
209
+
210
+ Rejections name the probes that failed rather than only counting them — `capabilities 70%
211
+ (missing: fontMetrics, animationFrame)`. A percentage says something is wrong; the names say
212
+ whether it is bots or a population whose browsers cannot answer one particular question, and
213
+ those call for opposite responses.
214
+
215
+ `interactionAt` is set from measurement rather than taste. Fed a fabricated
216
+ constant-velocity path a client scores **0.60**, and a person in a browser scores **0.90**;
217
+ the bar sits between them. It does **not** separate crude automation inside a real browser
218
+ from a person — see the table above — and it is not set as though it does.
219
+
220
+ The [`clearance` detector](../detection/detectors.md) then reads the level: `interaction`
221
+ is `strong` human evidence at weight 0.7, `pow` is `moderate` at 0.45.
222
+
223
+ ### A keyboard is never penalised
224
+
225
+ A pointer path is worth something, and its **absence is worth nothing either way**. Voice
226
+ control produces no pointer movement. Switch access produces machine-regular timing. A
227
+ screen reader activates the control from the keyboard.
228
+
229
+ Scoring those down would put assistive technology on the wrong side of a check the rest of
230
+ this library exists to keep people out of. So a keyboard or unclassified activation is
231
+ graded on its capabilities and its timing alone.
232
+
233
+ The consequence, stated plainly: **claiming keyboard activation is the cheapest way
234
+ through, and it costs a real browser.** That is the floor this feature raises, and it is
235
+ deliberate. The alternative — penalising the absence of mouse movement — refuses people
236
+ for how they use a computer.
237
+
238
+ A **tap is not a mouse**, and is reported as its own thing. Touch produces almost no
239
+ `pointermove` — one sample, frequently none — so classifying a tap as a pointer would score
240
+ it zero for movement and quietly grade every phone down to the weaker clearance. The page
241
+ reads `click.detail`, which the platform sets to 0 for a keyboard activation and to the
242
+ click count for a pointer, and takes the device from `pointerdown`.
243
+
244
+ A path **too short to describe is no evidence, not bad evidence**. Somebody whose cursor
245
+ already rested on the control, or who nudged it a few pixels, is graded on capabilities like
246
+ a keyboard or a tap.
247
+
248
+ Scoring that as zero was tried first, on the reasoning that a pointer activation showing no
249
+ movement describes something that did not happen. Measured, it was wrong in both directions
250
+ at once: it downgraded honest clients, and it caught nobody — `via` is a field the client
251
+ fills in, so an attacker with no path to show simply writes `keyboard` and is graded on
252
+ capabilities like everybody else. A rule that only ever costs honest people something is not
253
+ strictness.
254
+
255
+ The discrimination that does work is untouched: a path of four samples or more that looks
256
+ interpolated scores zero, and that is a claim about movement the client *did* report.
257
+
258
+ ---
259
+
260
+ ## Tuning
261
+
262
+ ```ts
263
+ challenge: {
264
+ secrets: [SECRET],
265
+ interaction: {
266
+ minElapsedMs: 1000, // server-measured floor between issue and answer
267
+ interactionAt: 0.75, // at or above this, grant `interaction`
268
+ refuseBelow: 0.2, // below this, refuse outright
269
+ },
270
+ }
271
+ ```
272
+
273
+ **`challengeTtlMs` defaults to ten minutes when the gesture is on**, rather than the two
274
+ the plain challenge uses. Two minutes is the right budget for a puzzle a machine solves in
275
+ milliseconds; it is the wrong one for a page that stops and waits for a person to read it
276
+ and act. Somebody using a screen reader that announces the whole page, on a slow device
277
+ where the proof of work itself takes twenty seconds, or simply interrupted, runs out and is
278
+ told to reload — having done nothing wrong and with no way to know why.
279
+
280
+ `minElapsedMs` is the one worth raising during an incident. It is server-verified, so it is
281
+ a hard ceiling on how fast a farm can work through challenges however many browsers it
282
+ runs — and it costs a real visitor nothing, because a person takes longer than a second to
283
+ read a page and tick a box anyway.
284
+
285
+ ---
286
+
287
+ ## What it costs people
288
+
289
+ The gesture is a **checkbox**, and that choice is the whole accessibility argument. It is
290
+ the one interactive control that every way of using a computer can operate: pointer, touch,
291
+ the space bar, a screen reader, switch access, voice control. A slider, a press-and-hold or
292
+ an image puzzle would each exclude somebody.
293
+
294
+ The interstitial with the gesture is axe-audited in both themes at every release, the
295
+ control has a real `<label>`, and the page moves focus to it when the puzzle finishes so
296
+ that a keyboard user is put on the one remaining action rather than having to go looking.
297
+
298
+ **Who still cannot get through:** anyone without JavaScript or WebCrypto, and anyone whose
299
+ browser cannot run the probes — a text-mode browser, for instance. They could not complete
300
+ the plain proof of work either, so this changes nothing for them, and `contactHtml` remains
301
+ their route. Supply a real one.
302
+
303
+ When the page gives up, **it withdraws the checkbox** rather than leaving it on screen. A
304
+ live control that cannot work offers a way through that does not exist, and it is worst for
305
+ somebody using a screen reader, who would find it, activate it, and be told nothing.
306
+
307
+ ## Why not an image puzzle
308
+
309
+ Because they no longer work and they exclude people.
310
+
311
+ Machine learning solves image grids more accurately than humans do, and solving farms
312
+ charge about a dollar per thousand — so a visual CAPTCHA adds friction for people and a
313
+ rounding error for attackers. It also locks out blind and low-vision users, motor
314
+ impairments and cognitive disabilities.
315
+
316
+ The [guard](../concepts/the-guard.md) permits a `challenge` on a *guess* precisely because
317
+ the client can pass it on its own. For somebody who cannot see the puzzle, that is not a
318
+ challenge — it is a block, delivered on a guess. This library will not ship one.
319
+
320
+ ## Related
321
+
322
+ - [The challenge](index.md) — the proof of work this builds on
323
+ - [Localisation](localisation.md) — the interstitial in a language the visitor reads
324
+ - [Client signals](../detection/client-signals.md) — the same reasoning, on ordinary pages
325
+ - [Threat model](../concepts/threat-model.md) — what none of this can do
@@ -133,6 +133,41 @@ Passing a challenge cannot change a proven verdict, so re-issuing would loop for
133
133
  **Challenging on an API** breaks your customers' integrations and stops nobody — see
134
134
  `protect-api` in [lesson 10](10-actions-and-presets.md).
135
135
 
136
+ ## Asking for a gesture as well
137
+
138
+ The proof of work shows a JavaScript engine ran. One option asks for two more things — a
139
+ deliberate gesture, and evidence that a *browser* rendered the page:
140
+
141
+ ```js
142
+ challenge: {
143
+ secrets: [process.env.SERIF_CHALLENGE_SECRET],
144
+ contactHtml: "<p>…</p>",
145
+ interaction: true,
146
+ }
147
+ ```
148
+
149
+ The interstitial grows a checkbox, and six probes read back things only a rendering engine
150
+ produces — a computed style that needs the cascade to have run, a laid-out box, font
151
+ metrics, a frame loop. **Solving the puzzle alone no longer grants clearance**: the gesture
152
+ is required, and passing grants the stronger `interaction` clearance rather than `pow`.
153
+
154
+ The control is a checkbox rather than a slider or a puzzle for one reason: it is the only
155
+ interactive element every way of using a computer can operate — pointer, touch, the space
156
+ bar, a screen reader, switch access, voice control.
157
+
158
+ **Be clear about what it buys.** It does not prove a person. What it does is move a scraper
159
+ from `fetch()` in a loop to running a browser engine and rendering CSS per request, which
160
+ is three or four orders of magnitude more expensive. Exactly one signal in the exchange is
161
+ server-verified and cannot be faked: the elapsed time between issuing the challenge and
162
+ receiving the answer, taken from the signed token.
163
+
164
+ Watch it with the counters it emits — `bothandler_clearances_total{level=…}`,
165
+ `bothandler_challenge_rejections_total{cause=…}` and `bothandler_interaction_score_bucket`.
166
+ Without the score distribution, moving the threshold is guessing.
167
+
168
+ See [the interaction challenge](../challenge/interaction.md) for the full account,
169
+ including where its movement analysis stops working.
170
+
136
171
  ## Unsolved challenges as a signal
137
172
 
138
173
  Every issued-and-never-solved challenge is counted on the [actor](07-actors.md):
@@ -221,7 +256,7 @@ browser" over an English "email us" is half a fix.
221
256
 
222
257
  ## Reference
223
258
 
224
- - [The challenge](../challenge/index.md) · [Localisation](../challenge/localisation.md)
259
+ - [The challenge](../challenge/index.md) · [The interaction challenge](../challenge/interaction.md) · [Localisation](../challenge/localisation.md)
225
260
  - [Actions](../policy/actions.md) — where `challenge` sits
226
261
 
227
262
  Next: [Going live](12-going-live.md).
@@ -63,6 +63,23 @@ That combination is worth understanding. The dashboard people watch all day need
63
63
  one that names individuals, or the one that explains your detection to whoever is scraping
64
64
  you.
65
65
 
66
+ **It does not have to be its own page.** The same dashboard is also a custom element, so it
67
+ can live inside the admin tool your team already opens, rather than behind a second link
68
+ they have to remember:
69
+
70
+ ```html
71
+ <bot-dashboard src="/_bots"></bot-dashboard>
72
+ <script type="module">
73
+ import { defineBotDashboard } from "@osqd/bothandlerjs/element";
74
+ defineBotDashboard();
75
+ </script>
76
+ ```
77
+
78
+ `sections` and `redact` above still apply — they are enforced on the server, and embedding
79
+ changes nothing about that. What the element adds is which screens appear and how it looks.
80
+ [Embedding it](../operations/embedding.md) has the whole of it, including what putting the
81
+ dashboard inside your own page costs you.
82
+
66
83
  ## Metrics
67
84
 
68
85
  On by default; a handful of integer increments per request.
@@ -232,7 +249,8 @@ miss.
232
249
 
233
250
  ## Reference
234
251
 
235
- - [The dashboard](../operations/dashboard.md) · [Metrics](../operations/metrics.md)
236
- - [The audit](../operations/audit.md) · [Notifications](../operations/notifications.md)
252
+ - [The dashboard](../operations/dashboard.md) · [Embedding it](../operations/embedding.md)
253
+ - [Metrics](../operations/metrics.md) · [The audit](../operations/audit.md)
254
+ - [Notifications](../operations/notifications.md)
237
255
 
238
256
  Next: [Scaling and changing it live](14-scaling.md).
@@ -41,6 +41,18 @@ individual name does not.
41
41
  | `library` | curl, wget, python-requests, Go-http-client, okhttp | usually challenge |
42
42
  | `headless` | HeadlessChrome, Playwright, Puppeteer, Selenium | usually challenge |
43
43
  | `embedded` | Smart TVs, set-top boxes, game consoles | allow |
44
+ | `commerce` | idealo, Kelkoo, PriceRunner, Trivago, Skyscanner, Indeedbot | a commercial decision |
45
+ | `academic` | Crossref, OpenAlex, university web-science crawls | usually allow |
46
+ | `accessibility` | Siteimprove and other WCAG auditors | allow |
47
+
48
+ The last three are separate from their nearest neighbours because the decision is. A price
49
+ comparator is not an SEO auditor — nothing there is auditing your site for you, and the
50
+ same crawler is a distribution channel to one retailer and a competitor's research tool to
51
+ the next, which is why nothing in `commerce` is marked benign. A citation index is not a
52
+ model being trained, and an operator refusing the second is often glad of the first. And an
53
+ accessibility crawler is not an uptime probe: it is usually commissioned by the site's own
54
+ owner and then forgotten about, so blocking it does not reduce load, it makes an
55
+ accessibility report look clean by removing the evidence.
44
56
 
45
57
  ```ts
46
58
  { id: "no-ai", match: { category: "ai" }, action: "block", reason: "Not for model training." }
@@ -54,6 +66,7 @@ What, if anything, can check the claim:
54
66
  type Verification =
55
67
  | { kind: "fcrdns"; domains: readonly string[] } // reverse DNS, forward-confirmed
56
68
  | { kind: "ip-ranges"; publishedAt?: string } // an address list the operator publishes
69
+ | { kind: "proof"; via: string } // something only you can check
57
70
  | { kind: "none" }; // no published mechanism
58
71
  ```
59
72
 
@@ -62,6 +75,29 @@ them, and for those the claim is **unfalsifiable**. The library neither confirms
62
75
  accuses — it records what the client said and lets the policy decide what a self-declared
63
76
  identity is worth. See [verifying a crawler](verification.md).
64
77
 
78
+ **But you may be able to check what this library cannot.** Your CDN has often already
79
+ verified the crawler and says so in a header it adds; some bots now sign their requests;
80
+ you may hold ASN data. None of those belong inside a detection library — two need a
81
+ network dependency and the third needs a key it has no business fetching — so instead you
82
+ supply the answer:
83
+
84
+ ```ts
85
+ new BotHandler({
86
+ crawlerVerification: {
87
+ verifiers: {
88
+ // Whatever you can prove, however you prove it.
89
+ gptbot: (ctx) => (ctx.facts.headers["cf-verified-bot"] === "gptbot" ? "verified" : "unknown"),
90
+ },
91
+ },
92
+ });
93
+ ```
94
+
95
+ A verifier returns `"verified"`, `"refuted"` or `"unknown"`, runs before the built-in check
96
+ for that signature, and a definite answer settles it — which also means no DNS lookup.
97
+ `"unknown"` falls through to whatever the library can do on its own, and so does a throw:
98
+ an unreachable key server must never read as an accusation, or an outage becomes a wave of
99
+ blocked crawlers.
100
+
65
101
  ## What a name is worth
66
102
 
67
103
  A matched signature makes `self-identified` produce `certain` evidence, and this is the
package/docs/index.md CHANGED
@@ -59,6 +59,7 @@ Read these once and everything else follows from them. They are short.
59
59
  | | |
60
60
  | --- | --- |
61
61
  | [The challenge](challenge/index.md) | Proof of work, clearance, and exactly what it buys. |
62
+ | [The interaction challenge](challenge/interaction.md) | A gesture and a browser examination, and exactly what they are worth. |
62
63
  | [Languages](challenge/localisation.md) | Writing the interstitial in a language the visitor reads. |
63
64
 
64
65
  ## Running it
@@ -67,6 +68,7 @@ Read these once and everything else follows from them. They are short.
67
68
  | --- | --- |
68
69
  | [Operations overview](operations/index.md) | What to watch, and what to do when it moves. |
69
70
  | [The dashboard](operations/dashboard.md) | What it shows, what it refuses to do, and every option it takes. |
71
+ | [Embedding it](operations/embedding.md) | `<bot-dashboard>` in a page you already have: screens, themes, panels of your own. |
70
72
  | [Metrics](operations/metrics.md) | Counters, histograms and the Prometheus exposition. |
71
73
  | [The traffic audit](operations/audit.md) | Watching the shape of your traffic rather than any one request. |
72
74
  | [Notifications](operations/notifications.md) | Getting told, without being told a thousand times. |