@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,48 @@
1
+ import type { BotEvent } from "./types.js";
2
+ export interface RedactionOptions {
3
+ /**
4
+ * Replace client addresses with a coarse network (`/24`, `/64`) before the event
5
+ * leaves the process. Default true.
6
+ *
7
+ * On by default because a notification sink is usually a third party — Slack, a
8
+ * webhook, an aggregator — and an IP address is personal data in most of the world.
9
+ * The network is enough to recognise a pattern and to correlate repeat offenders,
10
+ * which is what an alert is for; the exact address is only needed when you are
11
+ * ready to act on one, and that belongs in your own logs.
12
+ */
13
+ maskIp?: boolean;
14
+ /** Drop the full User-Agent, keeping only the parsed shape. Default false. */
15
+ dropUserAgent?: boolean;
16
+ /** Headers never included in an outbound event, whatever else is configured. */
17
+ neverSend?: readonly string[];
18
+ /**
19
+ * Replace query-string *values* with a placeholder, keeping the parameter names.
20
+ * Default true.
21
+ *
22
+ * A query string is where password-reset tokens, invitation links, email addresses
23
+ * and session ids actually live, and it was the one client-supplied structure this
24
+ * module passed through untouched while stripping cookies and credential headers
25
+ * beside it. The names are what make an alert legible — a burst of `?export=` is the
26
+ * pattern worth seeing — and the values are what you do not want in a third party's
27
+ * message history.
28
+ */
29
+ maskQuery?: boolean;
30
+ }
31
+ /** Always stripped. Each of these carries a credential or a session. */
32
+ /**
33
+ * Always stripped, wherever a request is shown to somebody other than the engine.
34
+ *
35
+ * Exported because the dashboard's header inspector needs the same list: a panel that
36
+ * prints the request as it arrived would otherwise print session cookies and bearer
37
+ * tokens onto an operator's screen, and into whatever screenshot they paste into a
38
+ * ticket.
39
+ */
40
+ export declare const CREDENTIAL_HEADERS: readonly string[];
41
+ /**
42
+ * Produces the version of an event that is safe to send somewhere else.
43
+ *
44
+ * This runs on the way *out*, not on the way in: detection sees everything, and only
45
+ * the copy handed to a sink is reduced. Getting that order wrong would trade
46
+ * detection quality for a privacy property you can have for free.
47
+ */
48
+ export declare function redactEvent(event: BotEvent, options?: RedactionOptions): BotEvent;
@@ -0,0 +1,62 @@
1
+ import type { Notifier } from "./types.js";
2
+ export interface ConsoleNotifierOptions {
3
+ /** `"pretty"` for humans, `"json"` for a log pipeline. Default `"pretty"`. */
4
+ format?: "pretty" | "json";
5
+ /** Where to write. Default `console`. */
6
+ target?: Pick<Console, "log" | "warn" | "error">;
7
+ }
8
+ /** Writes events to the console. The default sink, and the right one to start with. */
9
+ export declare function consoleNotifier(options?: ConsoleNotifierOptions): Notifier;
10
+ export interface WebhookNotifierOptions {
11
+ url: string;
12
+ /**
13
+ * Shared secret. When set, each request carries `X-BotHandler-Signature` as
14
+ * `sha256=<hex-free base64url HMAC>` over the exact body, plus a timestamp header.
15
+ *
16
+ * Sign your webhooks. An unsigned endpoint accepting bot alerts is an endpoint
17
+ * anyone on the internet can fill with fabricated ones.
18
+ */
19
+ secret?: string;
20
+ /** Extra headers, e.g. an API token. */
21
+ headers?: Record<string, string>;
22
+ /** Per-attempt timeout, ms. Default 5000. */
23
+ timeoutMs?: number;
24
+ /** Retries after the first attempt. Default 2, with exponential backoff. */
25
+ retries?: number;
26
+ /** `fetch` implementation. Defaults to the global. */
27
+ fetch?: typeof globalThis.fetch;
28
+ }
29
+ /**
30
+ * POSTs events as JSON.
31
+ *
32
+ * Delivery is best-effort by design. This runs outside the request path, so a slow or
33
+ * unreachable endpoint costs a visitor nothing — but it also means an alert can be
34
+ * lost, and it is not a substitute for logging events where you can query them.
35
+ */
36
+ export declare function webhookNotifier(options: WebhookNotifierOptions): Notifier;
37
+ export interface SlackNotifierOptions {
38
+ /** Incoming-webhook URL. */
39
+ url: string;
40
+ timeoutMs?: number;
41
+ fetch?: typeof globalThis.fetch;
42
+ }
43
+ /** Posts a compact, readable message to a Slack incoming webhook. */
44
+ export declare function slackNotifier(options: SlackNotifierOptions): Notifier;
45
+ export interface NotifyJsOptions {
46
+ /** Base URL of your NotifyJS hub. */
47
+ endpoint: string;
48
+ /** Hub API token. */
49
+ token: string;
50
+ /** Channel or topic to publish under. Default "bothandler". */
51
+ topic?: string;
52
+ timeoutMs?: number;
53
+ fetch?: typeof globalThis.fetch;
54
+ }
55
+ /**
56
+ * Publishes to a NotifyJS hub, so bot alerts arrive on the same devices as the rest
57
+ * of your operational notifications.
58
+ *
59
+ * Written against the hub's HTTP surface rather than importing the client, so this
60
+ * adds no dependency and works whichever version of the hub you run.
61
+ */
62
+ export declare function notifyJsNotifier(options: NotifyJsOptions): Notifier;
@@ -0,0 +1,56 @@
1
+ import type { Assessment } from "../types.js";
2
+ import type { Decision } from "../policy/types.js";
3
+ import type { TrafficAnomaly } from "../audit.js";
4
+ /** Why a notification was raised. */
5
+ export type BotEventType =
6
+ /** An assessment concluded something other than "unknown". */
7
+ "detection"
8
+ /** An action was taken that withheld or altered the response. */
9
+ | "action"
10
+ /** The safety guard replaced a terminal action with something recoverable. */
11
+ | "downgrade"
12
+ /** A detector, sink or store failed. Operational, not about traffic. */
13
+ | "error"
14
+ /**
15
+ * The audit noticed the *shape* of your traffic change — a spike in automation, a
16
+ * collapse in human traffic, a policy suddenly denying far more than usual.
17
+ *
18
+ * The one event type that is not about a single request, which is why
19
+ * {@link BotEvent.assessment} is optional.
20
+ */
21
+ | "anomaly";
22
+ export interface BotEvent {
23
+ type: BotEventType;
24
+ /** ISO-8601 timestamp. */
25
+ at: string;
26
+ /** The request this is about. Absent on `anomaly`, which is about a stretch of time. */
27
+ assessment?: Assessment | undefined;
28
+ decision?: Decision | undefined;
29
+ /** Present on `error` events. */
30
+ error?: {
31
+ source: string;
32
+ message: string;
33
+ } | undefined;
34
+ /** Present on `anomaly` events. */
35
+ anomaly?: TrafficAnomaly | undefined;
36
+ }
37
+ export interface Notifier {
38
+ id: string;
39
+ /**
40
+ * Delivers an event.
41
+ *
42
+ * Called off the request path — the engine never awaits a sink — so taking time
43
+ * here is fine. Throwing is also fine: failures are caught, reported once through
44
+ * `onError`, and never surfaced to the client.
45
+ */
46
+ notify(event: BotEvent): void | Promise<void>;
47
+ }
48
+ /** Controls what a sink is told about. */
49
+ export interface NotifyFilter {
50
+ /** Event types to deliver. Default: everything except `detection`. */
51
+ types?: readonly BotEventType[];
52
+ /** Minimum score for a `detection` event. Default 60. */
53
+ minScore?: number;
54
+ /** Only deliver proven detections. Default false. */
55
+ certainOnly?: boolean;
56
+ }
@@ -0,0 +1,7 @@
1
+ export { Policy } from "./policy.js";
2
+ export type { GuardSettings } from "./policy.js";
3
+ export { compileMatch, independentStrongSignals } from "./match.js";
4
+ export { monitorOnly, allowCrawlers, protectContent, declineAiTraining, protectData, protectApi, protectAuth, underAttack, PRESETS } from "./presets.js";
5
+ export type { PresetName } from "./presets.js";
6
+ export { ACTION_NAMES, TERMINAL_ACTIONS } from "./types.js";
7
+ export type { ActionName, ActionParams, Decision, FalsePositivePolicy, MatchSpec, PolicyOptions, Rule } from "./types.js";
@@ -0,0 +1,20 @@
1
+ import type { Assessment } from "../types.js";
2
+ import type { MatchSpec } from "./types.js";
3
+ /**
4
+ * Compiles a {@link MatchSpec} into a predicate.
5
+ *
6
+ * Compiling once at policy-construction time rather than interpreting the spec per
7
+ * request keeps rule evaluation to a handful of set lookups, which matters when the
8
+ * policy is consulted on every request to the site.
9
+ */
10
+ export declare function compileMatch(spec: MatchSpec): (assessment: Assessment) => boolean;
11
+ /**
12
+ * Counts independent `strong`-or-better bot signals.
13
+ *
14
+ * "Independent" is approximated as "from different detectors", which is imperfect —
15
+ * `header-integrity` and `accept-signature` both read the same header block and are
16
+ * correlated — but it is a real bar that a single misfiring check cannot clear on its
17
+ * own. Used by the `balanced` guard, where it is the difference between "the score
18
+ * got high" and "several different things went wrong".
19
+ */
20
+ export declare function independentStrongSignals(assessment: Assessment): number;
@@ -0,0 +1,123 @@
1
+ import type { Assessment } from "../types.js";
2
+ import type { ActionName, Decision, FalsePositivePolicy, PolicyOptions, Rule } from "./types.js";
3
+ /** The settings that decide how far a rule may go. See {@link Policy.replaceGuard}. */
4
+ export interface GuardSettings {
5
+ falsePositivePolicy: FalsePositivePolicy;
6
+ fallbackAction: ActionName;
7
+ defaultAction: ActionName;
8
+ terminalScoreThreshold: number;
9
+ }
10
+ /**
11
+ * Chooses an action for an assessment, then refuses to let that action be harsher
12
+ * than the evidence supports.
13
+ *
14
+ * The second half is the point. Rules are ordinary first-match-wins configuration and
15
+ * there is nothing clever about them. What makes the policy trustworthy is the guard
16
+ * that runs *after* a rule has been selected: under the default `strict` mode a
17
+ * terminal action survives only if `assessment.certain` is true, and otherwise is
18
+ * replaced by something recoverable, with the substitution recorded on the decision.
19
+ *
20
+ * That ordering is deliberate. The guard cannot be forgotten in a rule, cannot be
21
+ * bypassed by a cleverly-worded predicate, and does not depend on whoever wrote the
22
+ * rules understanding the certainty model. Turning it off is a single, explicit,
23
+ * greppable configuration change.
24
+ */
25
+ export declare class Policy {
26
+ /**
27
+ * Compiled rules, in evaluation order.
28
+ *
29
+ * Mutable — and only ever replaced wholesale, never spliced. {@link replaceRules}
30
+ * swaps the array in one assignment, so a request evaluated during an update sees
31
+ * either the old list or the new one and never a half-applied policy.
32
+ */
33
+ private compiled;
34
+ /**
35
+ * The guard settings.
36
+ *
37
+ * Not `readonly` any more, and the reason is narrow enough to write down.
38
+ * {@link replaceGuard} exists so that an operator who has been *given* the power to
39
+ * change these can change them without a deploy — the dashboard gates it behind a
40
+ * control flag of its own that is off by default. Everything about how they are
41
+ * *used* is unchanged: `decide()` reads them on every request, so a change takes
42
+ * effect on the next one, and it can only ever be a whole valid set replacing
43
+ * another whole valid set.
44
+ */
45
+ private defaultAction;
46
+ private defaultParams;
47
+ private mode;
48
+ private fallbackAction;
49
+ private terminalScoreThreshold;
50
+ private readonly onDowngrade;
51
+ constructor(options?: PolicyOptions);
52
+ private static compile;
53
+ /** Ids of every configured rule, in evaluation order. For diagnostics and tests. */
54
+ get ruleIds(): string[];
55
+ /**
56
+ * The rule definitions themselves, in evaluation order.
57
+ *
58
+ * Exposed because two things outside the policy need to *read* rules rather than
59
+ * apply them: `robotsFromRules`, which turns the ones that deny crawlers into a
60
+ * `robots.txt`, and the dashboard, which shows an operator what is actually
61
+ * installed. Both want the spec as written, not the compiled predicate.
62
+ */
63
+ get rules(): readonly Rule[];
64
+ /**
65
+ * Replaces the rule list while the process runs.
66
+ *
67
+ * Deliberately narrow: this changes *which* rules exist, and nothing about how far
68
+ * they are allowed to go. The guard settings are a separate, separately-gated
69
+ * operation — see {@link replaceGuard} — so that "edit the rules" and "change what a
70
+ * rule is allowed to do" are never the same permission.
71
+ */
72
+ replaceRules(rules: readonly Rule[]): void;
73
+ /**
74
+ * Replaces the guard settings while the process runs.
75
+ *
76
+ * This is the one edit that can start denying people, so it is separated from
77
+ * {@link replaceRules} at every level: a different method here, a different control
78
+ * flag on the dashboard, a different endpoint, and a different event when it lands.
79
+ * A caller that may change rules does not thereby become a caller that may change
80
+ * this.
81
+ *
82
+ * Two things are refused outright rather than warned about, because both turn the
83
+ * guard into scenery while leaving it apparently switched on:
84
+ *
85
+ * - **A terminal `fallbackAction`.** The fallback is what a downgrade *becomes*.
86
+ * Set it to `block` and every downgrade blocks, which is the exact outcome the
87
+ * downgrade exists to prevent — and the decision would still be recorded as a
88
+ * guard stop, so the metric that is supposed to catch this would report success.
89
+ * - **A `terminalScoreThreshold` outside 1–100.** Zero would let balanced mode
90
+ * deny on any score at all.
91
+ *
92
+ * Fields left undefined keep their current value, so a caller can move one setting
93
+ * without restating the rest.
94
+ */
95
+ replaceGuard(settings: Partial<GuardSettings>): void;
96
+ /** Just the guard half of {@link describe}, in the shape {@link replaceGuard} accepts back. */
97
+ describeGuard(): GuardSettings;
98
+ /**
99
+ * The settings that decide how far a rule may go, for anything that reports on the
100
+ * policy rather than applying it — a dashboard, a diagnostic, a startup log.
101
+ *
102
+ * Worth surfacing rather than keeping private: `falsePositivePolicy` is the single
103
+ * setting that decides whether an unproven verdict can deny anybody, and a reader
104
+ * looking at a screen full of verdicts cannot interpret one without knowing it.
105
+ */
106
+ describe(): {
107
+ falsePositivePolicy: FalsePositivePolicy;
108
+ fallbackAction: ActionName;
109
+ defaultAction: ActionName;
110
+ terminalScoreThreshold: number;
111
+ rules: string[];
112
+ };
113
+ decide(assessment: Assessment): Decision;
114
+ /**
115
+ * The safety guard.
116
+ *
117
+ * Note what it does *not* consider: how confident the score is, how many detectors
118
+ * fired, or how badly the operator wants to block. Under `strict` there is exactly
119
+ * one question — is there proof? — and everything else is a downgrade.
120
+ */
121
+ private guard;
122
+ private downgrade;
123
+ }
@@ -0,0 +1,161 @@
1
+ import type { Rule } from "./types.js";
2
+ /**
3
+ * Ready-made rule sets.
4
+ *
5
+ * Each is a starting point to read, adapt and own — not a black box. Every rule
6
+ * carries an id, so you can see exactly which one fired in a decision and replace
7
+ * just that one.
8
+ *
9
+ * All of them assume the default `strict` guard, which means even the harshest rule
10
+ * here cannot deny an unproven request. Choose a preset for the *shape* of the policy;
11
+ * the guard decides how far it is allowed to go.
12
+ */
13
+ /**
14
+ * Watch and learn. Nothing is ever withheld from anybody.
15
+ *
16
+ * Run this first, for at least a week, on real traffic. Every bot policy that has
17
+ * ever caused an outage was deployed straight to enforcement by someone who was sure
18
+ * they knew what their traffic looked like.
19
+ */
20
+ export declare function monitorOnly(): Rule[];
21
+ /**
22
+ * Sensible defaults for a public content site.
23
+ *
24
+ * Keeps the crawlers that bring traffic, slows the ones that only take it, challenges
25
+ * what is probably automated, and blocks only what has proven itself.
26
+ */
27
+ export declare function protectContent(): Rule[];
28
+ /**
29
+ * For an application whose value is in its data — pricing, listings, inventory.
30
+ *
31
+ * The distinguishing move is that AI and SEO crawlers are challenged rather than
32
+ * allowed. That is a business decision rather than a security one, and it is exactly
33
+ * the kind of decision that should be a visible rule you can point at.
34
+ */
35
+ export declare function protectData(): Rule[];
36
+ /**
37
+ * For login, signup, password reset, checkout and anything else where automation is
38
+ * expensive to be wrong about in both directions.
39
+ *
40
+ * **Mount this on those routes only.** Applied site-wide it blocks your payment
41
+ * webhooks, your own server-side renderer and every honest crawler you have — all of
42
+ * which are proven automation, which is exactly what this preset refuses. That is
43
+ * correct behaviour on a login form and an outage anywhere else; the traffic corpus
44
+ * catches it, and it is the kind of mistake whose symptom is a support ticket about
45
+ * missing orders three days later.
46
+ *
47
+ * ```ts
48
+ * app.use("/login", botHandler(authDetector));
49
+ * app.use("/checkout", botHandler(authDetector));
50
+ * ```
51
+ *
52
+ * The unusual choice here is `delay` on merely-suspected traffic. A quarter of a
53
+ * second is imperceptible to a person filling in a form and ruinous to a credential
54
+ * stuffer working through a list — and, unlike a challenge, it excludes nobody.
55
+ */
56
+ export declare function protectAuth(): Rule[];
57
+ /**
58
+ * For a publisher whose problem is being *found*, not being scraped.
59
+ *
60
+ * The distinguishing move is that it allows more than it stops, explicitly and by
61
+ * name. Verified crawlers are allowed ahead of everything; declared benign
62
+ * automation — link unfurlers, feed readers, uptime monitors, the specialist search
63
+ * fleets — is allowed rather than merely tolerated; and the only things refused are
64
+ * the three that are refused everywhere, because they are proven and cannot be
65
+ * anything else.
66
+ *
67
+ * Reach for this when a bot policy has already cost you traffic, or when the site's
68
+ * whole purpose is to be indexed, quoted and shared. The cost is honest: bulk
69
+ * extraction gets rate-limited rather than challenged, so a determined scraper will
70
+ * get your content. On a site that wants to be read, that was always going to be
71
+ * true; what this preset refuses to do is trade away your search traffic to make it
72
+ * slightly less true.
73
+ */
74
+ export declare function allowCrawlers(): Rule[];
75
+ /**
76
+ * Keep the search engines. Decline the model trainers.
77
+ *
78
+ * This is the policy question of the moment, and it is a *business* decision rather
79
+ * than a security one — which is exactly the kind of decision this library thinks
80
+ * should be a visible rule you can point at rather than a threshold somebody tuned.
81
+ *
82
+ * The split it draws is the one the AI crawlers themselves publish. A crawler
83
+ * collecting a training corpus and a crawler fetching one page because a person asked
84
+ * a question about it are different jobs, often from the same operator under different
85
+ * product tokens, and a policy that cannot tell them apart either feeds the trainers or
86
+ * breaks the citations. Here the training fleet is declined and everything else is
87
+ * served.
88
+ *
89
+ * **`robots.txt` is the primary mechanism, not this.** The crawlers named here honour
90
+ * it, and a rule that blocks a crawler nobody told is a rule that produces load and no
91
+ * compliance. Generate the file from this policy — `robotsFromRules(declineAiTraining())`
92
+ * — publish it, and treat these rules as what happens to the ones that ignore it.
93
+ */
94
+ export declare function declineAiTraining(): Rule[];
95
+ /**
96
+ * For a JSON API rather than a site.
97
+ *
98
+ * One difference drives the whole shape of this preset: **a challenge is useless
99
+ * here.** A proof-of-work interstitial is solved by a browser running JavaScript, and
100
+ * an API client is not one. Challenging your customers' integrations does not slow an
101
+ * attacker down; it breaks the integrations and leaves the attacker to solve it once
102
+ * in a headless browser. So the escalation ladder is rate limiting, and the terminal
103
+ * actions stay where they always are — on proof.
104
+ *
105
+ * The second difference is about what bot detection is *for* on an API. Your
106
+ * authentication is the control that matters, and it already knows who the caller is.
107
+ * This preset therefore tags everything, so your own handlers can combine a verdict
108
+ * with a key, a plan and a quota; it does not try to be the access control.
109
+ *
110
+ * A bare HTTP client is not suspicious here — it is the normal case, and the rule that
111
+ * challenges one on a content site is deliberately absent.
112
+ */
113
+ export declare function protectApi(): Rule[];
114
+ /**
115
+ * For while it is happening.
116
+ *
117
+ * A deliberately impatient posture for an incident: a scrape in progress, a stuffing
118
+ * run, a scanner sweeping the range. Everything proven is refused, everything
119
+ * suspected is challenged at a much lower bar than usual, and *everyone* — including
120
+ * the people — is rate-limited, because a uniform ceiling is the one mitigation that
121
+ * cannot single anybody out.
122
+ *
123
+ * Three things to be clear about before turning it on.
124
+ *
125
+ * **It is temporary.** Nothing here is a good permanent policy: the low challenge
126
+ * threshold will interrupt real people on unusual browsers, and the uniform rate limit
127
+ * will interrupt your keenest readers. Turn it on during an incident, turn it off
128
+ * afterwards, and put it behind a switch you can flip without a deploy —
129
+ * `updatePolicy()` and the dashboard's editor exist for exactly this.
130
+ *
131
+ * **It still cannot deny anyone on a guess.** The guard applies here as everywhere
132
+ * else: the `challenge` on suspicion is a challenge because that is as far as
133
+ * unproven evidence may go, and asking for a block instead would simply be refused
134
+ * and recorded. An incident is precisely when people reach for `falsePositivePolicy:
135
+ * "aggressive"`, and precisely when the population getting caught is at its most
136
+ * unusual.
137
+ *
138
+ * **It is not DDoS protection.** This runs inside your process, after the connection
139
+ * has been accepted. Volume that hurts you at the network layer needs handling at the
140
+ * network layer; what this reduces is the *usefulness* of the traffic to whoever is
141
+ * sending it.
142
+ *
143
+ * And one practical warning the traffic corpus makes concrete: like `protect-auth`,
144
+ * this refuses proven automation, so **your own webhooks, health probes and
145
+ * server-side renderer are refused too** — thirteen of the corpus's infrastructure
146
+ * cases are, under this policy. Allowlist their addresses *before* you switch it on,
147
+ * not during the incident when you notice.
148
+ */
149
+ export declare function underAttack(): Rule[];
150
+ /** Every preset by name, for config-driven setups. */
151
+ export declare const PRESETS: {
152
+ readonly "monitor-only": typeof monitorOnly;
153
+ readonly "allow-crawlers": typeof allowCrawlers;
154
+ readonly "protect-content": typeof protectContent;
155
+ readonly "decline-ai-training": typeof declineAiTraining;
156
+ readonly "protect-data": typeof protectData;
157
+ readonly "protect-api": typeof protectApi;
158
+ readonly "protect-auth": typeof protectAuth;
159
+ readonly "under-attack": typeof underAttack;
160
+ };
161
+ export type PresetName = keyof typeof PRESETS;
@@ -0,0 +1,155 @@
1
+ import type { Assessment, BotClass, Verdict } from "../types.js";
2
+ import type { BotCategory } from "../detectors/known-bots.js";
3
+ /**
4
+ * What the engine does about an assessment.
5
+ *
6
+ * Ordered roughly by how much they cost a client that turns out to be a person:
7
+ *
8
+ * - `allow` — nothing at all. The verdict is still emitted for logging.
9
+ * - `tag` — serve normally, attach headers describing the verdict so a downstream
10
+ * service (your app, a cache, an edge worker) can decide for itself.
11
+ * - `log` — serve normally and raise a notification. Costs the client nothing.
12
+ * - `delay` — serve normally, but slowly. Invisible to a person, expensive at scale.
13
+ * - `rate-limit` — serve until a threshold, then reject with `429` and `Retry-After`.
14
+ * Recovers on its own.
15
+ * - `challenge` — withhold the response until the client passes a check. Recoverable
16
+ * by the client itself, which is what makes it the safe escalation. It does still
17
+ * shut out anyone without JavaScript, so it is not free.
18
+ * - `redirect` — send the client somewhere else.
19
+ * - `block` — refuse, with a status and a body explaining it.
20
+ * - `drop` — close the connection without a response.
21
+ *
22
+ * `redirect`, `block` and `drop` are **terminal**: the client is denied and cannot
23
+ * recover on its own. Those are the three the safety guard governs.
24
+ */
25
+ export type ActionName = "allow" | "tag" | "log" | "delay" | "rate-limit" | "challenge" | "redirect" | "block" | "drop" | "custom";
26
+ /** Every action, ordered by how much it costs a client that turns out to be a person. */
27
+ export declare const ACTION_NAMES: readonly ActionName[];
28
+ /** Actions a client cannot recover from by itself. The guard's whole concern. */
29
+ export declare const TERMINAL_ACTIONS: ReadonlySet<ActionName>;
30
+ export interface ActionParams {
31
+ /** Status for `block`. Default 403. */
32
+ status?: number | undefined;
33
+ /** Response body for `block`. Defaults to a short plain-text explanation. */
34
+ body?: string | undefined;
35
+ contentType?: string | undefined;
36
+ /** Target for `redirect`. Must be same-origin or an absolute URL you control. */
37
+ location?: string | undefined;
38
+ /** Milliseconds for `delay`. */
39
+ delayMs?: number | undefined;
40
+ /** Ceiling for `rate-limit`. */
41
+ limit?: {
42
+ max: number;
43
+ windowMs: number;
44
+ } | undefined;
45
+ /** Extra response headers, merged after the engine's own. */
46
+ headers?: Record<string, string> | undefined;
47
+ /** Handler id for `custom`. */
48
+ handler?: string | undefined;
49
+ }
50
+ export interface Decision {
51
+ action: ActionName;
52
+ /** Id of the rule that produced it, or `"default"`. */
53
+ rule: string;
54
+ /** Sentence explaining the choice, safe to log and to show an operator. */
55
+ reason: string;
56
+ params: ActionParams;
57
+ /**
58
+ * Set when the safety guard replaced a stronger action. Both the original and the
59
+ * substitute are recorded, because a policy silently doing less than it says is
60
+ * worse than one that refuses loudly.
61
+ */
62
+ downgradedFrom?: ActionName | undefined;
63
+ /** Why the downgrade happened. */
64
+ downgradeReason?: string | undefined;
65
+ }
66
+ /** Declarative matcher. Every field present must match; absent fields are ignored. */
67
+ export interface MatchSpec {
68
+ verdict?: Verdict | readonly Verdict[];
69
+ botClass?: BotClass | readonly BotClass[];
70
+ /** Established or claimed identity, e.g. `"googlebot"`. */
71
+ identity?: string | readonly string[];
72
+ /** Category of the matched signature, e.g. `"ai"`, `"seo"`. */
73
+ category?: BotCategory | readonly BotCategory[];
74
+ /**
75
+ * Require (or forbid) proven evidence.
76
+ *
77
+ * Note what this does *not* mean: `certain` is about the strength of the evidence,
78
+ * not its direction, so `{ certain: true }` also matches a proven **human** — a
79
+ * customer your application vouched for through `grantClearance`. A rule intended
80
+ * for automation must say so, either by putting an allow rule for `verdict: "human"`
81
+ * ahead of it or by naming the verdicts it means. Both shipped presets had this
82
+ * wrong until the traffic corpus caught it.
83
+ */
84
+ certain?: boolean;
85
+ minScore?: number;
86
+ maxScore?: number;
87
+ /** Request path. Strings match as a prefix; regexes are tested as written. */
88
+ path?: string | RegExp | readonly (string | RegExp)[];
89
+ method?: string | readonly string[];
90
+ /** Fires when any evidence came from one of these detectors. */
91
+ detector?: string | readonly string[];
92
+ /** Fires only when the actor has been proven a bot at least this many times before. */
93
+ minPriorConfirmations?: number;
94
+ /**
95
+ * Fires only when this actor has this many challenges outstanding — issued, and never
96
+ * answered.
97
+ *
98
+ * A rule rather than evidence, and the difference is the point. Somebody who abandons
99
+ * a challenge is somebody: a slow phone, a lost tab, a change of mind. It is only
100
+ * *repetition* that means anything, and how much it means depends on traffic the
101
+ * library cannot see — a checkout flow and a documentation site should read the same
102
+ * number differently. So the engine counts, and your policy decides.
103
+ *
104
+ * Solving one clears the count, so this never accumulates against a person who came
105
+ * back and proved it.
106
+ *
107
+ * ```ts
108
+ * { id: "persistent-refusers", match: { minUnsolvedChallenges: 3 }, action: "rate-limit" }
109
+ * ```
110
+ */
111
+ minUnsolvedChallenges?: number;
112
+ }
113
+ export interface Rule {
114
+ /** Stable id. Appears in every decision and log line this rule produces. */
115
+ id: string;
116
+ /** Declarative spec, or a predicate for anything the spec cannot express. */
117
+ match: MatchSpec | ((assessment: Assessment) => boolean);
118
+ action: ActionName;
119
+ params?: ActionParams;
120
+ /** Overrides the generated explanation. */
121
+ reason?: string;
122
+ }
123
+ /**
124
+ * How strictly the engine refuses to act on unproven evidence.
125
+ *
126
+ * - **`strict`** (default) — a terminal action requires `assessment.certain`. A rule
127
+ * asking to block on a probabilistic verdict is downgraded to `fallbackAction` and
128
+ * the substitution is recorded. This is the mode in which the library's central
129
+ * claim holds: nothing is ever denied service on the strength of a guess.
130
+ * - **`balanced`** — terminal actions additionally allowed when the score clears
131
+ * `terminalScoreThreshold` **and** at least two independent `strong` signals fired.
132
+ * Two independent strong signals is a much higher bar than a score alone, which any
133
+ * number of weak correlated observations can reach. It is still, unambiguously, a
134
+ * probabilistic decision — some real people will be caught.
135
+ * - **`aggressive`** — rules run exactly as written. Choose this only with a way to
136
+ * see who you turned away, and a way for them to reach you.
137
+ */
138
+ export type FalsePositivePolicy = "strict" | "balanced" | "aggressive";
139
+ export interface PolicyOptions {
140
+ /** Evaluated in order; the first match wins. */
141
+ rules?: readonly Rule[];
142
+ /** Used when no rule matches. Default `allow`. */
143
+ defaultAction?: ActionName;
144
+ defaultParams?: ActionParams;
145
+ falsePositivePolicy?: FalsePositivePolicy;
146
+ /**
147
+ * Substituted when the guard blocks a terminal action. Default `challenge` if a
148
+ * challenge is configured, otherwise `tag`.
149
+ */
150
+ fallbackAction?: ActionName;
151
+ /** Score needed for a terminal action under `balanced`. Default 85. */
152
+ terminalScoreThreshold?: number;
153
+ /** Called whenever the guard downgrades, so a mismatch between intent and effect is visible. */
154
+ onDowngrade?: ((decision: Decision, assessment: Assessment) => void) | undefined;
155
+ }