@osqd/bothandlerjs 0.3.0 → 0.4.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 (69) 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 +653 -51
  7. package/dist/cli.cjs.map +1 -1
  8. package/dist/cli.js +653 -51
  9. package/dist/cli.js.map +1 -1
  10. package/dist/core.d.ts +15 -0
  11. package/dist/corpus/index.cjs +20 -0
  12. package/dist/corpus/index.cjs.map +1 -1
  13. package/dist/corpus/index.js +20 -0
  14. package/dist/corpus/index.js.map +1 -1
  15. package/dist/dashboard/client/actions.d.ts +17 -0
  16. package/dist/dashboard/client/actor.d.ts +5 -0
  17. package/dist/dashboard/client/api.d.ts +15 -0
  18. package/dist/dashboard/client/app.d.ts +37 -0
  19. package/dist/dashboard/client/bars.d.ts +4 -0
  20. package/dist/dashboard/client/boot.d.ts +5 -0
  21. package/dist/dashboard/client/charts.d.ts +25 -0
  22. package/dist/dashboard/client/css.d.ts +11 -0
  23. package/dist/dashboard/client/dom.d.ts +55 -0
  24. package/dist/dashboard/client/draft.d.ts +46 -0
  25. package/dist/dashboard/client/feed.d.ts +6 -0
  26. package/dist/dashboard/client/format.d.ts +19 -0
  27. package/dist/dashboard/client/guard.d.ts +3 -0
  28. package/dist/dashboard/client/index.d.ts +1 -0
  29. package/dist/dashboard/client/outcome.d.ts +33 -0
  30. package/dist/dashboard/client/panels.d.ts +40 -0
  31. package/dist/dashboard/client/policy.d.ts +25 -0
  32. package/dist/dashboard/client/query.d.ts +42 -0
  33. package/dist/dashboard/client/ranges.d.ts +2 -0
  34. package/dist/dashboard/client/registry.d.ts +4 -0
  35. package/dist/dashboard/client/replay.d.ts +14 -0
  36. package/dist/dashboard/client/result.d.ts +10 -0
  37. package/dist/dashboard/client/store.d.ts +68 -0
  38. package/dist/dashboard/client/stream.d.ts +14 -0
  39. package/dist/dashboard/client/tester.d.ts +1 -0
  40. package/dist/dashboard/client/types.d.ts +69 -0
  41. package/dist/dashboard/client.generated.d.ts +1 -1
  42. package/dist/dashboard/page.d.ts +25 -0
  43. package/dist/dashboard/sections.d.ts +92 -0
  44. package/dist/dashboard/types.d.ts +2 -82
  45. package/dist/element/config.d.ts +135 -0
  46. package/dist/element/index.cjs +4844 -0
  47. package/dist/element/index.cjs.map +1 -0
  48. package/dist/element/index.d.ts +137 -0
  49. package/dist/element/index.js +4825 -0
  50. package/dist/element/index.js.map +1 -0
  51. package/dist/index.cjs +644 -51
  52. package/dist/index.cjs.map +1 -1
  53. package/dist/index.d.ts +2 -0
  54. package/dist/index.js +635 -51
  55. package/dist/index.js.map +1 -1
  56. package/dist/metrics.d.ts +27 -0
  57. package/dist/policy/index.d.ts +1 -1
  58. package/dist/policy/presets.d.ts +47 -0
  59. package/docs/challenge/index.md +1 -0
  60. package/docs/challenge/interaction.md +325 -0
  61. package/docs/course/11-the-challenge.md +36 -1
  62. package/docs/course/13-operating-it.md +20 -2
  63. package/docs/index.md +2 -0
  64. package/docs/operations/dashboard.md +2 -0
  65. package/docs/operations/embedding.md +348 -0
  66. package/docs/operations/index.md +1 -0
  67. package/docs/policy/presets.md +53 -0
  68. package/docs/start/choosing-a-policy.md +1 -0
  69. package/package.json +8 -3
package/CHANGELOG.md CHANGED
@@ -6,7 +6,163 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
- Nothing yet.
9
+ ### Added
10
+
11
+ - **`indexers-only`, a preset for sites that want search traffic and nothing else.**
12
+ Serves a bot only when its identity has been confirmed by DNS or a published range
13
+ *and* it is a `search` or `social` crawler; refuses every other proven bot; challenges
14
+ suspicion and holds weak signal to a ceiling. It is the first shipped preset whose
15
+ terminal rules cover all proven automation permanently rather than during an incident.
16
+
17
+ Read its documentation before choosing it. Twenty-three of the shipped search and
18
+ social signatures — Twitterbot, LinkedInBot, Slackbot, Discord, Telegram, WhatsApp,
19
+ Reddit, Mastodon, Bluesky among them — publish nothing that can confirm a claim, so
20
+ they can never be verified and `unverifiable-indexer-block` refuses them, taking your
21
+ link previews with it. Fifteen of the corpus's infrastructure cases are refused too,
22
+ including health checks, a server-side renderer and a payment webhook: allowlist your
23
+ own automation above the preset before switching it on.
24
+
25
+ - **`<bot-dashboard>`, the dashboard as an element.** A new entry point,
26
+ `@osqd/bothandlerjs/element`, exporting `defineBotDashboard()`. Mount
27
+ `createDashboardHandler` as before and drop the element into a page you already
28
+ have: it renders the whole dashboard into a shadow root in your own layout, under
29
+ your own heading, rather than on a route of its own.
30
+
31
+ `config.tabs` chooses which of the four screens appear, in what order and under what
32
+ labels. `config.theme` sets the scheme, the density and any of the stylesheet's
33
+ tokens — which is why every token block is now written `:root, :host`, since `:root`
34
+ matches nothing inside a shadow tree. `config.panels` adds panels of your own, fed
35
+ by a URL or a function and rendered as text.
36
+
37
+ **Be clear about what embedding costs.** A shadow root is a styling boundary and not
38
+ a security boundary: any script that can run on the host page can read every client
39
+ address and every piece of evidence the dashboard renders, and call its API with your
40
+ credentials. On the standalone page it could not. Mount it behind your admin
41
+ authentication and treat an XSS on that page as equivalent to handing the dashboard
42
+ over — or serve the standalone page, which is the same dashboard and already
43
+ isolated. `config.hide` is cosmetic; `sections` on the handler is the setting that
44
+ stops data leaving the process.
45
+
46
+ Embedded, the dashboard stops doing four things it does when it owns a page: binding
47
+ its keyboard shortcuts to the window, where a digit pressed on the host page switched
48
+ a tab in here; writing its tab and filter into `location.hash`, which is the host's
49
+ address bar and its back button; relying on a fragment anchor for the skip link,
50
+ which is inert across a shadow boundary; and refusing to mount a second time, which
51
+ made it unusable under any router, since unmount-and-remount is what a route change
52
+ is. A remount now keeps the feed history it had built.
53
+
54
+ `GET {basePath}/api/bootstrap` is new, serving the element the same configuration the
55
+ standalone page carries stamped into it.
56
+
57
+ The entry point imports safely on a server — Next, Remix, Astro and the rest evaluate
58
+ top-level imports while rendering, where there is no DOM — and `defineBotDashboard()`
59
+ does nothing until it is in a browser, so it can be imported like anything else and
60
+ called on mount.
61
+
62
+ - **An interaction challenge.** `challenge.interaction` asks the interstitial for a
63
+ deliberate gesture as well as the proof of work, and probes what the browser can
64
+ actually do while it waits. Six probes read back things only a rendering engine
65
+ produces — a computed style that requires the cascade to have run, a laid-out box,
66
+ font metrics that differ between two families, a frame loop, a media query, an element
67
+ that `display: none` actually hid. One of them is different for every challenge: the
68
+ box count and box height of the layout probe are drawn from the nonce under the
69
+ signing secret, so the client cannot compute the answer and can only measure it.
70
+
71
+ When it is on, solving the puzzle alone no longer grants clearance: the gesture is
72
+ required, and passing grants the `interaction` clearance level, which the `clearance`
73
+ detector has always known how to read and which nothing until now ever granted.
74
+ `challengeTtlMs` defaults to ten minutes rather than two, because the page now stops
75
+ and waits for a person to read it.
76
+
77
+ The gesture is a checkbox, and that is the whole accessibility argument: it is the one
78
+ interactive control that a pointer, a touch screen, the space bar, a screen reader,
79
+ switch access and voice control can all operate. A tap and a keypress are reported as
80
+ what they are and never marked down for producing no pointer path, and a pointer path
81
+ too short to measure is treated as no evidence rather than bad evidence.
82
+
83
+ **Be clear about the ceiling, which is measured rather than assumed.** A client that
84
+ replays a report captured from a real browser gets nowhere, and one that hardcodes a
85
+ formula for the layout probe gets nowhere — but one that *parses the served HTML and
86
+ CSS each time* passes every check, because both numbers have to reach the browser to
87
+ be rendered. Against an adversary who writes a parser for your challenge page this
88
+ adds nothing over the plain proof of work beyond the server-verified elapsed-time
89
+ floor, and no client-side probe can. What it defeats is every scraper that does not
90
+ bother. Nothing here is proof of humanity and none of it is ever `certain`.
91
+
92
+ - **Counters for the challenge that were not there.** `bothandler_clearances_total` by
93
+ level, `bothandler_challenge_rejections_total` by cause, and
94
+ `bothandler_interaction_score_bucket` in tenths, fed by refusals as well as successes
95
+ so the distribution is not censored at the threshold it exists to inform. The
96
+ `challenge` event carries `level`, `score` and `reason` to match.
97
+
98
+ `MetricsSnapshot` gains three fields, so anything constructing one by hand needs them.
99
+
100
+ - **A test that parses the interstitial's own inline script.** The page is built inside
101
+ a template literal, where one backtick — in a comment, a string, a regular
102
+ expression — ends the literal early and turns the rest into markup.
103
+ `tests/challenge-page.test.ts` parses the rendered script with `node:vm`, including
104
+ with operator-supplied copy that contains a backtick.
105
+
106
+
107
+ ### Fixed
108
+
109
+ - **The "N not streamed" badge outlived the feed it described.** The badge adds two
110
+ counts: the server's rate-cap `skipped`, which `FeedRing.clear()` resets, and the
111
+ connection's lagged drops, which nothing did. After a Reset — or after the replace-sync
112
+ that follows a dropped stream, which is the likelier path, since a viewer dropped for
113
+ lagging reconnects with a stale cursor and is sent a fresh backlog — the number went on
114
+ reporting a gap in a window that had just been replaced, under a tooltip promising those
115
+ entries were still in it.
116
+
117
+ ## [0.3.0] — 2026-09-06
118
+
119
+ ### Changed
120
+
121
+ - **The package is now `@osqd/bothandlerjs`.** Every import moves with it —
122
+ `@osqd/bothandlerjs`, `/adapters`, `/client`, `/corpus`, `/cli` — and
123
+ `npm install bothandlerjs` becomes `npm install @osqd/bothandlerjs`. The CLI command
124
+ is unchanged: a `bin` name is independent of the package name, so `bothandlerjs check`
125
+ still works once installed, and `npx @osqd/bothandlerjs` installs it.
126
+
127
+ npm refuses capital letters in a new package name, so the spelling is
128
+ `@osqd/bothandlerjs` rather than `@osqd/BotHandlerJS`. `publishConfig.access` is set
129
+ to public, without which a scoped package publishes private on the first attempt, and
130
+ the tarball is now `osqd-bothandlerjs-<version>.tgz`.
131
+
132
+ ### Added
133
+
134
+ - **Releases are cut from `main`.** `scripts/next-version.mjs` reads the commits since
135
+ the last release tag and works out the version — `feat:` minor, `fix:`/`perf:` patch,
136
+ a `!` or a `BREAKING CHANGE:` footer major (or minor while the major is 0), and
137
+ everything else nothing at all. That last rule is what makes publishing on every push
138
+ tolerable: a documentation fix releases nothing.
139
+ `.github/workflows/publish.yml` runs the full gate against the commit, refuses a
140
+ version already on the registry, bumps and tags, inspects the tarball, and publishes
141
+ with provenance.
142
+
143
+ - **A sixteen-lesson course**, in `docs/course/`, that builds one integration from a
144
+ first assessment to a policy you can defend. Every checkpoint in it is real output
145
+ from running the code.
146
+ - **A test for the published entry points.** `tests/entry-points.test.ts` asserts the
147
+ surface of all four, which is the actual fix for the two entries below: every module
148
+ in the repository imports its neighbours by path, so nothing exercised the paths the
149
+ documentation tells other people to use.
150
+
151
+ ### Fixed
152
+
153
+ - **`@osqd/bothandlerjs/corpus` did not export `runCorpus`.** That is the entire point
154
+ of publishing the entry point, and the README, the changelog, the design notes and
155
+ three documentation pages all told people to import it. It would have failed for
156
+ everyone outside this repository, and no test could have noticed because every
157
+ internal caller reaches past the entry point to `./runner.js`.
158
+
159
+ - **`renderClientScript` and `parseClientSignals` were documented on the root export.**
160
+ They live behind `/client`, and the example omitted the required `endpoint` argument.
161
+
162
+ - **A challenge test failed about one run in 271, claiming a bad proof of work had been
163
+ accepted.** It submitted the fixed solution `"1"` against a random nonce at difficulty
164
+ 8, where one guess in 256 is a valid proof by accident. It searches for a counter that
165
+ provably misses now, and asserts that it misses before submitting it.
10
166
 
11
167
  ## [0.2.0]
12
168
 
package/README.md CHANGED
@@ -41,7 +41,7 @@ explaining why a thing exists as well as how to use it.
41
41
  | **Concepts** | [Evidence and certainty](docs/concepts/evidence.md) · [Verdicts and scores](docs/concepts/verdicts.md) · [The safety guard](docs/concepts/the-guard.md) · [Actors](docs/concepts/actors.md) · [Threat model](docs/concepts/threat-model.md) |
42
42
  | **[Detection](docs/detection/index.md)** | [The 20 detectors](docs/detection/detectors.md) · [Signatures](docs/detection/signatures.md) · [Verification](docs/detection/verification.md) · [Browser signals](docs/detection/client-signals.md) · [Writing a detector](docs/detection/writing-a-detector.md) |
43
43
  | **[Policy](docs/policy/index.md)** | [Rules](docs/policy/rules.md) · [Actions](docs/policy/actions.md) · [Presets](docs/policy/presets.md) · [robots.txt](docs/policy/robots.md) · [The challenge](docs/challenge/index.md) |
44
- | **[Operations](docs/operations/index.md)** | [The dashboard](docs/operations/dashboard.md) · [Metrics](docs/operations/metrics.md) · [The audit](docs/operations/audit.md) · [Notifications](docs/operations/notifications.md) · [Runtime changes](docs/operations/runtime-changes.md) |
44
+ | **[Operations](docs/operations/index.md)** | [The dashboard](docs/operations/dashboard.md) · [Embedding it](docs/operations/embedding.md) · [Metrics](docs/operations/metrics.md) · [The audit](docs/operations/audit.md) · [Notifications](docs/operations/notifications.md) · [Runtime changes](docs/operations/runtime-changes.md) |
45
45
  | **[Integration](docs/integration/index.md)** | [Adapters](docs/integration/adapters.md) · [The client IP](docs/integration/client-ip.md) · [Stores](docs/integration/stores.md) |
46
46
  | **[Testing](docs/testing/index.md)** | [The CLI](docs/testing/cli.md) · [The corpus](docs/testing/corpus.md) · [Log replay](docs/testing/replay.md) · [Try it locally](docs/testing/try-it.md) |
47
47
  | **Reference** | [Configuration](docs/reference/configuration.md) · [API](docs/reference/api.md) · [Design decisions](docs/design/decisions.md) |
@@ -1,4 +1,5 @@
1
1
  import type { ChallengeCopy } from "./language.js";
2
+ import type { InteractionSettings } from "./interaction.js";
2
3
  import type { Clock } from "../internal/clock.js";
3
4
  import type { BotHandlerStore } from "../stores/types.js";
4
5
  import type { ClearanceClaims, ClearanceLevel } from "./token.js";
@@ -73,6 +74,16 @@ export interface ChallengeOptions {
73
74
  * write rather than by a heuristic here.
74
75
  */
75
76
  translations?: Record<string, ChallengeCopy>;
77
+ /**
78
+ * Ask for a deliberate gesture as well as the proof of work, and measure what the
79
+ * browser can actually do while waiting for it.
80
+ *
81
+ * `true` uses {@link DEFAULT_INTERACTION_SETTINGS}. Solving the puzzle alone no longer
82
+ * grants clearance when this is on: the gesture is required. Read
83
+ * `src/challenge/interaction.ts` before turning it on — in particular the part about
84
+ * what is and is not verifiable — because it changes who can get through your site.
85
+ */
86
+ interaction?: boolean | Partial<InteractionSettings>;
76
87
  store?: BotHandlerStore;
77
88
  clock?: Clock;
78
89
  }
@@ -85,10 +96,13 @@ export type SolutionOutcome = {
85
96
  ok: true;
86
97
  setCookie: string;
87
98
  level: ClearanceLevel;
99
+ interactionScore?: number;
100
+ notes?: readonly string[];
88
101
  } | {
89
102
  ok: false;
90
103
  status: number;
91
104
  reason: string;
105
+ interactionScore?: number | undefined;
92
106
  };
93
107
  /**
94
108
  * Issues challenges, verifies solutions and grants clearance.
@@ -106,6 +120,8 @@ export declare class ChallengeService {
106
120
  private readonly challengeTtlMs;
107
121
  private readonly clock;
108
122
  private readonly store;
123
+ /** Resolved interaction settings, or `undefined` when the gesture is not asked for. */
124
+ private readonly interaction;
109
125
  readonly verifyPath: string;
110
126
  readonly cookieName: string;
111
127
  /**
@@ -115,6 +131,8 @@ export declare class ChallengeService {
115
131
  */
116
132
  readonly clearanceTtlMs: number;
117
133
  constructor(options: ChallengeOptions);
134
+ /** Whether this service asks for a gesture as well as the puzzle. */
135
+ get wantsInteraction(): boolean;
118
136
  /**
119
137
  * Derives the subject a token is bound to.
120
138
  *
@@ -0,0 +1,215 @@
1
+ /**
2
+ * The interaction challenge: what a client did on the interstitial, and what that is
3
+ * worth.
4
+ *
5
+ * The proof of work in `pow.ts` demonstrates that a JavaScript engine ran. That is a
6
+ * real cost and it is the whole of what the plain challenge asks for. This asks for
7
+ * something else: evidence that a *browser* rendered the page and that a *person*
8
+ * acted on it.
9
+ *
10
+ * ## What is actually verifiable, stated plainly
11
+ *
12
+ * Everything on this page is reported by the client, and the client is the one place an
13
+ * adversary has complete control. A determined attacker driving a real browser through
14
+ * CDP dispatches genuine input events, produces real layout, and can synthesise pointer
15
+ * paths from a minimum-jerk model — the literature on human motor movement is public and
16
+ * so are the libraries. **Nothing here is proof of humanity and none of it may ever be
17
+ * `certain`.**
18
+ *
19
+ * What it does is move the cost. A scraper that was `fetch()` in a loop must now run a
20
+ * browser engine, render CSS, and synthesise a plausible gesture per request. That is
21
+ * three or four orders of magnitude more expensive per page, and for most bulk
22
+ * extraction it is the difference between worth doing and not.
23
+ *
24
+ * Exactly one signal here is **server-verified** and cannot be fabricated: the elapsed
25
+ * wall-clock time between issuing the challenge and receiving the answer, which comes
26
+ * from the signed token's `iat` rather than from anything the client says. See
27
+ * {@link verifyInteraction}.
28
+ *
29
+ * ## Why the analysis lives here and not in the page
30
+ *
31
+ * If the page scored itself and posted the number, an attacker would post the number.
32
+ * The client sends a bounded, quantised record of what happened; every judgement about
33
+ * it is made on this side.
34
+ */
35
+ /**
36
+ * The shape of one challenge's layout probe: how many boxes, and how tall each one is.
37
+ *
38
+ * Both are drawn from the nonce **under the signing secret**, which is the whole point.
39
+ * An earlier version fixed the height at 7px in the stylesheet and derived the count from
40
+ * the nonce in the page's own script, so the expected answer was a formula an attacker
41
+ * could read once and hardcode for ever — a client that never rendered anything answered
42
+ * it correctly five times out of five. Deriving it under the secret means the client
43
+ * cannot compute the answer at all. It can only measure it.
44
+ *
45
+ * That does not make it unforgeable. Both numbers still have to reach the browser to be
46
+ * rendered — the count as elements in the markup, the height as a literal in the
47
+ * stylesheet — so anything willing to parse the page it was served can still find them.
48
+ * What it removes is the *formula*: there is no longer a fixed rule to implement once and
49
+ * reuse. See the honesty section in docs/challenge/interaction.md.
50
+ */
51
+ export interface ProbeShape {
52
+ boxes: number;
53
+ height: number;
54
+ }
55
+ /** Derives {@link ProbeShape} for a nonce. The secret keeps it unpredictable to the client. */
56
+ export declare function probeShapeFor(nonce: string, secret: string): ProbeShape;
57
+ /** One pointer sample: movement since the previous sample, and the gap in milliseconds. */
58
+ export interface PointerSample {
59
+ dx: number;
60
+ dy: number;
61
+ dt: number;
62
+ }
63
+ /**
64
+ * What the interstitial observed. Every field is client-supplied and therefore a claim
65
+ * rather than a fact; see the note at the top of this file.
66
+ */
67
+ export interface InteractionReport {
68
+ /**
69
+ * `event.isTrusted` on the activation.
70
+ *
71
+ * False for anything `element.click()` or a synthesised `MouseEvent` produces, which
72
+ * is what a naive automation script reaches for first. A browser driven through the
73
+ * DevTools protocol produces `true`, so this is a floor rather than a test.
74
+ */
75
+ trusted: boolean;
76
+ /**
77
+ * How the control was activated.
78
+ *
79
+ * `pointer` means a mouse, and is the only value from which a path is expected. `touch`
80
+ * covers a tap or a stylus, which legitimately produce almost no movement; `keyboard`
81
+ * covers the space bar, a screen reader, switch access and voice control; `other` is
82
+ * anything the page could not classify.
83
+ */
84
+ via: "pointer" | "touch" | "keyboard" | "other";
85
+ /** Milliseconds from page load to activation, as the *client* measured it. */
86
+ msToActivate: number;
87
+ /** Bounded, quantised pointer path leading up to the activation. */
88
+ path: readonly PointerSample[];
89
+ /** Results of the capability probes. See {@link CAPABILITY_WEIGHTS}. */
90
+ capabilities: Readonly<Record<string, boolean>>;
91
+ /**
92
+ * Measured height of the nonce-bound layout probe, in CSS pixels, or `undefined` when
93
+ * the client reported none. See {@link probeShapeFor}.
94
+ */
95
+ layoutHeight?: number | undefined;
96
+ }
97
+ /** What the movement looked like, once measured rather than asserted. */
98
+ export interface MovementAnalysis {
99
+ samples: number;
100
+ /**
101
+ * Coefficient of variation of the distance covered per sample.
102
+ *
103
+ * The primary spatial measure, and the one that cannot be manufactured by accident:
104
+ * it reads only where the pointer went, never how fast the events arrived.
105
+ */
106
+ distanceVariation: number;
107
+ /** Coefficient of variation of speed. Human movement is ballistic; a lerp is flat. */
108
+ speedVariation: number;
109
+ /** Coefficient of variation of the gaps between samples. */
110
+ timingVariation: number;
111
+ /** Times the speed changed direction — sped up after slowing, or the reverse. */
112
+ accelerationChanges: number;
113
+ /** Net displacement over path length. 1 is a perfectly straight line. */
114
+ straightness: number;
115
+ /** Share of samples carrying a fractional component. */
116
+ fractionalShare: number;
117
+ /** Sum of absolute turn angles, in radians, over the whole path. */
118
+ totalTurning: number;
119
+ }
120
+ export type InteractionOutcome = {
121
+ ok: true;
122
+ level: "interaction" | "pow";
123
+ score: number;
124
+ notes: readonly string[];
125
+ }
126
+ /**
127
+ * `score` and `notes` are present whenever the refusal came from the grading rather
128
+ * than from a gate reached before it.
129
+ *
130
+ * Carrying them matters more than it looks. The score histogram exists so that an
131
+ * operator can see where to put `interactionAt`, and feeding it only from successes
132
+ * gives them a distribution with everything below the threshold cut out of it — which
133
+ * is precisely the part of the shape the decision depends on.
134
+ */
135
+ | {
136
+ ok: false;
137
+ reason: string;
138
+ score?: number | undefined;
139
+ notes?: readonly string[] | undefined;
140
+ };
141
+ export interface InteractionSettings {
142
+ /**
143
+ * Least time that may pass between issuing a challenge and accepting its answer, ms.
144
+ *
145
+ * Checked against the signed token rather than against anything the client reports,
146
+ * which makes it the one part of this file an attacker cannot lie about. It is also
147
+ * the part that costs them most: a floor of one second is a hard ceiling on how fast
148
+ * a farm can work through challenges, however many browsers it runs.
149
+ */
150
+ minElapsedMs: number;
151
+ /** Score at or above which the clearance is `interaction` rather than `pow`. */
152
+ interactionAt: number;
153
+ /** Score below which the answer is refused outright. */
154
+ refuseBelow: number;
155
+ }
156
+ export declare const DEFAULT_INTERACTION_SETTINGS: InteractionSettings;
157
+ /**
158
+ * What each capability probe is worth.
159
+ *
160
+ * These are ordered by how hard the capability is to fake *without actually having it*.
161
+ * `cssApplied` is the one that matters most: it asks the page to read back a computed
162
+ * style that only exists if a CSSOM parsed the stylesheet and applied the cascade. An
163
+ * HTTP client that parses HTML has no answer to it, and that is precisely the population
164
+ * a challenge is aimed at.
165
+ *
166
+ * None of them is worth much on its own, and a real browser driven by a script passes
167
+ * every one — which is why they contribute to a score rather than to a verdict.
168
+ */
169
+ export declare const CAPABILITY_WEIGHTS: Readonly<Record<string, number>>;
170
+ /**
171
+ * Measures a pointer path.
172
+ *
173
+ * The statistics are chosen for what they cost to fake convincingly rather than for how
174
+ * well they describe a human. Anyone can add noise to a straight line; producing a path
175
+ * whose *speed profile* is ballistic, whose turning is concentrated near the target, and
176
+ * whose sample timing jitters the way a real event stream does takes deliberate work.
177
+ */
178
+ export declare function analyseMovement(path: readonly PointerSample[]): MovementAnalysis;
179
+ /**
180
+ * How human-shaped a measured path is, 0 to 1.
181
+ *
182
+ * Each term is a property that costs an attacker something different to reproduce, and
183
+ * the sum is deliberately forgiving: a person making one short, quick, straight-ish
184
+ * movement to a checkbox should not be punished for it. What this separates reliably is
185
+ * *linear interpolation* — the constant-velocity, uniform-timing, integer-coordinate
186
+ * path that every naive automation library produces — from anything organic.
187
+ */
188
+ export declare function scoreMovement(analysis: MovementAnalysis): number;
189
+ /** How much of the browser is really there, 0 to 1. */
190
+ export declare function scoreCapabilities(capabilities: Readonly<Record<string, boolean>>): number;
191
+ /**
192
+ * Reads an interaction report off the wire.
193
+ *
194
+ * Every field is attacker-supplied, so this is written to be total: anything malformed
195
+ * becomes `undefined` rather than throwing, and every array and number is bounded before
196
+ * it reaches the analysis. A report that arrives as a two-megabyte array of NaN is a
197
+ * request to burn CPU, not an interaction.
198
+ */
199
+ export declare function parseInteractionReport(value: unknown): InteractionReport | undefined;
200
+ /**
201
+ * Decides what an interaction report is worth.
202
+ *
203
+ * `elapsedMs` is measured by the server from the signed challenge token, and it is the
204
+ * only argument here that the client cannot influence.
205
+ *
206
+ * ## Why a keyboard activation is not penalised
207
+ *
208
+ * A pointer path is worth something, and its absence is worth nothing either way. Voice
209
+ * control produces no pointer movement; switch access produces machine-regular timing;
210
+ * a screen reader activates the control from the keyboard. Scoring those down would put
211
+ * assistive technology on the wrong side of a check the rest of this library exists to
212
+ * keep people out of, so a keyboard or `other` activation is graded on its capabilities
213
+ * and its timing alone.
214
+ */
215
+ export declare function verifyInteraction(report: InteractionReport | undefined, elapsedMs: number, settings?: InteractionSettings, expected?: ProbeShape): InteractionOutcome;
@@ -13,6 +13,24 @@ export interface ChallengePageOptions {
13
13
  contactHtml?: string;
14
14
  /** Page language for the `lang` attribute. Default "en". */
15
15
  lang?: string;
16
+ /**
17
+ * Ask for a deliberate gesture as well as the proof of work, and probe what the
18
+ * browser can actually do while waiting for it.
19
+ *
20
+ * The control is a plain checkbox for a reason. It is the one interactive element that
21
+ * every way of using a computer can operate — pointer, touch, the space bar, a screen
22
+ * reader, switch access, voice control — so requiring it excludes far less than a
23
+ * drag, a puzzle or anything needing visual acuity would.
24
+ */
25
+ interaction?: boolean;
26
+ /**
27
+ * Shape of this challenge's layout probe. Rendered into the markup and the stylesheet
28
+ * so that answering it requires laying it out; see `probeShapeFor`.
29
+ */
30
+ probe?: {
31
+ boxes: number;
32
+ height: number;
33
+ };
16
34
  }
17
35
  export interface RenderedChallenge {
18
36
  html: string;