@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
@@ -0,0 +1,348 @@
1
+ # Embedding the dashboard
2
+
3
+ `<bot-dashboard>` — the operator dashboard as an element you drop into a page you already
4
+ have.
5
+
6
+ ← [Documentation](../index.md) · [Operations](index.md)
7
+
8
+ ---
9
+
10
+ ## The whole integration
11
+
12
+ ```ts
13
+ // server
14
+ import { createDashboardHandler } from "@osqd/bothandlerjs";
15
+
16
+ const dashboard = createDashboardHandler(detector, {
17
+ basePath: "/_bots",
18
+ auth: { username: "ops", password: process.env.DASHBOARD_PASSWORD! },
19
+ });
20
+ app.use("/_bots", (req, res) => dashboard(req, res));
21
+ ```
22
+
23
+ ```html
24
+ <!-- your admin page -->
25
+ <bot-dashboard src="/_bots"></bot-dashboard>
26
+
27
+ <script type="module">
28
+ import { defineBotDashboard } from "@osqd/bothandlerjs/element";
29
+ defineBotDashboard();
30
+ </script>
31
+ ```
32
+
33
+ The data still comes from a mounted handler, because there is nowhere else for it to come
34
+ from. What the element removes is having to build, route and style a page around it — the
35
+ dashboard now sits inside your own layout, under your own heading, in your own card.
36
+
37
+ ---
38
+
39
+ ## What running it in your page costs you
40
+
41
+ The element renders into a **shadow root** in your document. That is what makes it flow
42
+ with your layout instead of sitting in a frame, and it is worth being exact about what it
43
+ does not do.
44
+
45
+ **What it weighs.** About 50 KB gzipped — the dashboard client, its charts and its
46
+ stylesheet, which is most of what the standalone page loads. It is a separate entry point
47
+ (`@osqd/bothandlerjs/element`), so importing the library on your server does not pull any
48
+ of it, and an `import()` where you mount it keeps it off every other page in your admin
49
+ app. On a warm connection it reaches its first render in well under a tenth of a second;
50
+ what it renders into is a shadow root of a few hundred nodes, which your page's own layout
51
+ never has to walk.
52
+
53
+ **A shadow root is a styling boundary, not a security boundary.** Any script that can run
54
+ on the host page can reach through `element.shadowRoot`, read every client address and
55
+ every piece of evidence on screen, and call the dashboard's API with your credentials. On
56
+ the standalone page — a different document, and usually a different origin — an injected
57
+ script in your application could do none of that.
58
+
59
+ **The standalone page refuses to be framed; your page has to refuse for itself.** The
60
+ dashboard this package serves sends `frame-ancestors 'none'`, so another site cannot frame
61
+ it and trick an operator into clicking a control they cannot see. The element renders into
62
+ a document this package does not serve and cannot set a header on, so that protection does
63
+ not come along with it. The element says so in the console when it finds a cross-origin
64
+ ancestor — a page framing its own pages is ordinary and stays quiet — but only your page
65
+ can actually stop it.
66
+
67
+ So:
68
+
69
+ - Mount it on a page that is **already behind your admin authentication**.
70
+ - Treat an XSS on that page as equivalent to handing over the dashboard.
71
+ - Send `Content-Security-Policy: frame-ancestors 'none'` (or `X-Frame-Options: DENY`) on
72
+ the page you embed it in, the way the standalone page already does.
73
+ - If you would rather have the isolation than the layout, serve the standalone page. It is
74
+ the same dashboard, and `createDashboardHandler` already returns it — point a link at
75
+ `/_bots` instead of embedding.
76
+
77
+ The handler's own safeguards still apply and are worth keeping: it refuses to mount
78
+ without an explicit `auth` decision, and it refuses `controls.editPolicy` with `auth:
79
+ false` on a server of your own.
80
+
81
+ ---
82
+
83
+ ## Choosing the screens
84
+
85
+ ```html
86
+ <bot-dashboard id="d" src="/_bots"></bot-dashboard>
87
+ <script type="module">
88
+ import { defineBotDashboard } from "@osqd/bothandlerjs/element";
89
+
90
+ document.getElementById("d").config = {
91
+ tabs: [
92
+ { id: "stats", label: "Overview" },
93
+ { id: "live", label: "Traffic" },
94
+ ],
95
+ };
96
+
97
+ defineBotDashboard();
98
+ </script>
99
+ ```
100
+
101
+ Four screens exist — `live`, `actors`, `stats`, `policy`. Listing them chooses which
102
+ appear, in what order, under what labels. A screen left out is not built at all.
103
+
104
+ **`tabs` and `hide` do not take the same names.** `tabs` names screens — `live`, `actors`,
105
+ `stats`, `policy`. `hide` names [sections](dashboard.md), which are finer-grained and
106
+ mostly are not screens at all: the four that correspond to screens are `feed`, `registry`,
107
+ `statistics` and `policy`. So `hide: { live: true }` hides nothing — the name you want
108
+ there is `feed`. TypeScript rejects the wrong name outright; in plain JavaScript the
109
+ element says so in the console, and names the one you probably meant.
110
+
111
+ Where they do meet — `tabs` listing a screen that `hide` switches off — `hide` wins. An
112
+ explicit instruction to hide something is not overridden by its appearing in a list.
113
+
114
+ `config.theme` may be changed after mount and takes effect immediately, which is what a
115
+ framework re-rendering its props expects. `tabs`, `panels` and `src` are read once, when
116
+ the element first mounts — and changing any of them afterwards says so in the console
117
+ rather than doing nothing quietly. Re-assigning an equivalent `config`, which is what a
118
+ framework does on every render, says nothing: the comparison is by what the config
119
+ describes, not by object identity.
120
+
121
+ **`src` must be on this origin.** The dashboard sends no CORS headers — that is what stops
122
+ another site reading your traffic through a logged-in browser — so it is same-origin only.
123
+ Point `src` at a path, not at another host; an absolute cross-origin URL says so rather
124
+ than failing as "Failed to fetch".
125
+
126
+ **Set `config` before `defineBotDashboard()`**, as above. That order is the natural one and
127
+ it is also the one that works: the element upgrades when it is defined, and reads its
128
+ configuration then.
129
+
130
+ ### Hiding is not withholding
131
+
132
+ `config.hide` removes parts of the page from the screen. It does **not** stop the server
133
+ sending them:
134
+
135
+ ```js
136
+ config = { hide: { evidence: true } }; // off the screen, still on the wire
137
+ ```
138
+
139
+ The [`sections`](dashboard.md) option on the handler is the one that stops data leaving the
140
+ process, and it is the one to use when the point is that somebody should not have it.
141
+ `hide` is for tidying a view; `sections` is for withholding.
142
+
143
+ ---
144
+
145
+ ## Theming
146
+
147
+ ```js
148
+ config = {
149
+ theme: {
150
+ scheme: "light", // or "dark"; omit to follow the page and the OS
151
+ density: "compact", // or "comfortable"
152
+ tokens: {
153
+ accent: "#7c3aed",
154
+ surface: "#ffffff",
155
+ ink: "#1a1a2e",
156
+ },
157
+ },
158
+ };
159
+ ```
160
+
161
+ Tokens are set on the host element and inherit into the shadow root, which is why every
162
+ token block in the stylesheet is written `:root, :host` — `:root` matches nothing inside a
163
+ shadow tree, and an element styled only against it renders with no colours at all.
164
+
165
+ `scheme` and `density` are also attributes, if a template is easier than a script:
166
+
167
+ ```html
168
+ <bot-dashboard src="/_bots" scheme="dark" density="compact"></bot-dashboard>
169
+ ```
170
+
171
+ Either way they take the two values above and nothing else; anything else is ignored and
172
+ says so in the console once, rather than being written onto the element where it would
173
+ match no rule and look like a dashboard disregarding its configuration. A token dropped
174
+ from `tokens` on a later render is removed from the element, so switching themes gives you
175
+ the new theme rather than the union of every theme you have set.
176
+
177
+ **One caution.** The shipped palette was measured: every ink is at least 4.5:1 on its
178
+ surface, and the two series colours clear every colour-vision gate as an adjacent pair.
179
+ Replace a token and that measurement is yours to redo. The dashboard is a page people read
180
+ all day, sometimes during an incident.
181
+
182
+ ---
183
+
184
+ ## Panels of your own
185
+
186
+ ```js
187
+ config = {
188
+ panels: [
189
+ {
190
+ id: "checkout",
191
+ screen: "stats",
192
+ title: "Checkout health",
193
+ source: "/admin/api/checkout", // → { rows: [{ label, value, note? }] }
194
+ refreshMs: 15_000,
195
+ },
196
+ {
197
+ id: "queue",
198
+ screen: "live",
199
+ title: "Queue depth",
200
+ source: () => ({ rows: [{ label: "Pending", value: pending.length }] }),
201
+ },
202
+ ],
203
+ };
204
+ ```
205
+
206
+ A `source` is a URL returning `{ rows }` or a function returning the same shape. Rows are
207
+ rendered as **text, always** — the rule the rest of the client follows, for the same
208
+ reason. A value containing a tag appears as that tag rather than becoming one.
209
+
210
+ At most 200 rows are drawn, and the panel says how many it left out. A panel is a summary,
211
+ and a source that returns everything it has should not be able to lock up your admin page
212
+ laying it out.
213
+
214
+ What a source returns is treated as data rather than as a contract. Anything that is not an
215
+ array of rows, a row missing its label or value, a source that throws — each says so in
216
+ place instead of drawing something wrong. An object value is shown as JSON rather than as
217
+ `[object Object]`, because the second reads like a bug in the dashboard.
218
+
219
+ A panel whose source fails says so in place, rather than rendering empty and looking like
220
+ a quiet system.
221
+
222
+ ---
223
+
224
+ ## Content-Security-Policy
225
+
226
+ The element needs **nothing added to your policy**. Its stylesheet is a constructable one
227
+ adopted into the shadow root rather than an injected `<style>` element, so `style-src
228
+ 'self'` does not block it — and a strict policy is what an admin page ought to have.
229
+
230
+ The bundle is an ordinary module you import, so it is covered by whatever already lets your
231
+ own scripts run. Verified under `default-src 'self'; script-src 'self'; style-src 'self'`
232
+ with every combination of `theme`, `tabs` and `panels`, including a panel whose source
233
+ fails: no violations, and nothing rendered differently.
234
+
235
+ ## Accessibility
236
+
237
+ Audited with axe inside a host page, in both themes and at phone width, on every release —
238
+ the same bar the standalone page is held to.
239
+
240
+ Three things only go wrong once the dashboard is a section of somebody else's page rather
241
+ than the page, and the element handles all three:
242
+
243
+ - **It paints its own ink and surface.** A shadow root has no `body`, so without this
244
+ everything inherits the host page's colour. On a white page that passes for correct; in
245
+ dark mode it was near-black text on a near-black surface.
246
+ - **It adds no second `main` landmark.** Its own becomes a labelled region, so a screen
247
+ reader user is not offered two "main" landmarks to choose between.
248
+ - **It adds no second banner.** Its header keeps its styling and loses its landmark role.
249
+
250
+ ## Being a guest in your page
251
+
252
+ The dashboard was written to own a document, and embedding it means it no longer does.
253
+ Four things it deliberately stops doing when it is not the page:
254
+
255
+ **It leaves your keyboard alone.** Its shortcuts — digits for tabs, `/` for the filter —
256
+ are bound to its own subtree rather than to the window, so a digit pressed while your page
257
+ has focus is your page's business.
258
+
259
+ **It leaves your URL alone.** The standalone page writes its tab and filter into
260
+ `location.hash` so a link lands on a view. Embedded it writes nothing, because that is your
261
+ address bar and your back button.
262
+
263
+ **Its skip link still works.** `href="#view-live"` is inert inside a shadow root —
264
+ fragment navigation does not cross the boundary — so the affordance that lets a keyboard
265
+ past the header is wired by hand instead.
266
+
267
+ **It survives a route change.** Unmounting and remounting the element, which is what a
268
+ router does on every navigation, keeps the dashboard and the feed history it had built.
269
+
270
+ **It lets go while it is away.** Removing the element closes the event stream, so a page
271
+ that has navigated elsewhere is not holding a server connection and redrawing a dashboard
272
+ nobody can see. Mounting again resumes from the last entry it saw and collects whatever
273
+ arrived in between.
274
+
275
+ ## Frameworks
276
+
277
+ It is a custom element, so it works wherever elements do. Three things worth knowing:
278
+
279
+ **Importing it on the server is safe.** Next, Remix, Astro, Nuxt and the rest evaluate your
280
+ top-level imports while rendering on the server, where there is no DOM. This module imports
281
+ there without complaint and `defineBotDashboard()` does nothing, so you can write the import
282
+ at the top of a component like any other and call it in an effect, or on mount:
283
+
284
+ ```js
285
+ import { defineBotDashboard } from "@osqd/bothandlerjs/element";
286
+
287
+ useEffect(() => {
288
+ defineBotDashboard();
289
+ }, []);
290
+ ```
291
+
292
+ **React 18 in development mounts, unmounts and mounts again** on every component, and the
293
+ element handles that — one dashboard, its history intact, no error.
294
+
295
+ **A name of your own**, if `bot-dashboard` is taken — by your own code or by another
296
+ library, which the element will tell you about rather than quietly doing nothing:
297
+
298
+ ```js
299
+ defineBotDashboard("ops-dashboard");
300
+ ```
301
+
302
+ ## When it does not appear
303
+
304
+ The element does not fail quietly. If a screen, a panel or the whole dashboard is missing,
305
+ the console says which of these it was:
306
+
307
+ | What you see | What it means |
308
+ | --- | --- |
309
+ | "could not start: it answered 401 Unauthorized" | The handler has `auth` set and this browser has not signed in. A background fetch cannot raise the prompt a navigation would — open the dashboard's own URL once, or put the page behind the same authentication. |
310
+ | "could not start: no `src` was given" | It asked this page's own origin and was handed your HTML. Point `src` at where `createDashboardHandler` is mounted. |
311
+ | "could not start: src points at …, which is not this page's origin" | The dashboard sends no CORS headers on purpose. Mount it on this origin and use a path. |
312
+ | "A bot dashboard is already running on this page" | Two elements are connected at once. One at a time; the second takes over if the first leaves. |
313
+ | "`<bot-dashboard>` is already registered … by something else" | Another library owns the name. Use `defineBotDashboard("your-name")`. |
314
+ | "tabs lists `x`, which is not a screen" | The screens are `live`, `actors`, `stats` and `policy`. |
315
+ | "tabs lists `x`, but this dashboard's server has the `y` section switched off" | `sections` on the handler, not something this page can override — and deliberately so. |
316
+ | "hide.`x` did nothing" | `hide` takes section names, `tabs` takes screen names. The message names the one you meant. |
317
+ | "panel `x` asks for screen `y`, which this dashboard does not have" | A typo in `screen`; the message lists the screens that exist. |
318
+ | "two panels share the id `x`" | Ids identify a panel. The second was ignored. |
319
+ | "`tabs` and `panels` are read once" | They are settled at mount. `theme` is the one that updates live. |
320
+ | "scheme `x` is not `light` or `dark`" | Likewise `density`, which takes `comfortable` or `compact`. |
321
+ | "src `…` has a query string on it" | The element asks for `<src>/api/bootstrap`, so only the path can mean anything. It used the path. |
322
+ | "this page is framed by another origin" | Clickjacking risk your page must close itself — see [above](#what-running-it-in-your-page-costs-you). |
323
+
324
+ ## What it does not do
325
+
326
+ **Two at once.** The client is a module graph with its own state; two live elements would
327
+ share it, and the second would draw the first one's traffic. One at a time is fine — and is
328
+ what a router does — so a second element only refuses while another is actually connected.
329
+ The refusal is about the moment rather than about the element: one that lost the race takes
330
+ over if it is still there when the other leaves, and one whose boot failed — a handler that
331
+ had not finished starting, a `src` that was wrong and has since been corrected — tries again
332
+ on its next mount rather than staying dead behind the old message.
333
+
334
+ **It does not replace the standalone page.** Everything in
335
+ [the dashboard](dashboard.md) — `sections`, `controls`, `redact`, the auth options and
336
+ their refusals — applies unchanged, because the element is a different way of rendering the
337
+ same handler rather than a different dashboard. That includes the parts that write:
338
+ `controls.editPolicy` lets an operator change the live policy from inside your page, and
339
+ the same-origin check that guards it is satisfied because the element is on your origin.
340
+
341
+ **It is not translated, and it is not laid out for right-to-left.** In a `dir="rtl"` page
342
+ it inherits the direction and does not break the layout around it, but its own copy is
343
+ English and its columns read left to right.
344
+
345
+ ## Related
346
+
347
+ - [The dashboard](dashboard.md) — every option, and what it refuses to do
348
+ - [Operations](index.md) — events, metrics, the audit
@@ -10,6 +10,7 @@ Knowing what the library is doing to your traffic, and changing it without a dep
10
10
  | ---- | --- |
11
11
  | **This page** — events and hooks | wiring the engine into your own logging, paging and queues |
12
12
  | [The dashboard](dashboard.md) | seeing individual requests and the evidence behind them |
13
+ | [Embedding it](embedding.md) | dropping it into a page you already have |
13
14
  | [Metrics](metrics.md) | counters, histograms, Prometheus |
14
15
  | [The audit](audit.md) | noticing that the traffic changed *shape* |
15
16
  | [Notifications](notifications.md) | webhooks, Slack, batching and redaction |
@@ -38,6 +38,7 @@ allowed to go.**
38
38
  | [`protect-data`](#protect-data) | pricing, listings, inventory | + declared AI and SEO crawlers |
39
39
  | [`protect-api`](#protect-api) | a JSON API | impersonators, scanners, traps |
40
40
  | [`protect-auth`](#protect-auth) | login, signup, checkout — **those routes only** | all proven automation |
41
+ | [`indexers-only`](#indexers-only) | a site that wants search traffic and nothing else automated | all proven automation, and every crawler it cannot confirm |
41
42
  | [`under-attack`](#under-attack) | during an incident, then off again | all proven automation |
42
43
 
43
44
  ---
@@ -142,6 +143,58 @@ The unusual choice is `delay` on merely-suspected traffic: 250 ms is imperceptib
142
143
  person filling in a form and ruinous to a credential stuffer working through a list — and,
143
144
  unlike a challenge, it excludes nobody.
144
145
 
146
+ ## `indexers-only`
147
+
148
+ The strictest permanent posture here. A bot is served only when its identity has been
149
+ *confirmed* — forward-confirmed reverse DNS, or a published range you supplied — and only
150
+ when it is a `search` or `social` crawler. Everything else proven is refused; suspicion is
151
+ challenged; weak signal is held to a ceiling.
152
+
153
+ ```ts
154
+ new BotHandler({ preset: "indexers-only" });
155
+ ```
156
+
157
+ Against the corpus that means five crawlers served — Googlebot, Googlebot Smartphone,
158
+ Bingbot, DuckDuckBot and `facebookexternalhit`, the last two only because the run supplies
159
+ their published ranges — and 194 requests refused by `proven-automation-block` alone.
160
+
161
+ **Most indexers cannot be verified at all.** Twelve of the shipped search and social
162
+ signatures publish forward-confirmable DNS; two more are checkable only if you configure
163
+ `crawlerRanges`. The remaining twenty-three — Twitterbot, LinkedInBot, Slackbot, Discord,
164
+ Telegram, WhatsApp, Reddit, Mastodon, Bluesky, and the smaller search engines — publish
165
+ nothing a claim can be checked against, so they can never reach `verified-bot` and
166
+ `unverifiable-indexer-block` refuses them. Your pages stop getting link previews when
167
+ somebody shares them. That rule is separate and named so you can change its action to
168
+ `rate-limit`, which serves them at a ceiling instead.
169
+
170
+ **It refuses your own infrastructure.** Fifteen of the corpus's thirty-three
171
+ infrastructure cases are blocked by it: Kubernetes and ALB health checks, the Prometheus
172
+ blackbox exporter, a Cloudflare origin fetch, your own server-side renderer, and the
173
+ Stripe webhook. Allowlist yours by identity, address or path *above* the preset, before
174
+ you switch it on.
175
+
176
+ **And it reaches people through their software.** The corpus's `app-podcast-shownotes`
177
+ case is a person reading show notes in Overcast, whose User-Agent carries the crawler
178
+ contact convention because the same app fetches feeds. The library reads a proven declared
179
+ bot and is right about the client; the person behind it still gets a 403. Thirteen more
180
+ human cases — VS Code's Simple Browser, the Slack, Discord, Spotify, Notion, Figma,
181
+ Postman, Teams and Steam clients, an office of two hundred behind one address — are held
182
+ to the 60/minute ceiling, and five, including a corporate proxy and a carrier transcoder,
183
+ are challenged. None of that is a bug in the preset; it is the price of the posture, and
184
+ it is why `monitor-only` comes first.
185
+
186
+ One thing it cannot say in `robots.txt`. [`robotsFromRules`](../reference/api.md) reads
187
+ identities and categories, not verdicts, so it sees `verified-indexer-allow` serving the
188
+ `search` and `social` categories and generates a permissive file — while the policy in
189
+ fact refuses every crawler in those categories it could not confirm. The error runs in the
190
+ conservative direction (a file that turns crawlers away when the policy would have served
191
+ them is the expensive one), but it means the enforcement here is the 403 and not the file.
192
+
193
+ Suspicion is challenged rather than blocked, and deliberately: under `strict` a `block` on
194
+ a probabilistic verdict is downgraded to a challenge anyway, so a rule asking for one
195
+ would only add a guard stop to every suspicious request. To deny on suspicion, say so
196
+ where it shows — `falsePositivePolicy: "balanced"` plus a rule that asks for a block.
197
+
145
198
  ## `under-attack`
146
199
 
147
200
  A deliberately impatient posture for an incident. Everything proven is refused, suspicion
@@ -19,6 +19,7 @@ wrong?**
19
19
  | a broken integration | an API bill | [`protect-api`](../policy/presets.md#protect-api) |
20
20
  | a lost signup | a compromised account | [`protect-auth`](../policy/presets.md#protect-auth), on those routes |
21
21
  | your search traffic | your work in a training corpus | [`decline-ai-training`](../policy/presets.md#decline-ai-training) |
22
+ | a reader in an in-app browser, and your own health checks | any automation you did not confirm by name | [`indexers-only`](../policy/presets.md#indexers-only) |
22
23
 
23
24
  Whatever the answer, **the first week is [`monitor-only`](../policy/presets.md#monitor-only)**.
24
25
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@osqd/bothandlerjs",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -44,6 +44,11 @@
44
44
  "import": "./dist/client/index.js",
45
45
  "require": "./dist/client/index.cjs"
46
46
  },
47
+ "./element": {
48
+ "types": "./dist/element/index.d.ts",
49
+ "import": "./dist/element/index.js",
50
+ "require": "./dist/element/index.cjs"
51
+ },
47
52
  "./package.json": "./package.json",
48
53
  "./cli": {
49
54
  "types": "./dist/cli.d.ts",
@@ -74,7 +79,7 @@
74
79
  "LICENSE"
75
80
  ],
76
81
  "scripts": {
77
- "build": "tsup && tsc --emitDeclarationOnly",
82
+ "build": "tsup && tsc --emitDeclarationOnly && tsc -p tsconfig.element.json",
78
83
  "build:watch": "tsup --watch",
79
84
  "test": "vitest run",
80
85
  "test:watch": "vitest",
@@ -120,7 +125,7 @@
120
125
  "prebuild": "npm run client:build",
121
126
  "pretest": "npm run client:build",
122
127
  "pretypecheck": "npm run client:build",
123
- "pretest:browser": "npm run client:build",
128
+ "pretest:browser": "npm run client:build && tsup",
124
129
  "bench:guard": "tsx scripts/bench-guard.ts"
125
130
  },
126
131
  "devDependencies": {