@osqd/bothandlerjs 0.4.0 → 0.6.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 (44) hide show
  1. package/dist/adapters/index.cjs +3 -0
  2. package/dist/adapters/index.cjs.map +1 -1
  3. package/dist/adapters/index.js +3 -0
  4. package/dist/adapters/index.js.map +1 -1
  5. package/dist/cli.cjs +634 -25
  6. package/dist/cli.cjs.map +1 -1
  7. package/dist/cli.js +634 -25
  8. package/dist/cli.js.map +1 -1
  9. package/dist/config.d.ts +10 -0
  10. package/dist/core.d.ts +15 -0
  11. package/dist/corpus/index.cjs +84 -0
  12. package/dist/corpus/index.cjs.map +1 -1
  13. package/dist/corpus/index.js +84 -0
  14. package/dist/corpus/index.js.map +1 -1
  15. package/dist/corpus/schema.d.ts +8 -0
  16. package/dist/crawler-ranges.d.ts +31 -0
  17. package/dist/dashboard/client/feed.d.ts +16 -0
  18. package/dist/dashboard/client/format.d.ts +18 -0
  19. package/dist/dashboard/client/pager.d.ts +32 -0
  20. package/dist/dashboard/client/store.d.ts +60 -0
  21. package/dist/dashboard/client.generated.d.ts +1 -1
  22. package/dist/detectors/crawler-verification.d.ts +34 -1
  23. package/dist/detectors/id-enumeration.d.ts +31 -0
  24. package/dist/detectors/index.d.ts +12 -1
  25. package/dist/detectors/known-bots.d.ts +60 -1
  26. package/dist/detectors/parameter-sweep.d.ts +39 -0
  27. package/dist/detectors/probe-volume.d.ts +26 -0
  28. package/dist/detectors/transport-coherence.d.ts +31 -0
  29. package/dist/element/index.cjs +349 -40
  30. package/dist/element/index.cjs.map +1 -1
  31. package/dist/element/index.js +349 -40
  32. package/dist/element/index.js.map +1 -1
  33. package/dist/index.cjs +576 -31
  34. package/dist/index.cjs.map +1 -1
  35. package/dist/index.d.ts +2 -2
  36. package/dist/index.js +571 -31
  37. package/dist/index.js.map +1 -1
  38. package/dist/state.d.ts +76 -1
  39. package/dist/types.d.ts +36 -0
  40. package/docs/course/05-detectors.md +4 -0
  41. package/docs/detection/detectors.md +122 -0
  42. package/docs/detection/signatures.md +44 -0
  43. package/docs/operations/dashboard.md +26 -0
  44. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -123,16 +123,20 @@ function hashString(value) {
123
123
  }
124
124
  return hash >>> 0;
125
125
  }
126
- var TIMESTAMP_RING, PATH_CAP, UA_CAP, MAX_TRACKED_ARRIVALS, MAX_TRACKED_PATHS, ActorState, ActorRegistry;
126
+ var TIMESTAMP_RING, PATH_CAP, QUERY_CAP, METHOD_CAP, WALK_CAP, UA_CAP, MAX_TRACKED_ARRIVALS, MAX_TRACKED_PATHS, MAX_TRACKED_QUERIES, ActorState, ActorRegistry;
127
127
  var init_state = __esm({
128
128
  "src/state.ts"() {
129
129
  "use strict";
130
130
  init_lru();
131
131
  TIMESTAMP_RING = 32;
132
132
  PATH_CAP = 64;
133
+ QUERY_CAP = 64;
134
+ METHOD_CAP = 12;
135
+ WALK_CAP = 4;
133
136
  UA_CAP = 4;
134
137
  MAX_TRACKED_ARRIVALS = TIMESTAMP_RING;
135
138
  MAX_TRACKED_PATHS = PATH_CAP;
139
+ MAX_TRACKED_QUERIES = QUERY_CAP;
136
140
  ActorState = class {
137
141
  key;
138
142
  firstSeen;
@@ -173,6 +177,45 @@ var init_state = __esm({
173
177
  paths = /* @__PURE__ */ new Set();
174
178
  pathsOverflowed = false;
175
179
  pathsSaturatedAtTotal = 0;
180
+ /**
181
+ * Distinct *parameterised* requests: the path together with its query.
182
+ *
183
+ * Counted apart from `paths` because the two answer different questions and a scraper
184
+ * lives in the gap between them. `/products?page=1` through `?page=200` is one path and
185
+ * two hundred requests, so breadth reads it as somebody rereading a single page — which
186
+ * is exactly what enumerating a catalogue looks like from the path alone.
187
+ */
188
+ queries = /* @__PURE__ */ new Set();
189
+ queriesOverflowed = false;
190
+ /**
191
+ * Which HTTP methods this actor has used.
192
+ *
193
+ * A browser navigating issues GET. Something that has issued nothing but HEAD across a
194
+ * long visit is checking what exists rather than reading it, and that is a fact about
195
+ * the actor rather than about any one of its requests — which is why it is kept here.
196
+ */
197
+ methods = /* @__PURE__ */ new Set();
198
+ /**
199
+ * Numeric walks in progress, by path shape: `/user/#` against the ids requested under it.
200
+ *
201
+ * Three numbers per shape, deliberately — a count, a lowest and a highest — rather than
202
+ * the ids themselves. What separates enumeration from reading is not which ids were
203
+ * asked for but whether they *cover a range*: thirty requests spanning thirty
204
+ * consecutive ids is a walk, and thirty scattered across a hundred thousand is somebody
205
+ * following links. Both are answerable from a count and a span, and only the count and
206
+ * the span survive an actor asking for ten thousand of them.
207
+ */
208
+ walks = /* @__PURE__ */ new Map();
209
+ /**
210
+ * What the application answered, for the requests anybody bothered to tell us about.
211
+ *
212
+ * The engine decides *before* the response exists, so this arrives afterwards and only
213
+ * when the adapter reports it. Kept as two counters rather than a list because the one
214
+ * question worth asking is a ratio: an actor whose requests are almost all misses is
215
+ * looking for something rather than reading anything.
216
+ */
217
+ responsesSeen = 0;
218
+ missesSeen = 0;
176
219
  userAgents = /* @__PURE__ */ new Set();
177
220
  constructor(key, now) {
178
221
  this.key = key;
@@ -192,6 +235,15 @@ var init_state = __esm({
192
235
  this.pathsOverflowed = true;
193
236
  this.pathsSaturatedAtTotal = this.total;
194
237
  }
238
+ const keys = Object.keys(facts.query).sort();
239
+ if (keys.length > 0) {
240
+ const signature = `${facts.path}?${keys.map((key) => `${key}=${facts.query[key] ?? ""}`).join("&")}`;
241
+ const queryHash = hashString(signature);
242
+ if (this.queries.size < QUERY_CAP) this.queries.add(queryHash);
243
+ else if (!this.queries.has(queryHash)) this.queriesOverflowed = true;
244
+ }
245
+ if (this.methods.size < METHOD_CAP) this.methods.add(facts.method);
246
+ this.noteWalk(facts.path);
195
247
  const ua = facts.headers["user-agent"];
196
248
  if (ua !== void 0 && this.userAgents.size < UA_CAP) this.userAgents.add(ua);
197
249
  }
@@ -202,6 +254,79 @@ var init_state = __esm({
202
254
  get pathsSaturated() {
203
255
  return this.pathsOverflowed;
204
256
  }
257
+ /** Distinct path-and-query combinations seen. Saturates at {@link QUERY_CAP}. */
258
+ get distinctQueries() {
259
+ return this.queries.size;
260
+ }
261
+ get queriesSaturated() {
262
+ return this.queriesOverflowed;
263
+ }
264
+ /**
265
+ * Records what the application answered. Called after the response, if at all.
266
+ *
267
+ * 404 and 410 only. A 403 is usually this library's own doing and counting it would
268
+ * make the detector that reads this argue with itself; a 500 is the site's problem and
269
+ * says nothing about the client.
270
+ */
271
+ recordOutcome(status) {
272
+ this.responsesSeen++;
273
+ if (status === 404 || status === 410) this.missesSeen++;
274
+ }
275
+ /** Responses reported for this actor. Zero unless something is reporting them. */
276
+ get responses() {
277
+ return this.responsesSeen;
278
+ }
279
+ /** Of those, how many were 404 or 410. */
280
+ get misses() {
281
+ return this.missesSeen;
282
+ }
283
+ /**
284
+ * Files a request under the shape of its path, if that path carries a number.
285
+ *
286
+ * The last numeric segment is the one taken to be the identifier: in `/api/v2/orders/42`
287
+ * the version is part of the shape and the order id is what is being walked.
288
+ */
289
+ noteWalk(path) {
290
+ const segments = path.split("/");
291
+ let value;
292
+ let template = "";
293
+ for (const segment of segments) {
294
+ if (segment !== "" && /^\d+$/.test(segment)) {
295
+ const parsed = Number(segment);
296
+ if (Number.isSafeInteger(parsed) && parsed <= 1e7) value = parsed;
297
+ template += "/#";
298
+ } else if (segment !== "") {
299
+ template += `/${segment}`;
300
+ }
301
+ }
302
+ if (value === void 0) return;
303
+ const existing = this.walks.get(template);
304
+ if (existing !== void 0) {
305
+ existing.count++;
306
+ if (value < existing.min) existing.min = value;
307
+ if (value > existing.max) existing.max = value;
308
+ return;
309
+ }
310
+ if (this.walks.size < WALK_CAP) this.walks.set(template, { count: 1, min: value, max: value });
311
+ }
312
+ /**
313
+ * The path shape this actor has walked hardest, with how far it reached.
314
+ *
315
+ * `span` is inclusive of both ends, so a walk of 1 to 30 spans 30. Comparing the count
316
+ * against it is what separates covering a range from visiting a few points in one.
317
+ */
318
+ densestWalk() {
319
+ let best;
320
+ for (const [template, walk] of this.walks) {
321
+ const span = walk.max - walk.min + 1;
322
+ if (best === void 0 || walk.count > best.count) best = { template, count: walk.count, span };
323
+ }
324
+ return best;
325
+ }
326
+ /** Every HTTP method this actor has used, in first-seen order. */
327
+ get methodsSeen() {
328
+ return [...this.methods];
329
+ }
205
330
  /**
206
331
  * Requests seen when {@link distinctPaths} stopped being able to grow, or 0 if it
207
332
  * still can. Over that many requests the distinct count is exact, so it is the only
@@ -290,6 +415,12 @@ var init_state = __esm({
290
415
  key: this.key,
291
416
  requests: this.total,
292
417
  distinctPaths: this.distinctPaths,
418
+ distinctQueries: this.distinctQueries,
419
+ methodsSeen: this.methodsSeen,
420
+ walk: this.densestWalk(),
421
+ responses: this.responses,
422
+ misses: this.misses,
423
+ queriesSaturated: this.queriesSaturated,
293
424
  firstSeen: this.firstSeen,
294
425
  lastSeen: this.lastSeen,
295
426
  sinceLastMs: this.sinceLast(now),
@@ -344,7 +475,7 @@ var init_state = __esm({
344
475
  * A read, and only a read: it neither records a request against an actor nor moves
345
476
  * one up the LRU, so watching the list cannot change what it lists.
346
477
  */
347
- top(limit, now) {
478
+ top(limit, now, offset = 0) {
348
479
  const summaries = this.actors.values().map((state) => {
349
480
  const cadence = state.intervalStats();
350
481
  return {
@@ -358,7 +489,8 @@ var init_state = __esm({
358
489
  };
359
490
  });
360
491
  summaries.sort((a, b) => b.requests - a.requests);
361
- return summaries.slice(0, Math.max(0, limit));
492
+ const from = Math.max(0, offset);
493
+ return summaries.slice(from, from + Math.max(0, limit));
362
494
  }
363
495
  forget(key) {
364
496
  this.actors.delete(key);
@@ -3351,12 +3483,30 @@ function compileSignatures(signatures = BOT_SIGNATURES) {
3351
3483
  }
3352
3484
  return new MultiPatternMatcher(entries);
3353
3485
  }
3354
- var BOT_CATEGORIES, SEARCH, AI, SEO, SOCIAL, MONITORING, ARCHIVE, FEED, SECURITY, LIBRARY, HEADLESS, EMBEDDED, ADVERTISING, BOT_SIGNATURES;
3486
+ var BOT_CATEGORIES, SEARCH, AI, SEO, SOCIAL, MONITORING, ARCHIVE, FEED, SECURITY, LIBRARY, HEADLESS, EMBEDDED, ADVERTISING, COMMERCE, ACADEMIC, EMAIL_SECURITY, ACCESSIBILITY, BOT_SIGNATURES;
3355
3487
  var init_known_bots = __esm({
3356
3488
  "src/detectors/known-bots.ts"() {
3357
3489
  "use strict";
3358
3490
  init_matcher();
3359
- BOT_CATEGORIES = ["search", "ai", "seo", "social", "monitoring", "archive", "feed", "security", "advertising", "library", "headless", "embedded", "other"];
3491
+ BOT_CATEGORIES = [
3492
+ "search",
3493
+ "ai",
3494
+ "seo",
3495
+ "social",
3496
+ "monitoring",
3497
+ "archive",
3498
+ "feed",
3499
+ "security",
3500
+ "advertising",
3501
+ "library",
3502
+ "headless",
3503
+ "embedded",
3504
+ "commerce",
3505
+ "accessibility",
3506
+ "academic",
3507
+ "email-security",
3508
+ "other"
3509
+ ];
3360
3510
  SEARCH = [
3361
3511
  { id: "googlebot", name: "Googlebot", tokens: ["googlebot", "google-inspectiontool", "storebot-google", "googleother", "google favicon", "google-read-aloud", "google-shopping-quality", "google-site-verification", "googleproducersearch", "google-safety"], category: "search", benign: true, robotsAgent: "Googlebot", verification: { kind: "fcrdns", domains: ["googlebot.com", "google.com", "googleusercontent.com"] }, docs: "https://developers.google.com/search/docs/crawling-indexing/verifying-googlebot" },
3362
3512
  { id: "bingbot", name: "Bingbot", tokens: ["bingbot", "adidxbot", "msnbot", "bingpreview"], category: "search", benign: true, robotsAgent: "bingbot", verification: { kind: "fcrdns", domains: ["search.msn.com"] }, docs: "https://www.bing.com/webmasters/help/how-to-verify-bingbot-3905dc26" },
@@ -3379,6 +3529,13 @@ var init_known_bots = __esm({
3379
3529
  { id: "yisouspider", name: "Shenma (Yisou) Spider", tokens: ["yisouspider"], category: "search", benign: true, robotsAgent: "YisouSpider", verification: { kind: "none" } },
3380
3530
  { id: "yahoo-slurp", name: "Yahoo! Slurp", tokens: ["yahoo! slurp"], category: "search", benign: true, robotsAgent: "Slurp", verification: { kind: "fcrdns", domains: ["crawl.yahoo.net", "yahoo.com"] } },
3381
3531
  { id: "mail-ru", name: "Mail.Ru bot", tokens: ["mail.ru_bot"], category: "search", benign: true, verification: { kind: "none" } },
3532
+ { id: "brave-search", name: "Brave Search", tokens: ["bravesearchbot"], category: "search", benign: true, robotsAgent: "BraveSearchBot", verification: { kind: "none" } },
3533
+ { id: "ecosia", name: "Ecosia", tokens: ["ecosiabot"], category: "search", benign: true, verification: { kind: "none" } },
3534
+ { id: "startpage", name: "Startpage", tokens: ["startpagebot"], category: "search", benign: true, verification: { kind: "none" } },
3535
+ { id: "daum", name: "Daumoa", tokens: ["daumoa"], category: "search", benign: true, verification: { kind: "none" } },
3536
+ { id: "stract", name: "Stract", tokens: ["stractbot"], category: "search", benign: true, verification: { kind: "none" } },
3537
+ { id: "rightdao", name: "RightDao", tokens: ["rightdaobot"], category: "search", benign: true, verification: { kind: "none" } },
3538
+ { id: "gigablast", name: "Gigablast", tokens: ["gigablastopensource"], category: "search", benign: true, verification: { kind: "none" } },
3382
3539
  { id: "exabot", name: "Exabot (Exalead)", tokens: ["exabot"], category: "search", benign: true, verification: { kind: "none" } },
3383
3540
  { id: "kagibot", name: "Kagi", tokens: ["kagibot"], category: "search", benign: true, robotsAgent: "KagiBot", verification: { kind: "none" } }
3384
3541
  ];
@@ -3449,11 +3606,15 @@ var init_known_bots = __esm({
3449
3606
  { id: "pinterestbot", name: "Pinterestbot", tokens: ["pinterest/", "pinterestbot"], category: "social", benign: true, verification: { kind: "fcrdns", domains: ["pinterest.com"] } },
3450
3607
  { id: "mastodon", name: "Mastodon / Fediverse", tokens: ["mastodon/", "pleroma", "misskey/", "akkoma"], category: "social", benign: true, verification: { kind: "none" } },
3451
3608
  { id: "embedly", name: "Embedly", tokens: ["embedly"], category: "social", benign: true, verification: { kind: "none" } },
3452
- { id: "bluesky", name: "Bluesky card fetcher", tokens: ["bluesky cardyb", "cardyb/"], category: "social", benign: true, verification: { kind: "none" } },
3609
+ { id: "bluesky", name: "Bluesky card fetcher", tokens: ["bluesky cardyb", "cardyb/", "blueskybot"], category: "social", benign: true, verification: { kind: "none" } },
3453
3610
  { id: "iframely", name: "Iframely", tokens: ["iframely"], category: "social", benign: true, verification: { kind: "none" } },
3454
3611
  { id: "skype-preview", name: "Skype URI preview", tokens: ["skypeuripreview"], category: "social", benign: true, verification: { kind: "none" } },
3455
3612
  { id: "vk-share", name: "VK / Odnoklassniki preview", tokens: ["vkshare", "odklbot"], category: "social", benign: true, verification: { kind: "none" } },
3456
3613
  { id: "discourse-onebox", name: "Discourse Onebox", tokens: ["discourse forum onebox"], category: "social", benign: true, verification: { kind: "none" } },
3614
+ { id: "microsoft-preview", name: "Microsoft Teams preview", tokens: ["microsoftpreview"], category: "social", benign: true, verification: { kind: "none" } },
3615
+ { id: "zoom-preview", name: "Zoom link preview", tokens: ["zoombot"], category: "social", benign: true, verification: { kind: "none" } },
3616
+ { id: "signal-preview", name: "Signal link preview", tokens: ["signalbot"], category: "social", benign: true, verification: { kind: "none" } },
3617
+ { id: "matrix-synapse", name: "Matrix (Synapse) preview", tokens: ["synapse/"], category: "social", benign: true, verification: { kind: "none" } },
3457
3618
  { id: "yahoo-preview", name: "Yahoo Link Preview", tokens: ["yahoo link preview"], category: "social", benign: true, verification: { kind: "none" } }
3458
3619
  ];
3459
3620
  MONITORING = [
@@ -3482,10 +3643,21 @@ var init_known_bots = __esm({
3482
3643
  { id: "exporters", name: "Metrics and availability exporters", tokens: ["blackbox_exporter", "prometheus/", "zabbix", "check_http", "nagios", "icinga"], category: "monitoring", benign: true, verification: { kind: "none" } }
3483
3644
  ];
3484
3645
  ARCHIVE = [
3646
+ { id: "archive-today", name: "archive.today", tokens: ["archive.today"], category: "archive", benign: true, verification: { kind: "none" } },
3647
+ { id: "perma-cc", name: "Perma.cc", tokens: ["perma.cc"], category: "archive", benign: true, verification: { kind: "none" } },
3648
+ { id: "webrecorder", name: "Webrecorder", tokens: ["webrecorder"], category: "archive", benign: true, verification: { kind: "none" } },
3485
3649
  { id: "ia-archiver", name: "Internet Archive", tokens: ["ia_archiver", "archive.org_bot", "wayback"], category: "archive", benign: true, verification: { kind: "none" } },
3486
3650
  { id: "heritrix", name: "Heritrix", tokens: ["heritrix"], category: "archive", benign: true, verification: { kind: "none" } }
3487
3651
  ];
3488
3652
  FEED = [
3653
+ { id: "newsblur", name: "NewsBlur", tokens: ["newsblur"], category: "feed", benign: true, verification: { kind: "none" } },
3654
+ { id: "smartnews", name: "SmartNews", tokens: ["smartnewsbot"], category: "feed", benign: true, verification: { kind: "none" } },
3655
+ { id: "flipboard", name: "Flipboard", tokens: ["flipboardproxy"], category: "feed", benign: true, verification: { kind: "none" } },
3656
+ { id: "podcast-index", name: "Podcast Index", tokens: ["podcastindexbot"], category: "feed", benign: true, verification: { kind: "none" } },
3657
+ // Spotify's podcast fetcher sends `Spotify/1.0` — and so does the Spotify desktop app,
3658
+ // with a person driving it. There is no token that separates them, so this one is left
3659
+ // unnamed rather than named wrongly: the corpus proved the point immediately by blocking
3660
+ // a human under `protect-auth`, `indexers-only` and `under-attack` at once.
3489
3661
  { id: "freshrss", name: "FreshRSS", tokens: ["freshrss"], category: "feed", benign: true, verification: { kind: "none" } },
3490
3662
  { id: "netnewswire", name: "NetNewsWire", tokens: ["netnewswire"], category: "feed", benign: true, verification: { kind: "none" } },
3491
3663
  { id: "overcast", name: "Overcast", tokens: ["overcast/"], category: "feed", benign: true, verification: { kind: "none" } },
@@ -3612,7 +3784,46 @@ var init_known_bots = __esm({
3612
3784
  ];
3613
3785
  ADVERTISING = [
3614
3786
  { id: "adsbot-google", name: "AdsBot-Google", tokens: ["adsbot-google", "mediapartners-google", "adsbot"], category: "advertising", benign: true, verification: { kind: "fcrdns", domains: ["googlebot.com", "google.com"] } },
3615
- { id: "criteo", name: "Criteo", tokens: ["criteobot"], category: "advertising", benign: true, verification: { kind: "none" } }
3787
+ { id: "criteo", name: "Criteo", tokens: ["criteobot"], category: "advertising", benign: true, verification: { kind: "none" } },
3788
+ // Verification and contextual classification: they read a page to decide whether an ad
3789
+ // may appear beside it, or what the page is about. A publisher usually wants these and a
3790
+ // site with no advertising has no reason to.
3791
+ { id: "doubleverify", name: "DoubleVerify", tokens: ["doubleverifybot"], category: "advertising", benign: true, verification: { kind: "none" } },
3792
+ { id: "ias", name: "Integral Ad Science", tokens: ["ias crawler"], category: "advertising", benign: true, verification: { kind: "none" } },
3793
+ { id: "moat", name: "Moat", tokens: ["moatbot"], category: "advertising", benign: true, verification: { kind: "none" } },
3794
+ { id: "comscore", name: "comScore (Proximic)", tokens: ["proximic"], category: "advertising", benign: true, verification: { kind: "none" } },
3795
+ { id: "grapeshot", name: "Grapeshot", tokens: ["grapeshotcrawler"], category: "advertising", benign: true, verification: { kind: "none" } },
3796
+ { id: "peer39", name: "Peer39", tokens: ["peer39bot"], category: "advertising", benign: true, verification: { kind: "none" } },
3797
+ { id: "taboola", name: "Taboola", tokens: ["taboolabot"], category: "advertising", benign: true, verification: { kind: "none" } },
3798
+ { id: "outbrain", name: "Outbrain", tokens: ["outbrainbot"], category: "advertising", benign: true, verification: { kind: "none" } },
3799
+ { id: "pubmatic", name: "PubMatic", tokens: ["pubmaticbot"], category: "advertising", benign: true, verification: { kind: "none" } },
3800
+ { id: "thetradedesk", name: "The Trade Desk", tokens: ["ttd-content"], category: "advertising", benign: true, verification: { kind: "none" } },
3801
+ // Competitive ad intelligence rather than verification: it collects what everyone else
3802
+ // is running. Named, and left for the operator to decide about.
3803
+ { id: "adbeat", name: "Adbeat", tokens: ["adbeat_bot"], category: "advertising", benign: false, verification: { kind: "none" } }
3804
+ ];
3805
+ COMMERCE = [
3806
+ { id: "idealo", name: "idealo", tokens: ["idealo-bot"], category: "commerce", benign: false, verification: { kind: "none" } },
3807
+ { id: "kelkoo", name: "Kelkoo", tokens: ["kelkoobot"], category: "commerce", benign: false, verification: { kind: "none" } },
3808
+ { id: "pricerunner", name: "PriceRunner", tokens: ["pricerunnerbot"], category: "commerce", benign: false, verification: { kind: "none" } },
3809
+ { id: "trivago", name: "Trivago", tokens: ["trivagobot"], category: "commerce", benign: false, verification: { kind: "none" } },
3810
+ { id: "skyscanner", name: "Skyscanner", tokens: ["skyscannerbot"], category: "commerce", benign: false, verification: { kind: "none" } },
3811
+ { id: "indeedbot", name: "Indeedbot", tokens: ["indeedbot"], category: "commerce", benign: true, robotsAgent: "Indeedbot", verification: { kind: "none" }, docs: "http://www.indeed.com/indeedbot.html" },
3812
+ { id: "adzuna", name: "Adzuna", tokens: ["adzunabot"], category: "commerce", benign: true, verification: { kind: "none" } }
3813
+ ];
3814
+ ACADEMIC = [
3815
+ { id: "crossref", name: "Crossref", tokens: ["crossrefbot"], category: "academic", benign: true, verification: { kind: "none" } },
3816
+ { id: "openalex", name: "OpenAlex", tokens: ["openalexbot"], category: "academic", benign: true, verification: { kind: "none" } },
3817
+ { id: "webis", name: "Webis research crawler", tokens: ["webisbot"], category: "academic", benign: true, verification: { kind: "none" } }
3818
+ ];
3819
+ EMAIL_SECURITY = [
3820
+ { id: "proofpoint", name: "Proofpoint URL Defense", tokens: ["proofpointurldefensebot"], category: "email-security", benign: true, verification: { kind: "none" } },
3821
+ { id: "mimecast", name: "Mimecast URL Protect", tokens: ["mimecasturlprotectbot"], category: "email-security", benign: true, verification: { kind: "none" } },
3822
+ { id: "barracuda", name: "Barracuda Link Protect", tokens: ["barracudalinkprotectbot"], category: "email-security", benign: true, verification: { kind: "none" } },
3823
+ { id: "cisco-esa", name: "Cisco Secure Email", tokens: ["ciscosecureemailbot"], category: "email-security", benign: true, verification: { kind: "none" } }
3824
+ ];
3825
+ ACCESSIBILITY = [
3826
+ { id: "siteimprove", name: "Siteimprove", tokens: ["siteimprovebot"], category: "accessibility", benign: true, robotsAgent: "SiteimproveBot", verification: { kind: "none" } }
3616
3827
  ];
3617
3828
  BOT_SIGNATURES = Object.freeze([
3618
3829
  ...SEARCH,
@@ -3626,7 +3837,11 @@ var init_known_bots = __esm({
3626
3837
  ...ADVERTISING,
3627
3838
  ...LIBRARY,
3628
3839
  ...HEADLESS,
3629
- ...EMBEDDED
3840
+ ...EMBEDDED,
3841
+ ...COMMERCE,
3842
+ ...ACADEMIC,
3843
+ ...ACCESSIBILITY,
3844
+ ...EMAIL_SECURITY
3630
3845
  ]);
3631
3846
  }
3632
3847
  });
@@ -4378,10 +4593,171 @@ var init_crawl_breadth = __esm({
4378
4593
  }
4379
4594
  });
4380
4595
 
4596
+ // src/detectors/parameter-sweep.ts
4597
+ function parameterSweepDetector(options = {}) {
4598
+ const threshold = options.threshold ?? 25;
4599
+ const variantsPerPath = options.variantsPerPath ?? 8;
4600
+ const minRequests = options.minRequests ?? 20;
4601
+ if (threshold > MAX_TRACKED_QUERIES) {
4602
+ throw new RangeError(
4603
+ `parameterSweepDetector threshold ${threshold} can never be reached: an actor's distinct-query count saturates at ${MAX_TRACKED_QUERIES}. Use ${MAX_TRACKED_QUERIES} or fewer.`
4604
+ );
4605
+ }
4606
+ return {
4607
+ id: "parameter-sweep",
4608
+ description: "Counts distinct query strings against the paths they sit on, to catch enumeration that leaves the path unchanged",
4609
+ cost: "cheap",
4610
+ stage: "always",
4611
+ inspect(ctx) {
4612
+ const { distinctPaths, distinctQueries, queriesSaturated, total } = ctx.state;
4613
+ if (total < minRequests || distinctQueries < threshold) return void 0;
4614
+ const spread = distinctQueries / Math.max(1, distinctPaths);
4615
+ if (spread < variantsPerPath) return void 0;
4616
+ return {
4617
+ detector: "parameter-sweep",
4618
+ summary: queriesSaturated ? `at least ${distinctQueries} distinct query strings across only ${distinctPaths} path(s)` : `${distinctQueries} distinct query strings across only ${distinctPaths} path(s) in ${total} requests`,
4619
+ direction: "bot",
4620
+ certainty: "weak",
4621
+ // Saturation means the count stopped being able to grow, so the real spread is
4622
+ // wider than the one reported — the same argument breadth makes for itself.
4623
+ weight: queriesSaturated ? 0.25 : 0.15,
4624
+ botClass: "scraper",
4625
+ metadata: {
4626
+ distinctQueries,
4627
+ distinctPaths,
4628
+ variantsPerPath: Number(spread.toFixed(1)),
4629
+ totalRequests: total,
4630
+ saturated: queriesSaturated
4631
+ }
4632
+ };
4633
+ }
4634
+ };
4635
+ }
4636
+ var init_parameter_sweep = __esm({
4637
+ "src/detectors/parameter-sweep.ts"() {
4638
+ "use strict";
4639
+ init_state();
4640
+ }
4641
+ });
4642
+
4643
+ // src/detectors/transport-coherence.ts
4644
+ function transportCoherenceDetector(options = {}) {
4645
+ const checkLegacyHttp = options.legacyHttp ?? true;
4646
+ const minHeadRequests = options.minHeadRequests ?? 8;
4647
+ return {
4648
+ id: "transport-coherence",
4649
+ description: "Reads the HTTP version and the methods across a visit against the client the request claims to be",
4650
+ cost: "cheap",
4651
+ stage: "always",
4652
+ inspect(ctx) {
4653
+ if (!claimsBrowser(ctx.ua)) return void 0;
4654
+ const results = [];
4655
+ const version = ctx.facts.httpVersion;
4656
+ if (checkLegacyHttp && version !== void 0 && LEGACY_VERSIONS.has(version)) {
4657
+ results.push({
4658
+ detector: "transport-coherence",
4659
+ summary: `Client claims to be a browser but negotiated HTTP/${version}, which no shipping browser has offered in over a decade`,
4660
+ direction: "bot",
4661
+ certainty: "moderate",
4662
+ botClass: "impersonator",
4663
+ // One downgrading proxy in front of the application does this to every request
4664
+ // that passes through it, so this must count once rather than once per reason.
4665
+ family: "legacy-transport",
4666
+ metadata: { httpVersion: version, browser: ctx.ua.browser }
4667
+ });
4668
+ }
4669
+ const methods = ctx.state.methodsSeen;
4670
+ if (ctx.state.total >= minHeadRequests && methods.length > 0 && methods.every((method) => method === "HEAD")) {
4671
+ results.push({
4672
+ detector: "transport-coherence",
4673
+ summary: `Client claims to be a browser but has issued nothing but HEAD across ${ctx.state.total} requests`,
4674
+ direction: "bot",
4675
+ certainty: "moderate",
4676
+ botClass: "scraper",
4677
+ metadata: { requests: ctx.state.total, browser: ctx.ua.browser }
4678
+ });
4679
+ }
4680
+ return results.length > 0 ? results : void 0;
4681
+ }
4682
+ };
4683
+ }
4684
+ var LEGACY_VERSIONS;
4685
+ var init_transport_coherence = __esm({
4686
+ "src/detectors/transport-coherence.ts"() {
4687
+ "use strict";
4688
+ init_ua();
4689
+ LEGACY_VERSIONS = /* @__PURE__ */ new Set(["0.9", "1.0"]);
4690
+ }
4691
+ });
4692
+
4693
+ // src/detectors/probe-volume.ts
4694
+ function probeVolumeDetector(options = {}) {
4695
+ const minResponses = options.minResponses ?? 20;
4696
+ const missRatio = options.missRatio ?? 0.8;
4697
+ return {
4698
+ id: "probe-volume",
4699
+ description: "Reads the share of an actor's requests that were answered 404 or 410, where the application reports them",
4700
+ cost: "cheap",
4701
+ stage: "always",
4702
+ inspect(ctx) {
4703
+ const { responses, misses } = ctx.state;
4704
+ if (responses < minResponses) return void 0;
4705
+ const ratio = misses / responses;
4706
+ if (ratio < missRatio) return void 0;
4707
+ return {
4708
+ detector: "probe-volume",
4709
+ summary: `${misses} of this client's last ${responses} requests were answered "not found" (${(ratio * 100).toFixed(0)}%)`,
4710
+ direction: "bot",
4711
+ certainty: "moderate",
4712
+ botClass: "scanner",
4713
+ metadata: { responses, misses, missRatio: Number(ratio.toFixed(3)) }
4714
+ };
4715
+ }
4716
+ };
4717
+ }
4718
+ var init_probe_volume = __esm({
4719
+ "src/detectors/probe-volume.ts"() {
4720
+ "use strict";
4721
+ }
4722
+ });
4723
+
4724
+ // src/detectors/id-enumeration.ts
4725
+ function idEnumerationDetector(options = {}) {
4726
+ const minRequests = options.minRequests ?? 30;
4727
+ const density = options.density ?? 0.9;
4728
+ return {
4729
+ id: "id-enumeration",
4730
+ description: "Reports an actor covering a contiguous range of numeric identifiers under one path shape",
4731
+ cost: "cheap",
4732
+ stage: "always",
4733
+ inspect(ctx) {
4734
+ const walk = ctx.state.densestWalk();
4735
+ if (walk === void 0 || walk.count < minRequests) return void 0;
4736
+ if (walk.span < minRequests) return void 0;
4737
+ const covered = Math.min(1, walk.count / walk.span);
4738
+ if (covered < density) return void 0;
4739
+ return {
4740
+ detector: "id-enumeration",
4741
+ summary: `${walk.count} requests to ${walk.template} covering ${(covered * 100).toFixed(0)}% of a ${walk.span}-wide range of ids`,
4742
+ direction: "bot",
4743
+ certainty: "moderate",
4744
+ botClass: "scraper",
4745
+ metadata: { template: walk.template, requests: walk.count, span: walk.span, coverage: Number(covered.toFixed(3)) }
4746
+ };
4747
+ }
4748
+ };
4749
+ }
4750
+ var init_id_enumeration = __esm({
4751
+ "src/detectors/id-enumeration.ts"() {
4752
+ "use strict";
4753
+ }
4754
+ });
4755
+
4381
4756
  // src/detectors/crawler-verification.ts
4382
4757
  function crawlerVerificationDetector(options = {}) {
4383
4758
  const missingPtrIsForgery = options.treatMissingPtrAsForgery ?? true;
4384
4759
  const useRanges = options.useConfiguredRanges ?? true;
4760
+ const verifiers = options.verifiers ?? {};
4385
4761
  return {
4386
4762
  id: "crawler-verification",
4387
4763
  description: "Confirms or refutes a claimed crawler identity via forward-confirmed reverse DNS or published IP ranges",
@@ -4390,10 +4766,15 @@ function crawlerVerificationDetector(options = {}) {
4390
4766
  // crawler gives us nothing to verify, and resolving DNS for it would be pure cost.
4391
4767
  stage: "confirming",
4392
4768
  async inspect(ctx) {
4393
- const claims = ctx.signatureMatches.filter((signature) => signature.verification.kind !== "none");
4769
+ const claims = ctx.signatureMatches.filter((signature) => signature.verification.kind !== "none" || verifiers[signature.id] !== void 0);
4394
4770
  if (claims.length === 0) return void 0;
4395
4771
  const results = [];
4396
4772
  for (const claim of claims) {
4773
+ const own = await runVerifier(verifiers[claim.id], ctx, claim);
4774
+ if (own !== void 0) {
4775
+ results.push(own);
4776
+ continue;
4777
+ }
4397
4778
  const outcome = await verifyClaim(ctx, claim, { missingPtrIsForgery, useRanges });
4398
4779
  if (outcome) results.push(outcome);
4399
4780
  }
@@ -4401,6 +4782,39 @@ function crawlerVerificationDetector(options = {}) {
4401
4782
  }
4402
4783
  };
4403
4784
  }
4785
+ async function runVerifier(verifier, ctx, signature) {
4786
+ if (verifier === void 0) return void 0;
4787
+ let outcome;
4788
+ try {
4789
+ outcome = await verifier(ctx, signature);
4790
+ } catch {
4791
+ return void 0;
4792
+ }
4793
+ if (outcome === "unknown") return void 0;
4794
+ const via = signature.verification.kind === "proof" ? signature.verification.via : "a check you supplied";
4795
+ if (outcome === "verified") {
4796
+ return {
4797
+ detector: "crawler-verification",
4798
+ summary: `${signature.name} confirmed by your own verifier`,
4799
+ direction: "bot",
4800
+ certainty: "certain",
4801
+ botClass: "verified-bot",
4802
+ identity: signature.id,
4803
+ deterministicBasis: `Your application confirmed this identity through ${via} \u2014 a proof it holds and this library cannot see. It is your assertion about your own infrastructure, and it is treated the way the operator's word is treated everywhere else here.`,
4804
+ metadata: { signatureId: signature.id, method: "operator-verifier" }
4805
+ };
4806
+ }
4807
+ return {
4808
+ detector: "crawler-verification",
4809
+ summary: `Client claims to be ${signature.name}, and your own verifier refutes it`,
4810
+ direction: "bot",
4811
+ certainty: "certain",
4812
+ botClass: "impersonator",
4813
+ identity: signature.id,
4814
+ deterministicBasis: `The client named itself ${signature.name}, and the check you supplied for that identity \u2014 ${via} \u2014 returned a definite no. The refutation is yours; this library only reports it.`,
4815
+ metadata: { signatureId: signature.id, method: "operator-verifier" }
4816
+ };
4817
+ }
4404
4818
  async function verifyClaim(ctx, signature, flags) {
4405
4819
  if (signature.verification.kind === "ip-ranges") {
4406
4820
  if (!flags.useRanges) return void 0;
@@ -5502,7 +5916,7 @@ var init_ua_coherence = __esm({
5502
5916
  });
5503
5917
 
5504
5918
  // src/detectors/index.ts
5505
- function defaultDetectors() {
5919
+ function defaultDetectors(options = {}) {
5506
5920
  return [
5507
5921
  // Identity first: a self-declaration or a verified crawler settles the question
5508
5922
  // outright, and the engine can then skip everything that would only add nuance.
@@ -5516,16 +5930,20 @@ function defaultDetectors() {
5516
5930
  clientHintsDetector(),
5517
5931
  fetchMetadataDetector(),
5518
5932
  acceptSignatureDetector(),
5933
+ transportCoherenceDetector(),
5519
5934
  headerOrderDetector(),
5520
5935
  // Behaviour across requests.
5521
5936
  rateAnomalyDetector(),
5522
5937
  cadenceDetector(),
5523
5938
  crawlBreadthDetector(),
5939
+ parameterSweepDetector(),
5940
+ probeVolumeDetector(),
5941
+ idEnumerationDetector(),
5524
5942
  sessionIntegrityDetector(),
5525
5943
  // The other side of the argument: what a real browsing session looks like.
5526
5944
  browsingCoherenceDetector(),
5527
5945
  // Confirming stage: only runs when an identity was claimed.
5528
- crawlerVerificationDetector()
5946
+ crawlerVerificationDetector(options.crawlerVerification ?? {})
5529
5947
  ];
5530
5948
  }
5531
5949
  var init_detectors = __esm({
@@ -5536,6 +5954,10 @@ var init_detectors = __esm({
5536
5954
  init_cadence();
5537
5955
  init_client_hints();
5538
5956
  init_crawl_breadth();
5957
+ init_parameter_sweep();
5958
+ init_transport_coherence();
5959
+ init_probe_volume();
5960
+ init_id_enumeration();
5539
5961
  init_crawler_verification();
5540
5962
  init_fetch_metadata();
5541
5963
  init_header_integrity();
@@ -5789,7 +6211,7 @@ function validateRules(rules) {
5789
6211
  }
5790
6212
  function resolveConfig(config = {}) {
5791
6213
  const warnings = [];
5792
- const detectors2 = config.detectors ? [...config.detectors] : [...defaultDetectors(), ...config.extraDetectors ?? []];
6214
+ const detectors2 = config.detectors ? [...config.detectors] : [...defaultDetectors(config.crawlerVerification === void 0 ? {} : { crawlerVerification: config.crawlerVerification }), ...config.extraDetectors ?? []];
5793
6215
  const seen = /* @__PURE__ */ new Set();
5794
6216
  for (const detector of detectors2) {
5795
6217
  if (seen.has(detector.id)) {
@@ -6387,6 +6809,11 @@ function assessmentFromEntry(entry) {
6387
6809
  key: entry.actor,
6388
6810
  requests: entry.actorStats.requests,
6389
6811
  distinctPaths: entry.actorStats.distinctPaths,
6812
+ distinctQueries: 0,
6813
+ queriesSaturated: false,
6814
+ methodsSeen: ["GET"],
6815
+ responses: 0,
6816
+ misses: 0,
6390
6817
  firstSeen: entry.actorStats.firstSeen,
6391
6818
  lastSeen: entry.at,
6392
6819
  ...entry.actorStats.sinceLastMs !== void 0 ? { sinceLastMs: entry.actorStats.sinceLastMs } : {},
@@ -6435,7 +6862,7 @@ var CLIENT_SCRIPT;
6435
6862
  var init_client_generated = __esm({
6436
6863
  "src/dashboard/client.generated.ts"() {
6437
6864
  "use strict";
6438
- CLIENT_SCRIPT = '"use strict";\n(() => {\n // src/dashboard/client/css.ts\n function cssEscape(value) {\n return typeof CSS !== "undefined" && typeof CSS.escape === "function" ? CSS.escape(value) : String(value).replace(/[^\\w-]/g, "\\\\$&");\n }\n\n // src/dashboard/client/dom.ts\n function el(tag, className, value) {\n const node = document.createElement(tag);\n if (className !== void 0 && className !== null && className !== "") node.className = className;\n if (value !== void 0 && value !== null) node.textContent = String(value);\n return node;\n }\n function svgEl(name, attributes = {}) {\n const node = document.createElementNS("http://www.w3.org/2000/svg", name);\n for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, String(value));\n return node;\n }\n function svgText(attributes, text) {\n const node = svgEl("text", attributes);\n node.textContent = String(text);\n return node;\n }\n function clear(node) {\n while (node.firstChild) node.removeChild(node.firstChild);\n }\n var root = typeof document === "undefined" ? void 0 : document;\n var themeHost = typeof document === "undefined" ? void 0 : document.documentElement;\n var embedded = false;\n function isEmbedded() {\n return embedded;\n }\n function eventTarget() {\n return embedded ? root : globalThis;\n }\n function rootNode() {\n return root;\n }\n function themeElement() {\n return themeHost;\n }\n function $(id) {\n const node = root.querySelector(`#${cssEscape(id)}`);\n if (node === null || node === void 0) throw new Error(`dashboard: no element #${id}`);\n return node;\n }\n function byId(id) {\n return $(id);\n }\n function css(name) {\n return getComputedStyle(themeHost).getPropertyValue(name).trim();\n }\n var sequence = 0;\n function label(text, control) {\n const node = document.createElement("label");\n node.textContent = text;\n const single = Array.isArray(control) ? control.length === 1 ? control[0] : void 0 : control;\n if (single !== void 0 && /^(input|select|textarea)$/i.test(single.tagName)) {\n if (single.id === "") single.id = `field-${++sequence}`;\n node.htmlFor = single.id;\n } else {\n const group = Array.isArray(control) ? control : [control];\n for (const node_ of group) if (!node_.hasAttribute("aria-label")) node_.setAttribute("aria-label", text);\n }\n return node;\n }\n\n // src/dashboard/client/boot.ts\n var raw = globalThis.__BOOTSTRAP__;\n var BOOT = raw ?? {\n base: "",\n title: "bothandlerjs",\n allowReset: false,\n allowEdit: false,\n allowGuardEdit: false,\n allowActing: false,\n peers: [],\n sections: { feed: true, evidence: true, actors: true, registry: true, tester: true, statistics: true, audit: true, notices: true, changes: true, policy: true, guard: true, robots: true, ranges: true },\n links: []\n };\n var API = BOOT.base;\n var SECTIONS = BOOT.sections;\n\n // src/dashboard/client/app.ts\n var app = {\n draw: () => {\n },\n drawNow: () => {\n },\n showTab: () => {\n },\n syncUrl: () => {\n }\n };\n function toast(kind, title, detail = "") {\n const node = el("div", `toast ${kind}`);\n node.appendChild(el("b", null, title));\n if (detail !== "") node.appendChild(el("span", null, detail));\n const host = rootNode().querySelector("#toasts");\n if (host === null) return;\n host.appendChild(node);\n setTimeout(() => node.remove(), 6e3);\n }\n function download(text, filename, type) {\n const blob = new Blob([text], { type });\n const url = URL.createObjectURL(blob);\n const anchor = el("a");\n anchor.href = url;\n anchor.download = filename;\n anchor.click();\n setTimeout(() => URL.revokeObjectURL(url), 1e3);\n }\n function today() {\n return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);\n }\n\n // src/dashboard/client/outcome.ts\n var DENY = /* @__PURE__ */ new Set(["block", "drop", "redirect"]);\n var MITIGATE = /* @__PURE__ */ new Set(["challenge", "rate-limit", "delay"]);\n function outcome(entry) {\n return actionKind(entry.action);\n }\n function actionKind(action) {\n if (action === void 0) return "pending";\n if (DENY.has(action)) return "deny";\n if (MITIGATE.has(action)) return "mitigate";\n return "allow";\n }\n function verdictBadge(entry) {\n if (entry.verdict === "verified-bot" || entry.verdict === "confirmed-bot") return ["b-proven", entry.verdict];\n if (entry.verdict === "suspected-bot") return ["b-suspected", "suspected-bot"];\n if (entry.verdict === "human") return ["b-human", "human"];\n return ["b-unknown", entry.bypass !== void 0 ? `skipped \xB7 ${entry.bypass}` : "unknown"];\n }\n function provenBots(verdicts) {\n return (verdicts["confirmed-bot"] ?? 0) + (verdicts["verified-bot"] ?? 0);\n }\n\n // src/dashboard/client/query.ts\n var FIELDS = {\n path: "path",\n url: "path",\n actor: "actor",\n ip: "actor",\n ua: "userAgent",\n useragent: "userAgent",\n agent: "userAgent",\n verdict: "verdict",\n action: "action",\n rule: "rule",\n detector: "detector",\n identity: "identity",\n method: "method",\n class: "botClass",\n botclass: "botClass",\n id: "requestId",\n request: "requestId",\n bypass: "bypass",\n score: "score",\n outcome: "outcome",\n certain: "certain"\n };\n var NUMERIC = /* @__PURE__ */ new Set(["score"]);\n function tokenize(input) {\n const tokens = [];\n let current = "";\n let quoted = false;\n for (const character of input) {\n if (character === \'"\') {\n quoted = !quoted;\n continue;\n }\n if (!quoted && /\\s/.test(character)) {\n if (current !== "") tokens.push(current);\n current = "";\n continue;\n }\n current += character;\n }\n if (current !== "") tokens.push(current);\n return tokens;\n }\n function parseQuery(input) {\n const terms = [];\n for (const token of tokenize(input.trim())) {\n const negated = token.startsWith("-") || token.startsWith("!");\n const body = negated ? token.slice(1) : token;\n if (body === "") continue;\n const colon = body.indexOf(":");\n const name = colon === -1 ? "" : body.slice(0, colon).toLowerCase();\n const field2 = FIELDS[name];\n if (colon === -1 || field2 === void 0) {\n terms.push({ field: void 0, value: body.toLowerCase(), negated });\n continue;\n }\n let value = body.slice(colon + 1).toLowerCase();\n let compare;\n if (NUMERIC.has(field2)) {\n compare = value.startsWith(">") ? ">" : value.startsWith("<") ? "<" : "=";\n if (compare !== "=") value = value.slice(1);\n }\n if (value === "") continue;\n terms.push({ field: field2, value, negated, compare });\n }\n return terms;\n }\n function searchableText(entry) {\n const parts = [\n entry.method,\n entry.path,\n entry.actor,\n entry.userAgent,\n entry.verdict,\n entry.botClass,\n entry.identity ?? "",\n entry.action ?? "",\n entry.rule ?? "",\n entry.requestId\n ];\n for (const item of entry.evidence) parts.push(item.detector, item.summary);\n return parts.join(" ").toLowerCase();\n }\n function fieldValue(entry, field2) {\n switch (field2) {\n case "path":\n return entry.path;\n case "actor":\n return entry.actor;\n case "userAgent":\n return entry.userAgent;\n case "verdict":\n return entry.verdict;\n case "action":\n return entry.action ?? "";\n case "rule":\n return entry.rule ?? "";\n case "identity":\n return entry.identity ?? "";\n case "method":\n return entry.method;\n case "botClass":\n return entry.botClass;\n case "requestId":\n return entry.requestId;\n case "bypass":\n return entry.bypass ?? "";\n case "outcome":\n return outcome(entry);\n case "certain":\n return String(entry.certain);\n case "detector":\n return entry.evidence.map((item) => item.detector).join(" ");\n default:\n return "";\n }\n }\n function matchesTerm(term, entry, haystack) {\n if (term.field === void 0) return haystack.includes(term.value);\n if (term.field === "score") {\n const wanted = Number(term.value);\n if (Number.isNaN(wanted)) return false;\n if (term.compare === ">") return entry.score > wanted;\n if (term.compare === "<") return entry.score < wanted;\n return entry.score === wanted;\n }\n return fieldValue(entry, term.field).toLowerCase().includes(term.value);\n }\n function matchesQuery(terms, entry, haystack) {\n for (const term of terms) {\n if (matchesTerm(term, entry, haystack) === term.negated) return false;\n }\n return true;\n }\n function matchesFilter(filter, entry) {\n switch (filter) {\n case "proven":\n return entry.certain;\n case "suspected":\n return entry.verdict === "suspected-bot";\n case "human":\n return entry.verdict === "human";\n case "guard":\n return entry.downgradedFrom !== void 0;\n case "deny":\n return outcome(entry) === "deny";\n case "mitigate":\n return outcome(entry) === "mitigate";\n case "allow":\n return outcome(entry) === "allow";\n default:\n return true;\n }\n }\n\n // src/dashboard/client/store.ts\n var MAX_ROWS = 1e3;\n var FEED_LIMIT = 300;\n var state = {\n rows: [],\n byId: /* @__PURE__ */ new Map(),\n snapshot: void 0,\n policy: void 0,\n actors: [],\n actorsTracked: 0,\n paused: false,\n filter: "all",\n search: "",\n terms: [],\n tab: "live",\n open: /* @__PURE__ */ new Set(),\n actor: void 0,\n rangeMs: 3e5,\n scoreScope: "run",\n editorRules: [],\n editorDirty: false,\n editorMode: "gui",\n guardDirty: false,\n bufferedWhilePaused: 0,\n laggedDrops: 0\n };\n function ingest(entry) {\n const existing = state.byId.get(entry.requestId);\n if (existing !== void 0) {\n existing.entry = entry;\n existing.rev++;\n existing.text = void 0;\n return;\n }\n const row = { entry, rev: 0 };\n state.byId.set(entry.requestId, row);\n state.rows.push(row);\n if (state.rows.length > MAX_ROWS) {\n for (const dropped of state.rows.splice(0, state.rows.length - MAX_ROWS)) {\n state.byId.delete(dropped.entry.requestId);\n state.open.delete(dropped.entry.requestId);\n }\n }\n }\n function clearFeed() {\n state.rows = [];\n state.byId = /* @__PURE__ */ new Map();\n state.open.clear();\n state.actor = void 0;\n state.bufferedWhilePaused = 0;\n state.laggedDrops = 0;\n }\n function setSearch(value) {\n state.search = value;\n state.terms = parseQuery(value);\n }\n function textOf(row) {\n if (row.text === void 0) row.text = searchableText(row.entry);\n return row.text;\n }\n function matches(row) {\n return matchesFilter(state.filter, row.entry) && matchesQuery(state.terms, row.entry, textOf(row));\n }\n function visibleRows(limit = FEED_LIMIT) {\n const shown = [];\n for (let i = state.rows.length - 1; i >= 0 && shown.length < limit; i--) {\n const row = state.rows[i];\n if (row !== void 0 && matches(row)) shown.push(row);\n }\n return shown;\n }\n function matchingCount() {\n let count = 0;\n for (const row of state.rows) if (matches(row)) count++;\n return count;\n }\n function oldestAt() {\n return state.rows[0]?.entry.at;\n }\n function bump(counter, key) {\n counter.set(key, (counter.get(key) ?? 0) + 1);\n }\n function aggregate(rows) {\n const totals = {\n detectors: /* @__PURE__ */ new Map(),\n actors: /* @__PURE__ */ new Map(),\n identities: /* @__PURE__ */ new Map(),\n paths: /* @__PURE__ */ new Map(),\n deniedPaths: /* @__PURE__ */ new Map(),\n guardStops: /* @__PURE__ */ new Map(),\n ruleHits: /* @__PURE__ */ new Map(),\n bypassed: /* @__PURE__ */ new Map()\n };\n for (const { entry } of rows) {\n for (const item of entry.evidence) bump(totals.detectors, item.detector);\n bump(totals.actors, entry.actor);\n if (entry.bypass !== void 0) {\n bump(totals.bypassed, `${entry.path} (${entry.bypass})`);\n continue;\n }\n bump(totals.paths, entry.path);\n if (entry.identity !== void 0 && entry.identity !== "") {\n bump(totals.identities, `${entry.identity} \xB7 ${entry.verdict === "verified-bot" ? "verified" : "claimed"}`);\n }\n if (outcome(entry) === "deny") bump(totals.deniedPaths, entry.path);\n if (entry.downgradedFrom !== void 0 && entry.rule !== void 0) bump(totals.guardStops, `${entry.rule} \u2192 ${entry.downgradedFrom}`);\n if (entry.rule !== void 0) bump(totals.ruleHits, entry.rule);\n }\n return totals;\n }\n\n // src/dashboard/client/api.ts\n async function getJson(path) {\n const response = await fetch(API + path);\n if (!response.ok) throw new Error(await errorFrom(response));\n return await response.json();\n }\n async function postJson(path, body) {\n const response = await fetch(API + path, {\n method: "POST",\n headers: { "content-type": "application/json" },\n body: JSON.stringify(body)\n });\n const data = await response.json().catch(() => ({}));\n return response.ok ? { ok: true, data } : { ok: false, data, error: String(data.error ?? "The server refused this.") };\n }\n async function errorFrom(response) {\n const body = await response.json().catch(() => ({}));\n return body.error ?? `${response.status} ${response.statusText}`;\n }\n\n // src/dashboard/client/actions.ts\n var armed = 0;\n function isConfirming() {\n return armed > 0;\n }\n function actorActions(key, after) {\n if (!BOOT.allowActing) return [];\n const forget = el("button", null, "Forget");\n forget.title = "Discard this actor\'s history \u2014 the cure for a false positive that has stuck";\n forget.addEventListener("click", () => {\n void act({ key, action: "forget" }, `Forgot ${key}`, "Its next request is assessed as a first request.", after);\n });\n const clear2 = el("button", null, "Clear as human");\n clear2.title = "Grant this actor human clearance for an hour, as though it had solved a challenge";\n clear2.addEventListener("click", () => {\n void act({ key, action: "clear", forMs: 60 * 6e4 }, `Cleared ${key}`, "Held as human for an hour, then reassessed.", after);\n });\n return [confirmingButton("Allowlist", `Allowlist ${key} \u2014 it stops being assessed at all`, () => allowlist(key, after)), forget, clear2];\n }\n function confirmingButton(label2, confirmation, run2) {\n const button = el("button", "danger", label2);\n let pending2 = false;\n let timer2;\n const disarm = () => {\n if (!pending2) return;\n pending2 = false;\n armed--;\n button.textContent = label2;\n button.className = "danger";\n };\n button.addEventListener("click", () => {\n if (pending2) {\n if (timer2 !== void 0) clearTimeout(timer2);\n disarm();\n run2();\n return;\n }\n pending2 = true;\n armed++;\n button.textContent = confirmation;\n button.className = "danger primary";\n timer2 = setTimeout(disarm, 5e3);\n });\n return button;\n }\n async function allowlist(key, after) {\n const result = await postJson("/api/ranges", { name: "allowlist", add: [key] });\n if (!result.ok) {\n toast("bad", "Not allowlisted", result.error ?? "");\n return;\n }\n toast("warn", `Allowlisted ${key}`, "Requests from it are no longer assessed at all.");\n after();\n }\n async function act(body, title, detail, after) {\n const result = await postJson("/api/actor", body);\n if (!result.ok) {\n toast("bad", "Refused", result.error ?? "");\n return;\n }\n toast("ok", title, detail);\n after();\n }\n\n // src/dashboard/client/format.ts\n var numbers = new Intl.NumberFormat();\n function n(value) {\n return numbers.format(value ?? 0);\n }\n function pct(part, whole) {\n return whole > 0 ? `${Math.round(part / whole * 100)}%` : "\u2014";\n }\n function ms(value) {\n return value >= 10 ? `${value.toFixed(1)}ms` : `${value.toFixed(2)}ms`;\n }\n function uptime(milliseconds) {\n const seconds = Math.max(0, Math.round(milliseconds / 1e3));\n if (seconds < 90) return `${seconds}s`;\n const minutes = Math.round(seconds / 60);\n if (minutes < 90) return `${minutes}m`;\n return `${Math.round(minutes / 60)}h`;\n }\n function rangeLabel(milliseconds) {\n const seconds = Math.round(milliseconds / 1e3);\n if (seconds < 90) return `${seconds}s`;\n const minutes = Math.round(seconds / 60);\n return minutes < 90 ? `${minutes} min` : `${Math.round(minutes / 60)}h`;\n }\n function clockTime(at) {\n return new Date(at).toLocaleTimeString();\n }\n function windowLabel(count, oldestAt2, now) {\n if (count === 0) return "this window \xB7 empty";\n const span = oldestAt2 === void 0 ? 0 : Math.max(0, now - oldestAt2);\n return `last ${n(count)} requests \xB7 ${rangeLabel(span)}`;\n }\n\n // src/dashboard/client/bars.ts\n function drawBars(target, rows, emptyText) {\n clear(target);\n const filtered = [...rows].filter(([, value]) => value > 0);\n if (filtered.length === 0) {\n target.appendChild(el("div", "note", emptyText));\n return;\n }\n filtered.sort((a, b) => b[1] - a[1]);\n const max = filtered[0]?.[1] ?? 1;\n for (const [label2, value] of filtered.slice(0, 14)) {\n const bar = el("div", "bar");\n const track = el("div", "track");\n const fill = el("div", "fill");\n fill.style.width = `${Math.max(2, Math.round(value / max * 100))}%`;\n track.appendChild(fill);\n track.appendChild(el("div", "lbl", label2));\n bar.appendChild(track);\n bar.appendChild(el("div", "v tnum", n(value)));\n target.appendChild(bar);\n }\n }\n function pairs(record) {\n return Object.entries(record ?? {});\n }\n\n // src/dashboard/client/actor.ts\n function initActor() {\n if (!SECTIONS.actors) return;\n $("actor-close").addEventListener("click", closeActor);\n }\n function openActor(key) {\n state.actor = key;\n app.drawNow();\n $("actor-panel").scrollIntoView({ block: "nearest" });\n }\n function closeActor() {\n state.actor = void 0;\n app.drawNow();\n }\n function drawActor() {\n if (!SECTIONS.actors) return;\n const panel = $("actor-panel");\n if (state.actor === void 0) {\n panel.hidden = true;\n return;\n }\n panel.hidden = false;\n $("actor-key").textContent = state.actor;\n const mine = state.rows.filter((row) => row.entry.actor === state.actor).map((row) => row.entry);\n const latest = mine[mine.length - 1];\n const stats = latest?.actorStats;\n const gaps = [];\n for (let i = 1; i < mine.length; i++) gaps.push(mine[i].at - mine[i - 1].at);\n const meanGap = gaps.length > 0 ? gaps.reduce((a, b) => a + b, 0) / gaps.length : 0;\n const box = $("actor-stats");\n clear(box);\n const rows = [\n ["In this window", `${n(mine.length)} requests`],\n ["Engine sees", stats !== void 0 ? `${n(stats.requests)} requests, ${n(stats.distinctPaths)} distinct paths` : "\u2014"],\n ["Prior confirmations", stats !== void 0 ? n(stats.priorConfirmations) : "\u2014"],\n ["Holds clearance", stats !== void 0 ? stats.cleared ? "yes" : "no" : "\u2014"],\n ["First seen", stats !== void 0 ? clockTime(stats.firstSeen) : "\u2014"],\n ["Mean gap", gaps.length > 0 ? `${Math.round(meanGap)}ms over ${n(gaps.length)} gaps` : "one request only"]\n ];\n for (const [key, value] of rows) {\n box.appendChild(el("dt", null, key));\n box.appendChild(el("dd", null, value));\n }\n const verdicts = /* @__PURE__ */ new Map();\n const actions = /* @__PURE__ */ new Map();\n for (const entry of mine) {\n verdicts.set(entry.verdict, (verdicts.get(entry.verdict) ?? 0) + 1);\n if (entry.action !== void 0) actions.set(entry.action, (actions.get(entry.action) ?? 0) + 1);\n }\n drawBars($("actor-mix"), [...verdicts, ...actions], "Nothing yet.");\n const bar = $("actor-actions");\n clear(bar);\n const buttons = actorActions(state.actor, () => app.drawNow());\n bar.hidden = buttons.length === 0;\n for (const button of buttons) bar.appendChild(button);\n }\n\n // src/dashboard/client/replay.ts\n function replayLine(entry) {\n const headers = {};\n for (const [name, value] of entry.headers ?? []) headers[name] = value;\n const query = Object.keys(entry.query).map((name) => `${encodeURIComponent(name)}=${encodeURIComponent(entry.query[name] ?? "")}`).join("&");\n return JSON.stringify({\n method: entry.method,\n url: entry.path + (query === "" ? "" : `?${query}`),\n headers,\n ip: entry.actor,\n timestamp: new Date(entry.at).toISOString(),\n protocol: entry.protocol ?? "https",\n httpVersion: entry.httpVersion ?? "1.1"\n });\n }\n function replayFile(entries) {\n return entries.map(replayLine).join("\\n");\n }\n function corpusCase(entry) {\n const headers = (entry.headers ?? []).map((pair) => ` [${JSON.stringify(pair[0])}, ${JSON.stringify(pair[1])}]`).join(",\\n");\n return [\n "bot({",\n ` id: ${JSON.stringify(`case-${entry.requestId.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`)},`,\n ` title: ${JSON.stringify(`${entry.method} ${entry.path} from ${entry.userAgent.slice(0, 60)}`)},`,\n \' audience: "unwanted-bot", // human | benign-bot | declared-bot | unwanted-bot | hostile | infrastructure\',\n \' category: "observed",\',\n ` provenance: "Captured from the live dashboard on ${new Date(entry.at).toISOString().slice(0, 10)}",`,\n " requests: [",\n " {",\n " headers: [",\n headers,\n " ],",\n ` protocol: ${JSON.stringify(entry.protocol ?? "https")},`,\n ` httpVersion: ${JSON.stringify(entry.httpVersion ?? "1.1")},`,\n ` path: ${JSON.stringify(entry.path)},`,\n " },",\n " ],",\n ` expect: { verdict: ${JSON.stringify(entry.verdict)}, certain: ${String(entry.certain)} },`,\n "}),"\n ].join("\\n");\n }\n\n // src/dashboard/client/result.ts\n function showResult(kind, build) {\n const box = $("policy-result");\n box.hidden = false;\n box.className = `result ${kind}`;\n clear(box);\n build(box);\n }\n function renderPreview(preview, notes = []) {\n showResult(preview.newDenials > 0 ? "warn" : "ok", (box) => {\n const head = el("div");\n head.appendChild(el("b", null, `${n(preview.changed)} of ${n(preview.evaluated)} requests would be treated differently.`));\n box.appendChild(head);\n for (const note of notes) box.appendChild(el("div", "ev-meta", note));\n if (preview.newDenials > 0) {\n box.appendChild(el("div", null, `${n(preview.newDenials)} request(s) that are served today would be denied. Read the samples before applying this.`));\n }\n for (const warning of preview.warnings) box.appendChild(el("div", "ev-meta", warning));\n const dead = preview.ruleHits.filter((row) => row.hits === 0 && row.rule !== "default");\n if (dead.length > 0) box.appendChild(el("div", "ev-meta", `Never matched in this window: ${dead.map((row) => row.rule).join(", ")}`));\n if (preview.samples.length > 0) {\n const table = el("table", "diff");\n const head2 = el("tr");\n for (const label2 of ["Request", "Now", "Would be"]) head2.appendChild(el("th", null, label2));\n table.appendChild(head2);\n for (const sample of preview.samples) {\n const row = el("tr");\n const what = el("td");\n what.appendChild(el("div", "mono", sample.path));\n what.appendChild(el("div", "ev-meta", `${sample.verdict} \xB7 ${sample.userAgent.slice(0, 48)}`));\n row.appendChild(what);\n const from = el("td", "from");\n from.appendChild(el("div", null, sample.from));\n from.appendChild(el("div", "ev-meta", sample.fromRule));\n row.appendChild(from);\n const kind = sample.to === "block" || sample.to === "drop" || sample.to === "redirect" ? "deny" : sample.to === "allow" ? "allow" : "";\n const to = el("td", `to ${kind}`);\n to.appendChild(el("div", null, sample.to));\n to.appendChild(el("div", "ev-meta", sample.toRule));\n row.appendChild(to);\n table.appendChild(row);\n }\n box.appendChild(table);\n } else if (preview.evaluated === 0) {\n box.appendChild(el("div", "ev-meta", "No traffic in the window to preview against \u2014 send some requests first."));\n }\n });\n }\n\n // src/dashboard/client/guard.ts\n var draft;\n var MODE_NOTES = {\n strict: "A terminal action survives only on proven evidence. Nothing is ever denied on a guess. This is the default, and it is the claim this library makes about itself.",\n balanced: "A terminal action also survives on a probabilistic verdict that clears the score threshold with at least two independent strong signals. Real people do trip two signals \u2014 a hardened browser behind a corporate proxy is the usual pair \u2014 so this setting will eventually deny somebody who should have been served.",\n aggressive: "The guard is off. Every rule does exactly what it says, on proof or on suspicion alike, and the people it turns away first are the ones with the most unusual and most legitimate setups."\n };\n function drawGuard() {\n if (!SECTIONS.guard) return;\n const document_ = state.policy;\n const snapshot = state.snapshot;\n if (snapshot === void 0) return;\n const editable = document_?.guardEditable === true;\n const panel = $("stat-policy");\n clear(panel);\n if (!editable || document_ === void 0) {\n const rows = [\n ["False-positive policy", snapshot.policy.falsePositivePolicy],\n ["Fallback when the guard stops a rule", snapshot.policy.fallbackAction],\n ["Terminal score threshold", String(snapshot.policy.terminalScoreThreshold)],\n ["Suspect threshold", String(snapshot.policy.suspectThreshold)],\n ["Action when no rule matches", snapshot.policy.defaultAction],\n ["Challenge configured", snapshot.policy.challengeEnabled ? "yes" : "no"],\n ["Range sets", snapshot.ranges.length === 0 ? "none" : snapshot.ranges.map((range) => `${range.name} (${range.size})`).join(", ")]\n ];\n for (const [key, value] of rows) {\n const line = el("div", "stat-row");\n line.appendChild(el("span", "k", key));\n line.appendChild(el("span", "v", value));\n panel.appendChild(line);\n }\n $("guard-mode").textContent = "not editable here";\n $("guard-note").textContent = "These are fixed at construction on this dashboard. It can change which rules exist; it cannot change how far a rule is allowed to go, because relaxing that is the one edit that can start denying people. Enable it deliberately with controls: { editGuard: true }.";\n return;\n }\n if (draft === void 0) draft = { ...document_.guard };\n const vocabulary = document_.vocabulary;\n $("guard-mode").textContent = state.guardDirty ? "unsaved changes" : "editable";\n $("guard-note").textContent = "Changing these changes what every rule is allowed to do, on the next request. Preview it against the traffic in the window first \u2014 that is the only view you get of who it would start turning away.";\n panel.appendChild(\n guardField(\n // "Mode" rather than "Guard": the panel is already called that, and a field\n // repeating its own panel\'s name reads as a heading rather than as a control.\n "Mode",\n segmented(\n vocabulary.falsePositivePolicies.map((mode) => [mode, mode]),\n draft.falsePositivePolicy,\n (value) => {\n setDraft({ falsePositivePolicy: value });\n drawGuard();\n }\n )\n )\n );\n panel.appendChild(el("div", "note guard-explains", MODE_NOTES[draft.falsePositivePolicy] ?? ""));\n panel.appendChild(\n guardField(\n "Fallback",\n select(vocabulary.fallbackActions, draft.fallbackAction, (value) => setDraft({ fallbackAction: value })),\n "what a stopped rule becomes \u2014 the terminal actions are absent because a terminal fallback would deny the request the guard just protected"\n )\n );\n panel.appendChild(\n guardField(\n "Default action",\n select(vocabulary.actions, draft.defaultAction, (value) => setDraft({ defaultAction: value })),\n "when no rule matches"\n )\n );\n panel.appendChild(\n guardField(\n "Terminal score",\n number(draft.terminalScoreThreshold, (value) => setDraft({ terminalScoreThreshold: value })),\n "balanced mode only: the score a probabilistic verdict must clear"\n )\n );\n panel.appendChild(\n guardField(\n "Suspect at",\n number(draft.suspectThreshold, (value) => setDraft({ suspectThreshold: value })),\n "the score at which a request becomes suspected-bot"\n )\n );\n const bar = el("div", "bar-actions");\n const preview = el("button", null, "Preview");\n preview.addEventListener("click", () => {\n void previewGuard();\n });\n const apply = el("button", "primary", "Apply");\n apply.addEventListener("click", () => {\n void applyGuard();\n });\n const revert = el("button", null, "Revert");\n revert.addEventListener("click", () => {\n draft = { ...document_.guard };\n state.guardDirty = false;\n drawGuard();\n });\n bar.appendChild(preview);\n bar.appendChild(apply);\n bar.appendChild(revert);\n panel.appendChild(bar);\n }\n function setDraft(change) {\n if (draft === void 0) return;\n draft = { ...draft, ...change };\n state.guardDirty = true;\n $("guard-mode").textContent = "unsaved changes";\n }\n function resetGuardDraft() {\n draft = void 0;\n state.guardDirty = false;\n }\n function liveRules() {\n return (state.policy?.rules ?? []).filter((row) => row.editable && row.rule !== void 0).map((row) => row.rule);\n }\n async function previewGuard() {\n if (draft === void 0) return;\n const result = await postJson("/api/policy/preview", { rules: liveRules(), guard: draft });\n if (!result.ok) {\n showResult("bad", (box) => box.appendChild(el("div", null, result.error ?? "")));\n toast("bad", "Refused", result.error ?? "");\n return;\n }\n renderPreview(result.data, state.editorDirty ? ["Previewed against the rules currently in force, not the unsaved edits in the editor."] : []);\n app.showTab("policy");\n }\n async function applyGuard() {\n if (draft === void 0) return;\n const result = await postJson("/api/guard", draft);\n if (!result.ok) {\n showResult("bad", (box) => box.appendChild(el("div", null, result.error ?? "")));\n toast("bad", "Guard unchanged", result.error ?? "");\n return;\n }\n state.guardDirty = false;\n draft = { ...result.data.guard };\n if (state.policy !== void 0) state.policy.guard = { ...result.data.guard };\n toast("ok", "Guard changed", `${result.data.guard.falsePositivePolicy}, falling back to ${result.data.guard.fallbackAction}.`);\n showResult("warn", (box) => {\n box.appendChild(el("div", null, "The guard changed. It applies from the next request, and it is in the notices panel and in your logs."));\n if (result.data.guard.falsePositivePolicy !== "strict") {\n box.appendChild(\n el(\n "div",\n "ev-meta",\n "Requests can now be denied without proof. The guard-stop count is the series to watch: every stop that no longer happens is a request that used to be recoverable and is not any more."\n )\n );\n }\n });\n app.draw();\n }\n function guardField(name, control, hint) {\n const row = el("div", "field");\n row.appendChild(label(name, control));\n const right = el("div", "field-row");\n right.appendChild(control);\n if (hint !== void 0) right.appendChild(el("span", "hint", hint));\n row.appendChild(right);\n return row;\n }\n function segmented(options, value, onChange) {\n const box = el("div", "seg");\n for (const [label2, option] of options) {\n const button = el("button", null, label2);\n button.type = "button";\n button.setAttribute("aria-pressed", String(option === value));\n button.addEventListener("click", () => onChange(option));\n box.appendChild(button);\n }\n return box;\n }\n function select(options, value, onChange) {\n const node = el("select");\n for (const option of options) {\n const item = el("option", null, option);\n item.value = option;\n if (option === value) item.selected = true;\n node.appendChild(item);\n }\n node.addEventListener("change", () => onChange(node.value));\n return node;\n }\n function number(value, onChange) {\n const input = el("input");\n input.type = "number";\n input.min = "1";\n input.max = "100";\n input.value = String(value);\n input.addEventListener("input", () => {\n if (input.value !== "") onChange(Number(input.value));\n });\n return input;\n }\n\n // src/dashboard/client/ranges.ts\n var sets = [];\n async function loadRanges() {\n if (!SECTIONS.ranges) return;\n try {\n const body = await getJson("/api/ranges");\n sets = body.ranges;\n drawRanges();\n } catch {\n }\n }\n function drawRanges() {\n if (!SECTIONS.ranges) return;\n const body = $("ranges-body");\n clear(body);\n $("ranges-mode").textContent = BOOT.allowActing ? "editable" : "read-only";\n $("ranges-note").textContent = BOOT.allowActing ? "An address on the allowlist is not judged leniently \u2014 it is not judged at all. Detection does not run on it, no evidence is produced, and no rule sees it." : "These come from the code that constructed the handler. Enable controls.editRanges to add an address from here.";\n if (sets.length === 0) {\n body.appendChild(el("div", "note", "No range sets are configured. Add an address to the allowlist and one appears."));\n }\n for (const set of sets) {\n const block = el("div", "rangeset");\n const heading = el("h3");\n heading.appendChild(document.createTextNode(set.name));\n heading.appendChild(el("span", null, `${n(set.size)} entr${set.size === 1 ? "y" : "ies"}`));\n block.appendChild(heading);\n const list = el("div", "cidrs");\n for (const entry of set.entries) {\n const chip = el("span", BOOT.allowActing ? "cidr" : "cidr readonly");\n chip.appendChild(document.createTextNode(entry));\n if (BOOT.allowActing) {\n const remove = el("button", null, "\xD7");\n remove.title = `Remove ${entry} from ${set.name}`;\n remove.setAttribute("aria-label", `Remove ${entry} from ${set.name}`);\n remove.addEventListener("click", () => void update(set.name, { remove: [entry] }));\n chip.appendChild(remove);\n }\n list.appendChild(chip);\n }\n if (set.entries.length === 0) list.appendChild(el("span", "hint", "empty"));\n block.appendChild(list);\n body.appendChild(block);\n }\n if (!BOOT.allowActing) return;\n const form = el("div", "rangeset");\n const row = el("div", "field-row");\n const name = el("input", "mono-input");\n name.type = "text";\n name.value = "allowlist";\n name.setAttribute("aria-label", "Range set");\n name.style.maxWidth = "150px";\n const value = el("input", "mono-input");\n value.type = "text";\n value.placeholder = "203.0.113.0/24";\n value.setAttribute("aria-label", "Address or CIDR to add");\n const add = el("button", null, "Add");\n const submit = () => {\n const entry = value.value.trim();\n if (entry === "") return;\n value.value = "";\n void update(name.value.trim(), { add: [entry] });\n };\n add.addEventListener("click", submit);\n value.addEventListener("keydown", (event) => {\n if (event.key === "Enter") submit();\n });\n row.appendChild(name);\n row.appendChild(value);\n row.appendChild(add);\n form.appendChild(row);\n body.appendChild(form);\n }\n async function update(name, change) {\n const result = await postJson("/api/ranges", { name, ...change });\n if (!result.ok) {\n toast("bad", "Ranges unchanged", result.error ?? "");\n return;\n }\n toast(name === "allowlist" && change.add !== void 0 ? "warn" : "ok", `\u201C${name}\u201D updated`, `${n(result.data.entries?.length ?? 0)} entr${result.data.entries?.length === 1 ? "y" : "ies"} now.`);\n await loadRanges();\n }\n\n // src/dashboard/client/draft.ts\n function draftRule(entry, existingIds = []) {\n const match = {};\n let because;\n let stem;\n const proven = entry.evidence.filter((item) => item.certainty === "certain" && item.direction !== "human");\n const detectors = [...new Set((proven.length > 0 ? proven : entry.evidence.filter((item) => item.direction !== "human")).map((item) => item.detector))];\n if (entry.identity !== void 0 && entry.identity !== "") {\n match["identity"] = [entry.identity];\n if (entry.certain) match["certain"] = true;\n stem = entry.identity;\n because = entry.certain ? `Matched on the identity \u201C${entry.identity}\u201D, and on proof \u2014 so a client merely claiming that name does not match.` : `Matched on the claimed identity \u201C${entry.identity}\u201D. Nothing has verified it, so this matches anything that says so.`;\n } else if (proven.length > 0) {\n match["detector"] = detectors;\n match["certain"] = true;\n stem = detectors[0] ?? "proven";\n because = `Matched on proof from ${detectors.join(", ")}. Only requests that carry the same proof match.`;\n } else if (detectors.length > 0) {\n match["verdict"] = [entry.verdict];\n match["detector"] = detectors;\n match["minScore"] = Math.max(0, Math.floor(entry.score / 10) * 10);\n stem = detectors[0] ?? entry.verdict;\n because = `Matched on ${entry.verdict} at score ${String(match["minScore"])} or more, from ${detectors.join(", ")}. Every one of those is probabilistic, so the guard will not let this rule deny anybody.`;\n } else {\n match["verdict"] = [entry.verdict];\n stem = entry.verdict;\n because = `Nothing fired on this request, so there is nothing sharper to match on than the verdict itself. Narrow it before you use it.`;\n }\n return {\n rule: {\n id: uniqueId(`from-${slug(stem)}`, existingIds),\n match,\n action: "tag",\n reason: "Drafted from a request on the dashboard.",\n _open: true\n },\n because\n };\n }\n function slug(value) {\n const cleaned = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");\n return cleaned === "" ? "request" : cleaned.slice(0, 40);\n }\n function uniqueId(wanted, taken) {\n if (!taken.includes(wanted)) return wanted;\n for (let suffix = 2; suffix < 1e3; suffix++) {\n const candidate = `${wanted}-${suffix}`;\n if (!taken.includes(candidate)) return candidate;\n }\n return `${wanted}-${Date.now()}`;\n }\n\n // src/dashboard/client/policy.ts\n async function loadPolicy() {\n if (!SECTIONS.policy) return;\n try {\n const document_ = await getJson("/api/policy");\n state.policy = document_;\n if (!state.editorDirty) {\n setEditorRules(document_.rules.filter((row) => row.editable).map((row) => row.rule));\n }\n if (!state.guardDirty) resetGuardDraft();\n drawPolicyTab();\n } catch {\n }\n }\n function setEditorRules(rules) {\n state.editorRules = JSON.parse(JSON.stringify(rules ?? []));\n renderEditor();\n }\n function markDirty() {\n state.editorDirty = true;\n $("policy-dirty").hidden = false;\n }\n function editorRules() {\n if (state.editorMode === "json") {\n try {\n const parsed = JSON.parse(byId("policy-json").value);\n if (!Array.isArray(parsed)) return { error: "The JSON must be an array of rules." };\n return { rules: parsed };\n } catch (error) {\n return { error: `The editor does not contain valid JSON: ${String(error)}` };\n }\n }\n return { rules: state.editorRules };\n }\n function cleanRule(rule) {\n const match = {};\n for (const [key, value] of Object.entries(rule.match ?? {})) {\n if (value === void 0 || value === null || value === "") continue;\n if (Array.isArray(value) && value.length === 0) continue;\n match[key] = value;\n }\n const params = {};\n for (const [key, value] of Object.entries(rule.params ?? {})) {\n if (value === void 0 || value === null || value === "") continue;\n if (key === "limit") {\n const limit = value;\n if (limit.max === void 0 || limit.windowMs === void 0) continue;\n }\n params[key] = value;\n }\n const out = { id: rule.id, match, action: rule.action };\n if (Object.keys(params).length > 0) out["params"] = params;\n if (rule.reason !== void 0 && rule.reason !== "") out["reason"] = rule.reason;\n return out;\n }\n function cleanRules(rules) {\n return rules.map(cleanRule);\n }\n function field(name, control, hint) {\n const row = el("div", "field");\n row.appendChild(label(name, control));\n const right = el("div", "field-row");\n for (const node of Array.isArray(control) ? control : [control]) right.appendChild(node);\n if (hint !== void 0) right.appendChild(el("span", "hint", hint));\n row.appendChild(right);\n return row;\n }\n function chipSelect(options, selected, onChange) {\n const box = el("div", "chips-select");\n const chosen = Array.isArray(selected) ? [...selected] : selected === void 0 ? [] : [String(selected)];\n for (const option of options) {\n const button = el("button", null, option);\n button.type = "button";\n button.setAttribute("aria-pressed", String(chosen.includes(option)));\n button.addEventListener("click", () => {\n const at = chosen.indexOf(option);\n if (at === -1) chosen.push(option);\n else chosen.splice(at, 1);\n button.setAttribute("aria-pressed", String(at === -1));\n onChange(chosen.length === 0 ? void 0 : [...chosen]);\n });\n box.appendChild(button);\n }\n return box;\n }\n function segmented2(options, value, onChange) {\n const box = el("div", "seg");\n for (const [label2, option] of options) {\n const button = el("button", null, label2);\n button.type = "button";\n button.setAttribute("aria-pressed", String(option === value));\n button.addEventListener("click", () => {\n for (const other of Array.from(box.children)) other.setAttribute("aria-pressed", "false");\n button.setAttribute("aria-pressed", "true");\n onChange(option);\n });\n box.appendChild(button);\n }\n return box;\n }\n function textInput(value, placeholder, onChange, mono = false) {\n const input = el("input", mono ? "mono-input" : null);\n input.type = "text";\n input.value = value === void 0 || value === null ? "" : String(value);\n input.placeholder = placeholder;\n input.addEventListener("input", () => onChange(input.value.trim() === "" ? void 0 : input.value));\n return input;\n }\n function listInput(value, placeholder, onChange) {\n const current = value === void 0 ? "" : Array.isArray(value) ? value.join(", ") : String(value);\n const input = textInput(current, placeholder, () => {\n }, true);\n input.addEventListener("input", () => {\n const parts = input.value.split(",").map((part) => part.trim()).filter(Boolean);\n onChange(parts.length === 0 ? void 0 : parts.length === 1 ? parts[0] : parts);\n });\n return input;\n }\n function numberInput(value, placeholder, onChange) {\n const input = el("input");\n input.type = "number";\n input.value = value === void 0 || value === null ? "" : String(value);\n input.placeholder = placeholder;\n input.addEventListener("input", () => onChange(input.value === "" ? void 0 : Number(input.value)));\n return input;\n }\n function matchSummary(rule) {\n const box = el("div", "rule-summary");\n const match = rule.match ?? {};\n const parts = [];\n for (const key of ["verdict", "botClass", "category", "identity", "detector", "method", "path"]) {\n const value = match[key];\n if (value === void 0) continue;\n parts.push([key, Array.isArray(value) ? value.join(", ") : String(value)]);\n }\n if (match["certain"] !== void 0) parts.push(["certain", String(match["certain"])]);\n if (match["minScore"] !== void 0 || match["maxScore"] !== void 0) {\n parts.push(["score", `${String(match["minScore"] ?? 0)}\u2013${String(match["maxScore"] ?? 99)}`]);\n }\n if (match["minPriorConfirmations"] !== void 0) parts.push(["prior", String(match["minPriorConfirmations"])]);\n if (match["minUnsolvedChallenges"] !== void 0) parts.push(["unsolved", String(match["minUnsolvedChallenges"])]);\n if (parts.length === 0) {\n box.appendChild(el("span", "none", "matches everything"));\n return box;\n }\n for (const [key, value] of parts.slice(0, 4)) box.appendChild(el("span", "t", `${key}: ${value}`));\n if (parts.length > 4) box.appendChild(el("span", "k", `+${parts.length - 4} more`));\n return box;\n }\n function ruleCard(rule, index) {\n const vocabulary = state.policy?.vocabulary;\n const open = rule._open === true;\n const card = el("div", `rule${open ? "" : " collapsed"}`);\n const head = el("div", "rule-head");\n const chevron = el("button", "chev", open ? "\u25BE" : "\u25B8");\n chevron.title = open ? "Collapse" : "Expand";\n chevron.setAttribute("aria-expanded", String(open));\n chevron.addEventListener("click", () => {\n rule._open = !open;\n renderEditor();\n });\n head.appendChild(chevron);\n head.appendChild(el("span", "ord", index + 1));\n const id = textInput(rule.id, "rule-id", (value) => {\n rule.id = value ?? "";\n markDirty();\n }, true);\n id.setAttribute("aria-label", "Rule id");\n head.appendChild(id);\n if (open) {\n const action = el("select");\n action.setAttribute("aria-label", "Action");\n for (const name of vocabulary?.actions ?? []) {\n const option = el("option", null, name);\n option.value = name;\n if (name === rule.action) option.selected = true;\n action.appendChild(option);\n }\n action.addEventListener("change", () => {\n rule.action = action.value;\n rule.params = {};\n markDirty();\n renderEditor();\n });\n head.appendChild(action);\n } else {\n head.appendChild(matchSummary(rule));\n head.appendChild(el("span", `act-pill ${actionKind(rule.action)}`, rule.action));\n }\n const up = el("button", "icon", "\u2191");\n up.title = "Move earlier \u2014 the first matching rule wins";\n up.addEventListener("click", () => moveRule(index, -1));\n const down = el("button", "icon", "\u2193");\n down.title = "Move later";\n down.addEventListener("click", () => moveRule(index, 1));\n const remove = el("button", "icon danger", "Remove");\n remove.addEventListener("click", () => {\n state.editorRules.splice(index, 1);\n markDirty();\n renderEditor();\n });\n head.appendChild(up);\n head.appendChild(down);\n head.appendChild(remove);\n card.appendChild(head);\n if (!open) return card;\n const body = el("div", "rule-body");\n rule.match = rule.match ?? {};\n const match = rule.match;\n body.appendChild(field("Verdict", chipSelect(vocabulary?.verdicts ?? [], match["verdict"], (value) => {\n match["verdict"] = value;\n markDirty();\n })));\n body.appendChild(field("Bot class", chipSelect(vocabulary?.botClasses ?? [], match["botClass"], (value) => {\n match["botClass"] = value;\n markDirty();\n })));\n body.appendChild(field("Category", chipSelect(vocabulary?.categories ?? [], match["category"], (value) => {\n match["category"] = value;\n markDirty();\n })));\n body.appendChild(field("Detector", chipSelect(vocabulary?.detectors ?? [], match["detector"], (value) => {\n match["detector"] = value;\n markDirty();\n })));\n body.appendChild(field("Method", chipSelect(vocabulary?.methods ?? [], match["method"], (value) => {\n match["method"] = value;\n markDirty();\n })));\n body.appendChild(\n field(\n "Evidence",\n segmented2(\n [\n ["any", void 0],\n ["proven", true],\n ["unproven", false]\n ],\n match["certain"],\n (value) => {\n match["certain"] = value;\n markDirty();\n }\n ),\n "proven means at least one piece of certain evidence \u2014 including a proven human"\n )\n );\n body.appendChild(\n field("Score", [\n numberInput(match["minScore"], "min", (value) => {\n match["minScore"] = value;\n markDirty();\n }),\n el("span", "hint", "to"),\n numberInput(match["maxScore"], "max", (value) => {\n match["maxScore"] = value;\n markDirty();\n })\n ])\n );\n body.appendChild(field("Identity", listInput(match["identity"], "googlebot, gptbot", (value) => {\n match["identity"] = value;\n markDirty();\n })));\n body.appendChild(field("Path", listInput(match["path"], "/api/, /search", (value) => {\n match["path"] = value;\n markDirty();\n }), "a string matches as a prefix"));\n body.appendChild(\n field(\n "Prior bots",\n numberInput(match["minPriorConfirmations"], "0", (value) => {\n match["minPriorConfirmations"] = value;\n markDirty();\n }),\n "times this actor was already proven a bot"\n )\n );\n body.appendChild(\n field(\n "Unsolved",\n numberInput(match["minUnsolvedChallenges"], "0", (value) => {\n match["minUnsolvedChallenges"] = value;\n markDirty();\n }),\n "challenges issued to this actor that were never answered \u2014 solving one clears the count"\n )\n );\n rule.params = rule.params ?? {};\n const params = rule.params;\n for (const node of paramFields(rule.action, params)) body.appendChild(node);\n body.appendChild(field("Reason", textInput(rule.reason, "shown in the decision and in your logs", (value) => {\n rule.reason = value ?? "";\n markDirty();\n })));\n card.appendChild(body);\n return card;\n }\n function paramFields(action, params) {\n switch (action) {\n case "block":\n return [\n field("Status", numberInput(params["status"], "403", (value) => {\n params["status"] = value;\n markDirty();\n })),\n field("Body", textInput(params["body"], "Automated traffic is not served here.", (value) => {\n params["body"] = value;\n markDirty();\n }))\n ];\n case "redirect":\n return [field("Location", textInput(params["location"], "/too-fast", (value) => {\n params["location"] = value;\n markDirty();\n }, true))];\n case "delay":\n return [field("Delay", numberInput(params["delayMs"], "250", (value) => {\n params["delayMs"] = value;\n markDirty();\n }), "milliseconds")];\n case "rate-limit": {\n const limit = params["limit"] ?? {};\n params["limit"] = limit;\n return [\n field("Limit", [\n numberInput(limit["max"], "60", (value) => {\n limit["max"] = value;\n markDirty();\n }),\n el("span", "hint", "requests per"),\n numberInput(limit["windowMs"], "60000", (value) => {\n limit["windowMs"] = value;\n markDirty();\n }),\n el("span", "hint", "ms")\n ])\n ];\n }\n case "custom":\n return [field("Handler", textInput(params["handler"], "handler-id", (value) => {\n params["handler"] = value;\n markDirty();\n }, true), "id of a handler you registered")];\n default:\n return [];\n }\n }\n function lockedCard(row) {\n const card = el("div", "rule locked");\n const head = el("div", "rule-head");\n head.appendChild(el("span", "ord", `#${row.index + 1}`));\n head.appendChild(el("span", "mono", row.id));\n head.appendChild(el("span", "grow"));\n head.appendChild(el("span", "pill", "predicate \u2014 locked"));\n card.appendChild(head);\n card.appendChild(\n el("div", "rule-body", "This rule matches with a function, which cannot be represented here or sent over HTTP. It stays exactly as it is, at this position, whatever else you change.")\n );\n return card;\n }\n function moveRule(index, delta) {\n const target = index + delta;\n if (target < 0 || target >= state.editorRules.length) return;\n const moved = state.editorRules.splice(index, 1)[0];\n if (moved === void 0) return;\n state.editorRules.splice(target, 0, moved);\n markDirty();\n renderEditor();\n }\n function renderEditor() {\n if (!SECTIONS.policy) return;\n const list = $("rulelist");\n clear(list);\n const locked = (state.policy?.rules ?? []).filter((row) => !row.editable);\n if (state.editorRules.length === 0 && locked.length === 0) {\n list.appendChild(el("div", "note", "No rules. Every request takes the default action \u2014 add one, or import a set."));\n }\n const rendered2 = state.editorRules.map((rule, index) => ruleCard(rule, index));\n for (const row of locked) rendered2.splice(Math.min(row.index, rendered2.length), 0, lockedCard(row));\n for (const node of rendered2) list.appendChild(node);\n if (state.editorMode === "json") byId("policy-json").value = JSON.stringify(cleanRules(state.editorRules), null, 2);\n }\n function drawPolicyTab() {\n const document_ = state.policy;\n if (document_ === void 0) return;\n const editable = document_.editable;\n $("policy-apply").hidden = !editable;\n byId("policy-json").readOnly = !editable;\n $("policy-mode").textContent = editable ? "editable" : "read-only";\n $("rule-add").hidden = !editable;\n $("policy-import").hidden = !editable;\n const preserved = document_.rules.filter((row) => !row.editable);\n let note = editable ? "First match wins \u2014 order matters." : "Read-only. Enable controls.editPolicy to change these.";\n if (preserved.length > 0) note += ` ${preserved.length} rule(s) use a predicate function and are locked.`;\n $("policy-note").textContent = note;\n renderPresetButtons();\n drawGuard();\n drawRanges();\n if (SECTIONS.robots) {\n $("robots-preview").textContent = document_.robots === "" ? "(this policy declines no crawler by name)" : document_.robots;\n const notes = $("robots-notes");\n clear(notes);\n for (const note_ of document_.robotsNotes) notes.appendChild(el("div", "ev-meta", `${note_.rule}: ${note_.reason}`));\n }\n const rules = $("stat-rules");\n clear(rules);\n const installed = state.snapshot?.rules ?? [];\n if (installed.length === 0) rules.appendChild(el("div", "note", "No rules configured \u2014 every request takes the default action."));\n else installed.forEach((rule, index) => rules.appendChild(el("span", "chip", `${index + 1}. ${rule}`)));\n }\n async function draftIntoEditor(entry) {\n if (state.policy === void 0) await loadPolicy();\n const drafted = draftRule(entry, state.editorRules.map((rule) => rule.id));\n state.editorRules.push(drafted.rule);\n markDirty();\n if (state.editorMode === "json") byId("policy-json").value = JSON.stringify(cleanRules(state.editorRules), null, 2);\n renderEditor();\n app.showTab("policy");\n const notes = [\n `Drafted \u201C${drafted.rule.id}\u201D, tagging only. Nothing is applied \u2014 read it, choose the action, then apply.`,\n drafted.because,\n "It was added last, which is the only position that cannot change what an existing rule does. Move it up with \u2191 if it needs to win."\n ];\n showResult("warn", (box) => {\n for (const note of notes) box.appendChild(el("div", note === notes[0] ? null : "ev-meta", note));\n });\n toast("ok", "Rule drafted", "In the editor, tagging only, not applied.");\n await runPreview(notes);\n }\n function switchToGui() {\n if (state.editorMode === "gui") return;\n const parsed = editorRules();\n if (parsed.error !== void 0) {\n toast("bad", "That JSON will not parse", parsed.error);\n return;\n }\n state.editorRules = parsed.rules ?? [];\n state.editorMode = "gui";\n $("mode-gui").setAttribute("aria-pressed", "true");\n $("mode-json").setAttribute("aria-pressed", "false");\n $("editor-gui").hidden = false;\n $("editor-json").hidden = true;\n renderEditor();\n }\n function switchToJson() {\n state.editorMode = "json";\n $("mode-gui").setAttribute("aria-pressed", "false");\n $("mode-json").setAttribute("aria-pressed", "true");\n $("editor-gui").hidden = true;\n $("editor-json").hidden = false;\n byId("policy-json").value = JSON.stringify(cleanRules(state.editorRules), null, 2);\n }\n async function exportSettings() {\n try {\n const response = await fetch(`${API}/api/settings`);\n const text = await response.text();\n if (!response.ok) throw new Error(text);\n download(text, `bothandler-settings-${today()}.json`, "application/json");\n toast("ok", "Settings exported", "Rules, plus a record of the configuration around them.");\n } catch (error) {\n toast("bad", "Export failed", String(error));\n }\n }\n function readSettingsFile(file) {\n const reader = new FileReader();\n reader.onload = () => {\n try {\n applyImported(JSON.parse(String(reader.result)), file.name);\n } catch (error) {\n toast("bad", "That file is not JSON", String(error));\n }\n };\n reader.onerror = () => toast("bad", "Could not read that file", "");\n reader.readAsText(file);\n }\n function applyImported(document_, name) {\n const rules = Array.isArray(document_) ? document_ : Array.isArray(document_.rules) ? document_.rules : void 0;\n if (rules === void 0) {\n toast("bad", "Nothing to import", "Expected an array of rules, or a settings file with a rules array.");\n return;\n }\n setEditorRules(rules);\n markDirty();\n switchToGui();\n const ignored = [];\n const readOnly = document_.readOnly;\n if (readOnly !== void 0) {\n ignored.push("the guard, the detectors, the ranges and the audit \u2014 those come from the code that built the handler, not from a file");\n if (Array.isArray(readOnly.lockedRules) && readOnly.lockedRules.length > 0) {\n ignored.push(`${readOnly.lockedRules.length} predicate rule(s), which stay as they are`);\n }\n }\n showResult("warn", (box) => {\n box.appendChild(el("div", null, `Loaded ${rules.length} rule(s) from ${name}. Nothing has been applied yet \u2014 preview it first.`));\n for (const line of ignored) box.appendChild(el("div", "ev-meta", `Ignored: ${line}`));\n });\n toast("ok", `Imported ${rules.length} rule(s)`, "Review, preview, then apply.");\n }\n function collectForSubmit() {\n const parsed = editorRules();\n if (parsed.error !== void 0) {\n showResult("bad", (box) => box.appendChild(el("div", null, parsed.error ?? "")));\n toast("bad", "That JSON will not parse", parsed.error);\n return void 0;\n }\n const cleaned = cleanRules(parsed.rules ?? []);\n const blank = cleaned.filter((rule) => rule["id"] === void 0 || rule["id"] === "").length;\n if (blank > 0) {\n toast("bad", "Every rule needs an id", "It is what every decision and log line names.");\n return void 0;\n }\n return cleaned;\n }\n async function runPreview(notes = []) {\n const rules = collectForSubmit();\n if (rules === void 0) return;\n const result = await postJson("/api/policy/preview", { rules });\n if (!result.ok) {\n showResult("bad", (box) => box.appendChild(el("div", null, result.error ?? "")));\n toast("bad", "Refused", result.error ?? "");\n return;\n }\n renderPreview(result.data, notes);\n }\n async function applyPolicy() {\n const rules = collectForSubmit();\n if (rules === void 0) return;\n const result = await postJson("/api/policy/apply", { rules });\n if (!result.ok) {\n showResult("bad", (box) => box.appendChild(el("div", null, result.error ?? "")));\n toast("bad", "Refused", result.error ?? "");\n return;\n }\n state.editorDirty = false;\n $("policy-dirty").hidden = true;\n await loadPolicy();\n toast("ok", "Applied", `${n(result.data.rules)} rule(s) now in force.`);\n showResult("ok", (box) => {\n box.appendChild(el("div", null, `Applied. ${n(result.data.rules)} rule(s) are now in force.`));\n for (const warning of result.data.warnings ?? []) box.appendChild(el("div", "ev-meta", warning));\n });\n }\n function renderPresetButtons() {\n const box = $("preset-buttons");\n const presets = state.policy?.vocabulary.presets ?? [];\n if (box.childElementCount === presets.length) return;\n clear(box);\n for (const preset of presets) {\n const button = el("button", null, preset);\n button.addEventListener("click", () => {\n void (async () => {\n const result = await postJson("/api/policy/preview", { preset });\n if (!result.ok) {\n toast("bad", "Refused", result.error ?? "");\n return;\n }\n renderPreview(result.data);\n })();\n });\n box.appendChild(button);\n }\n }\n function initPolicy() {\n if (!SECTIONS.policy) return;\n $("mode-gui").addEventListener("click", switchToGui);\n $("mode-json").addEventListener("click", switchToJson);\n $("rule-add").addEventListener("click", () => {\n state.editorRules.push({ id: `new-rule-${state.editorRules.length + 1}`, match: {}, action: "tag", params: {}, _open: true });\n markDirty();\n renderEditor();\n });\n $("rule-expand").addEventListener("click", () => {\n const anyClosed = state.editorRules.some((rule) => rule._open !== true);\n for (const rule of state.editorRules) rule._open = anyClosed;\n $("rule-expand").textContent = anyClosed ? "Collapse all" : "Expand all";\n renderEditor();\n });\n byId("policy-json").addEventListener("input", markDirty);\n $("policy-preview").addEventListener("click", () => void runPreview());\n $("policy-apply").addEventListener("click", () => void applyPolicy());\n $("policy-revert").addEventListener("click", () => {\n state.editorDirty = false;\n $("policy-dirty").hidden = true;\n $("policy-result").hidden = true;\n resetGuardDraft();\n void loadPolicy();\n });\n $("policy-export").addEventListener("click", () => void exportSettings());\n $("policy-import").addEventListener("click", () => byId("policy-file").click());\n byId("policy-file").addEventListener("change", () => {\n const input = byId("policy-file");\n const file = input.files?.[0];\n if (file !== void 0) readSettingsFile(file);\n input.value = "";\n });\n const panel = $("editor-panel");\n for (const name of ["dragenter", "dragover"]) {\n panel.addEventListener(name, (event) => {\n if (state.policy?.editable !== true) return;\n event.preventDefault();\n panel.classList.add("drop");\n });\n }\n for (const name of ["dragleave", "drop"]) {\n panel.addEventListener(name, (event) => {\n panel.classList.remove("drop");\n if (name !== "drop") return;\n event.preventDefault();\n const file = event.dataTransfer?.files?.[0];\n if (file !== void 0) readSettingsFile(file);\n });\n }\n }\n\n // src/dashboard/client/feed.ts\n var FILTERS = [\n ["all", "All"],\n ["proven", "Proven"],\n ["suspected", "Suspected"],\n ["human", "Human"],\n ["guard", "Guard stops"],\n ["deny", "Denied"],\n ["mitigate", "Mitigated"],\n ["allow", "Served"]\n ];\n var rendered = /* @__PURE__ */ new Map();\n function initFeed() {\n const filters = $("filters");\n for (const [name, label2] of FILTERS) {\n const button = el("button", null, label2);\n button.setAttribute("aria-pressed", String(name === state.filter));\n button.dataset["filter"] = name;\n button.addEventListener("click", () => {\n state.filter = name;\n for (const other of Array.from(filters.children)) other.setAttribute("aria-pressed", "false");\n button.setAttribute("aria-pressed", "true");\n app.syncUrl();\n app.drawNow();\n });\n filters.appendChild(button);\n }\n const search = byId("search");\n search.addEventListener("input", () => {\n setSearch(search.value.trim());\n app.syncUrl();\n app.drawNow();\n });\n const exportShown = byId("feed-export");\n exportShown.hidden = !SECTIONS.evidence;\n exportShown.addEventListener("click", () => {\n const rows = visibleRows(Number.POSITIVE_INFINITY);\n if (rows.length === 0) {\n toast("warn", "Nothing to export", "No request in the window matches this filter.");\n return;\n }\n const entries = rows.map((row) => row.entry).reverse();\n download(`${replayFile(entries)}\n`, `bothandler-feed-${today()}.jsonl`, "application/x-ndjson");\n toast("ok", `Exported ${n(entries.length)} request(s)`, "Replay them with `bothandlerjs replay`.");\n });\n }\n function reflectFilterButtons() {\n for (const button of Array.from($("filters").children)) {\n button.setAttribute("aria-pressed", String(button instanceof HTMLElement && button.dataset["filter"] === state.filter));\n }\n const search = byId("search");\n if (search.value !== state.search) search.value = state.search;\n }\n function drawFeed() {\n const body = byId("rows");\n const shown = visibleRows();\n let index = 0;\n const place = (node) => {\n const current = body.childNodes[index] ?? null;\n if (current !== node) body.insertBefore(node, current);\n index++;\n };\n for (const row of shown) {\n const id = row.entry.requestId;\n const open = state.open.has(id);\n let cached = rendered.get(id);\n if (cached === void 0 || cached.rev !== row.rev || cached.open !== open) {\n cached = {\n row: buildRow(row.entry, open),\n detail: open ? buildDetail(row.entry) : void 0,\n rev: row.rev,\n open\n };\n rendered.set(id, cached);\n }\n place(cached.row);\n if (cached.detail !== void 0) place(cached.detail);\n }\n while (body.childNodes.length > index) body.removeChild(body.childNodes[index]);\n if (rendered.size > shown.length * 2 + 100) {\n const live = new Set(shown.map((row) => row.entry.requestId));\n for (const id of Array.from(rendered.keys())) if (!live.has(id)) rendered.delete(id);\n }\n const total = state.rows.length;\n const matching = matchingCount();\n $("empty").hidden = total > 0;\n $("feed-count").textContent = matching === total ? `${n(total)} in this window` : `${n(matching)} of ${n(total)}${matching > FEED_LIMIT ? ` \xB7 showing ${n(FEED_LIMIT)}` : ""}`;\n const skipped = (state.snapshot?.skipped ?? 0) + state.laggedDrops;\n const note = $("feed-skipped");\n note.hidden = skipped === 0;\n note.textContent = `${n(skipped)} not streamed`;\n note.title = state.laggedDrops > 0 ? `${n(state.laggedDrops)} were skipped because this connection could not keep up, and the rest by the rate cap. All of them are still in the window, the preview and the export.` : "Entries the rate cap kept off this stream. They are still in the window, the preview and the export \u2014 raise maxEventsPerSecond to see them live.";\n }\n function resetFeedCache() {\n rendered.clear();\n }\n function buildRow(entry, open) {\n const out = outcome(entry);\n const tr = el("tr", `row a-${entry.downgradedFrom !== void 0 ? "guard" : out}${open ? " open" : ""}`);\n tr.appendChild(el("td", "num mono tnum when", clockTime(entry.at)));\n const request = el("td", "edge req");\n const toggle = el("button", "row-toggle", `${entry.method} ${entry.path}`);\n toggle.type = "button";\n toggle.setAttribute("aria-expanded", String(open));\n toggle.setAttribute("aria-label", `${entry.method} ${entry.path}, ${entry.verdict}. Evidence.`);\n toggle.dataset["request"] = entry.requestId;\n request.appendChild(toggle);\n const ua = el("span", "ua");\n if (SECTIONS.actors) {\n const actorLink = el("a", null, entry.actor);\n actorLink.href = "#actor";\n actorLink.title = "Show everything from this actor";\n actorLink.addEventListener("click", (event) => {\n event.preventDefault();\n event.stopPropagation();\n openActor(entry.actor);\n });\n ua.appendChild(actorLink);\n ua.appendChild(document.createTextNode(` \xB7 ${entry.userAgent}`));\n } else {\n ua.appendChild(document.createTextNode(`${entry.actor} \xB7 ${entry.userAgent}`));\n }\n ua.title = `${entry.actor} \xB7 ${entry.userAgent}`;\n request.appendChild(ua);\n tr.appendChild(request);\n const verdictCell = el("td");\n const [badgeClass, badgeLabel] = verdictBadge(entry);\n verdictCell.appendChild(el("span", `badge ${badgeClass}`, badgeLabel));\n if (entry.identity !== void 0) verdictCell.appendChild(el("span", "sub", entry.identity));\n tr.appendChild(verdictCell);\n tr.appendChild(el("td", "num mono tnum", entry.certain ? "proven" : String(entry.score)));\n const actionCell = el("td");\n if (entry.action !== void 0) {\n const kind = out === "deny" ? "act-deny" : out === "mitigate" ? "act-mitigate" : out === "allow" ? "act-allow" : "act-tag";\n actionCell.appendChild(el("span", `act ${kind}`, entry.action));\n if (entry.rule !== void 0) {\n const ruleLabel = el("span", "sub", entry.rule);\n ruleLabel.title = entry.rule;\n actionCell.appendChild(ruleLabel);\n }\n if (entry.downgradedFrom !== void 0) actionCell.appendChild(el("span", "guard", `guard stopped ${entry.downgradedFrom}`));\n } else {\n actionCell.appendChild(el("span", "sub", "assessed only"));\n }\n tr.appendChild(actionCell);\n tr.appendChild(el("td", "num mono tnum", entry.durationMs.toFixed(2)));\n tr.dataset["request"] = entry.requestId;\n const flip = () => {\n if (state.open.has(entry.requestId)) state.open.delete(entry.requestId);\n else state.open.add(entry.requestId);\n app.drawNow();\n rootNode().querySelector(`button.row-toggle[data-request="${cssEscape(entry.requestId)}"]`)?.focus();\n };\n toggle.addEventListener("click", (event) => {\n event.stopPropagation();\n flip();\n });\n tr.addEventListener("click", flip);\n return tr;\n }\n function buildDetail(entry) {\n const tr = el("tr", "detail");\n const cell = el("td");\n cell.colSpan = 6;\n if (!SECTIONS.evidence) {\n cell.appendChild(\n el(\n "div",\n "ev-meta",\n "The evidence section is switched off on this dashboard, so the reasons behind this verdict are not sent to it. What fired, and why, is on a dashboard that has `sections: { evidence: true }`."\n )\n );\n } else if (entry.evidence.length === 0) {\n cell.appendChild(\n el(\n "div",\n "ev-meta",\n entry.bypass !== void 0 ? `Detection was skipped for this request: ${entry.bypass}.` : "No detector produced any evidence. This is what ordinary traffic looks like."\n )\n );\n } else {\n const list = el("div", "ev");\n for (const item of entry.evidence) {\n const row = el("div", `ev-item${item.direction === "human" ? " human" : ""}`);\n row.appendChild(el("div", `tier t-${item.certainty}`, item.certainty));\n const body = el("div");\n body.appendChild(el("div", null, item.summary));\n let meta = `${item.detector} \xB7 points to ${item.direction}`;\n if (item.family !== void 0) meta += ` \xB7 family \u201C${item.family}\u201D, counted once with its siblings`;\n body.appendChild(el("div", "ev-meta", meta));\n if (item.deterministicBasis !== void 0) body.appendChild(el("div", "basis", item.deterministicBasis));\n row.appendChild(body);\n list.appendChild(row);\n }\n cell.appendChild(list);\n }\n for (const failure of entry.failures) {\n cell.appendChild(el("div", "ev-meta", `Detector ${failure.detector} ${failure.reason}: ${failure.message}`));\n }\n if (entry.downgradeReason !== void 0) cell.appendChild(el("div", "basis", `Guard: ${entry.downgradeReason}`));\n const queryNames = Object.keys(entry.query);\n if (queryNames.length > 0) {\n const queryTable = el("table", "hdr");\n for (const name of queryNames) {\n const value = entry.query[name] ?? "";\n const row = el("tr");\n row.appendChild(el("td", "n", `?${name}`));\n row.appendChild(el("td", `v${value === "[redacted]" ? " red" : ""}`, value));\n queryTable.appendChild(row);\n }\n cell.appendChild(queryTable);\n }\n if (entry.headers !== void 0 && entry.headers.length > 0) {\n const table = el("table", "hdr");\n for (const [name, value] of entry.headers) {\n const row = el("tr");\n row.appendChild(el("td", "n", `${name}:`));\n row.appendChild(el("td", `v${value === "[redacted]" ? " red" : ""}`, value));\n table.appendChild(row);\n }\n cell.appendChild(table);\n }\n const tools = el("div", "tools");\n if (SECTIONS.evidence) {\n tools.appendChild(copyButton("Copy replay line", () => replayLine(entry)));\n tools.appendChild(downloadButton("Download replay line", () => replayLine(entry), `request-${entry.requestId}.jsonl`));\n tools.appendChild(copyButton("Copy corpus case", () => corpusCase(entry)));\n }\n if (SECTIONS.policy) {\n const draft2 = el("button", null, "Draft a rule");\n draft2.title = "Start a rule from this request, in the policy editor";\n draft2.addEventListener("click", (event) => {\n event.stopPropagation();\n void draftIntoEditor(entry);\n });\n tools.appendChild(draft2);\n }\n if (SECTIONS.actors) {\n const actorButton = el("button", null, "Show this actor");\n actorButton.addEventListener("click", (event) => {\n event.stopPropagation();\n openActor(entry.actor);\n });\n tools.appendChild(actorButton);\n }\n cell.appendChild(tools);\n const foot = el("div", "detail-foot");\n foot.appendChild(el("span", null, clockTime(entry.at)));\n foot.appendChild(el("span", null, `actor ${entry.actor}`));\n if (entry.rule !== void 0) foot.appendChild(el("span", null, `rule \u201C${entry.rule}\u201D`));\n foot.appendChild(el("span", null, `assessed in ${entry.durationMs.toFixed(3)}ms`));\n foot.appendChild(el("span", "mono", entry.requestId));\n cell.appendChild(foot);\n tr.appendChild(cell);\n return tr;\n }\n function copyButton(label2, produce) {\n const button = el("button", null, label2);\n button.addEventListener("click", (event) => {\n event.stopPropagation();\n const text = produce();\n const done = () => {\n button.textContent = "Copied";\n setTimeout(() => {\n button.textContent = label2;\n }, 1200);\n };\n if (navigator.clipboard?.writeText !== void 0) navigator.clipboard.writeText(text).then(done, () => showText(text));\n else showText(text);\n });\n return button;\n }\n function downloadButton(label2, produce, filename) {\n const button = el("button", null, label2);\n button.addEventListener("click", (event) => {\n event.stopPropagation();\n download(`${produce()}\n`, filename, "application/x-ndjson");\n });\n return button;\n }\n function showText(text) {\n const box = el("pre", "code", text);\n const host = $("policy-result");\n host.hidden = false;\n host.className = "result";\n clear(host);\n host.appendChild(el("div", "ev-meta", "Copying needs a secure context; here is the text."));\n host.appendChild(box);\n const selection = getSelection();\n if (selection !== null) {\n const range = document.createRange();\n range.selectNodeContents(box);\n selection.removeAllRanges();\n selection.addRange(range);\n }\n }\n\n // src/dashboard/client/stream.ts\n var source;\n function connectStream() {\n if (!SECTIONS.feed) {\n $("dot").className = "dot";\n $("conn").textContent = "feed off";\n return;\n }\n if (source !== void 0) return;\n source = new EventSource(`${API}/api/stream`);\n source.addEventListener("open", () => {\n $("dot").className = "dot on";\n $("conn").textContent = "live";\n });\n source.addEventListener("sync", (event) => {\n const detail = JSON.parse(event.data);\n if (!detail.replace) return;\n clearFeed();\n resetFeedCache();\n });\n source.addEventListener("entry", (event) => {\n ingest(JSON.parse(event.data));\n if (state.paused) {\n state.bufferedWhilePaused++;\n $("pause").textContent = `Resume (${state.bufferedWhilePaused})`;\n }\n app.draw();\n });\n source.addEventListener("update", (event) => {\n ingest(JSON.parse(event.data));\n app.draw();\n });\n source.addEventListener("reset", () => {\n clearFeed();\n resetFeedCache();\n app.draw();\n });\n source.addEventListener("lagged", (event) => {\n const detail = JSON.parse(event.data);\n state.laggedDrops += detail.dropped;\n app.draw();\n });\n source.addEventListener("stats", (event) => {\n state.snapshot = JSON.parse(event.data);\n app.draw();\n });\n source.addEventListener("error", () => {\n $("dot").className = "dot off";\n $("conn").textContent = "reconnecting\u2026";\n });\n }\n async function loadInitialSnapshot() {\n try {\n state.snapshot = await getJson("/api/stats");\n app.drawNow();\n } catch {\n }\n }\n\n // src/dashboard/client/panels.ts\n var paintedSnapshot;\n function drawTiles(force = false) {\n if (!SECTIONS.statistics) return;\n const box = $("tiles");\n const snapshot = state.snapshot;\n if (snapshot === void 0) return;\n if (!force && paintedSnapshot === snapshot) return;\n paintedSnapshot = snapshot;\n const metrics = snapshot.metrics;\n clear(box);\n if (metrics === void 0) {\n box.appendChild(el("div", "note", "Counters are switched off on this handler (metrics: false). The live feed still works."));\n return;\n }\n const actions = metrics.actions;\n const denied = actions.block + actions.drop + actions.redirect;\n const mitigated = actions.challenge + actions["rate-limit"] + actions.delay;\n const served = actions.allow + actions.tag + actions.log;\n const total = metrics.requests;\n const unremarkable = metrics.verdicts.unknown + metrics.verdicts.human;\n const provenBotCount = provenBots(metrics.verdicts);\n const tiles = [\n ["", n(total), "Requests", "since start"],\n ["proven", n(provenBotCount), "Proven bots", `${pct(provenBotCount, total)} of traffic`],\n ["warn", n(metrics.verdicts["suspected-bot"]), "Suspected", "never denied on this alone"],\n ["", n(unremarkable), "Unremarkable", `${pct(unremarkable, total)} of traffic`],\n ["warn", n(metrics.downgrades), "Guard stops", metrics.downgrades > 0 ? "rules asking for more than the evidence" : "no rule overreached"],\n ["crit", n(denied), "Denied", `${pct(denied, total)} of traffic`],\n [\n "",\n n(mitigated),\n "Mitigated",\n metrics.challenges.issued > 0 ? `${n(metrics.challenges.solved)} of ${n(metrics.challenges.issued)} challenges solved` : "challenged, limited or delayed"\n ],\n ["good", n(served), "Served", `${pct(served, total)} of traffic`],\n ["", n(metrics.actorsTracked), "Actors tracked", "in the registry now"]\n ];\n for (const [kind, value, key, sub] of tiles) {\n const tile = el("div", `tile ${kind}`);\n tile.appendChild(el("div", "v tnum", value));\n tile.appendChild(el("div", "k", key));\n tile.appendChild(el("div", "s", sub));\n box.appendChild(tile);\n }\n }\n function drawChips() {\n const snapshot = state.snapshot;\n if (snapshot === void 0) return;\n const box = $("chips");\n clear(box);\n const facts = [\n // Which process this is, first, because everything after it is a fact about this\n // process and nothing else. Behind a load balancer there are as many of these\n // dashboards as there are pods, each showing its own share of the traffic.\n ["instance", snapshot.instance],\n ["guard", snapshot.policy.falsePositivePolicy],\n ["suspect at", String(snapshot.policy.suspectThreshold)]\n ];\n if (SECTIONS.statistics) facts.push(["detectors", String(snapshot.detectors.length)]);\n if (SECTIONS.policy) facts.push(["rules", String(snapshot.rules.length)]);\n facts.push(["uptime", uptime(snapshot.now - snapshot.startedAt)]);\n for (const [label2, value] of facts) {\n const fact = el("span");\n fact.appendChild(document.createTextNode(`${label2} `));\n fact.appendChild(el("b", null, value));\n box.appendChild(fact);\n }\n }\n function updateWindowLabels() {\n const label2 = windowLabel(state.rows.length, oldestAt(), Date.now());\n for (const node of Array.from(rootNode().querySelectorAll(".win"))) node.textContent = label2;\n }\n function drawLivePanels() {\n if (!SECTIONS.statistics) return;\n const totals = aggregate(state.rows);\n drawBars($("live-detectors"), totals.detectors, "Nothing has fired in this window.");\n if (SECTIONS.actors) drawBars($("live-actors"), totals.actors, "No traffic in this window.");\n }\n function drawStatsPanels() {\n const snapshot = state.snapshot;\n if (snapshot === void 0) return;\n const metrics = snapshot.metrics;\n if (metrics !== void 0) {\n drawBars($("stat-verdicts"), pairs(metrics.verdicts), "Nothing assessed yet.");\n drawBars($("stat-actions"), pairs(metrics.actions), "No decisions yet.");\n drawBars($("stat-classes"), pairs(metrics.botClasses), "Nothing classified yet.");\n drawBars($("stat-detectors"), pairs(metrics.detectorFirings), "No detector has produced evidence yet.");\n const challenges = $("stat-challenges");\n clear(challenges);\n if (!snapshot.policy.challengeEnabled) {\n challenges.appendChild(el("div", "note", "No challenge is configured, so a rule asking for one degrades to a tag. Set challenge.secrets to enable it."));\n } else {\n const funnel = [\n ["Issued", n(metrics.challenges.issued)],\n ["Solved", n(metrics.challenges.solved)],\n ["Rejected", n(metrics.challenges.rejected)],\n ["Solve rate", metrics.challenges.issued > 0 ? pct(metrics.challenges.solved, metrics.challenges.issued) : "\u2014"]\n ];\n for (const [key, value] of funnel) challenges.appendChild(statRow(key, value));\n }\n const health = $("stat-health");\n clear(health);\n const bypassed = metrics.bypassed.allowlist + metrics.bypassed["ignored-path"];\n const rows = [\n ["Requests assessed", n(metrics.requests)],\n ["Bypassed \u2014 allowlist", n(metrics.bypassed.allowlist)],\n ["Bypassed \u2014 ignored path", n(metrics.bypassed["ignored-path"])],\n ["Detection ran on", pct(metrics.requests - bypassed, metrics.requests)],\n ["Actors tracked", n(metrics.actorsTracked)],\n ["Guard stops", n(metrics.downgrades)]\n ];\n const failures = pairs(metrics.detectorFailures);\n for (const [detector, count] of failures) rows.push([`Detector failures \u2014 ${detector}`, n(count)]);\n for (const [key, value] of rows) health.appendChild(statRow(key, value));\n if (failures.length === 0) health.appendChild(el("div", "note", "No detector has thrown or timed out."));\n }\n const totals = aggregate(state.rows);\n if (SECTIONS.actors) drawBars($("stat-identities"), totals.identities, "No client has named itself in this window.");\n drawBars($("stat-paths"), totals.paths, "No traffic in this window.");\n drawBars($("stat-denied-paths"), totals.deniedPaths, "Nothing has been denied in this window.");\n drawBars($("stat-guard"), totals.guardStops, "No rule has asked for more than its evidence supports.");\n drawBars($("stat-bypassed"), totals.bypassed, "Nothing bypassed detection.");\n drawRuleHits(totals);\n const list = $("stat-detector-list");\n clear(list);\n $("detector-count").textContent = `${snapshot.detectors.length} installed`;\n for (const detector of snapshot.detectors) {\n const row = el("div", "det");\n const left = el("div");\n left.appendChild(el("div", "mono", detector.id));\n left.appendChild(el("div", "d", detector.description));\n row.appendChild(left);\n const fires = metrics?.detectorFirings[detector.id] ?? 0;\n const timing = metrics?.detectorTimings[detector.id];\n let right = `${n(fires)} \xB7 ${detector.cost} \xB7 ${detector.stage}`;\n if (timing !== void 0 && timing.count > 0) right += ` \xB7 ${ms(timing.totalMs / timing.count)} avg`;\n row.appendChild(el("div", "n", right));\n list.appendChild(row);\n }\n }\n function statRow(key, value) {\n const line = el("div", "stat-row");\n line.appendChild(el("span", "k", key));\n line.appendChild(el("span", "v", value));\n return line;\n }\n function drawRuleHits(totals) {\n const target = $("stat-rule-hits");\n clear(target);\n const rules = state.snapshot?.rules ?? [];\n if (rules.length === 0) {\n target.appendChild(el("div", "note", "No rules configured."));\n return;\n }\n let max = 1;\n for (const rule of rules) max = Math.max(max, totals.ruleHits.get(rule) ?? 0);\n for (const rule of rules) {\n const value = totals.ruleHits.get(rule) ?? 0;\n const bar = el("div", `bar${value === 0 ? " dead" : ""}`);\n const track = el("div", "track");\n if (value > 0) {\n const fill = el("div", "fill");\n fill.style.width = `${Math.max(2, Math.round(value / max * 100))}%`;\n track.appendChild(fill);\n }\n track.appendChild(el("div", "lbl", rule));\n bar.appendChild(track);\n bar.appendChild(el("div", "v tnum", value === 0 ? "never" : n(value)));\n target.appendChild(bar);\n }\n }\n function drawAudit() {\n const snapshot = state.snapshot;\n if (snapshot === void 0) return;\n const body = $("audit-body");\n const checks = $("audit-checks");\n clear(body);\n clear(checks);\n const audit = snapshot.audit;\n if (audit === void 0) {\n $("audit-spans").textContent = "";\n body.appendChild(\n el(\n "div",\n "note",\n "The traffic audit is switched off on this handler (audit: false). It watches the shape of your traffic rather than any one request \u2014 a spike in automation, a collapse in human traffic, a policy suddenly denying far more than usual."\n )\n );\n return;\n }\n const current = audit.window;\n const baseline = audit.baseline;\n $("audit-spans").textContent = `last ${rangeLabel(current.spanMs)} against the ${rangeLabel(baseline.spanMs)} before`;\n const rows = [\n ["Requests", n(current.requests), n(baseline.requests)],\n ["Rate", `${current.rate.toFixed(1)}/min`, `${baseline.rate.toFixed(1)}/min`],\n ["Bot share", `${Math.round(current.botShare * 100)}%`, `${Math.round(baseline.botShare * 100)}%`],\n ["Bots", n(current.bots), n(baseline.bots)],\n ["Humans", n(current.humans), n(baseline.humans)],\n ["Denials", n(current.denials), n(baseline.denials)],\n ["Challenges", n(current.challenges), n(baseline.challenges)],\n ["Guard stops", n(current.downgrades), n(baseline.downgrades)],\n ["Detector failures", n(current.failures), n(baseline.failures)],\n ["Bypassed", n(current.bypassed), n(baseline.bypassed)]\n ];\n for (const [key, now, was] of rows) {\n const line = el("div", "stat-row");\n line.appendChild(el("span", "k", key));\n const values = el("span", "v");\n values.appendChild(el("b", null, now));\n values.appendChild(el("span", "was", ` was ${was}`));\n line.appendChild(values);\n body.appendChild(line);\n }\n if (audit.checks.length === 0) {\n checks.appendChild(el("div", "note", "No checks are installed, so nothing here will ever raise an anomaly."));\n return;\n }\n for (const check of audit.checks) {\n const row = el("div", "det");\n const left = el("div");\n left.appendChild(el("div", "mono", check.id));\n left.appendChild(el("div", "d", check.description));\n row.appendChild(left);\n checks.appendChild(row);\n }\n }\n function drawNoticeBadge() {\n const notices = state.snapshot?.notices ?? [];\n const badge = $("notice-badge");\n badge.hidden = notices.length === 0 || !SECTIONS.notices;\n badge.textContent = String(notices.length);\n }\n function drawNotices() {\n const box = $("stat-notices");\n const notices = state.snapshot?.notices ?? [];\n clear(box);\n $("notice-count").textContent = notices.length > 0 ? `${notices.length} total` : "";\n if (notices.length === 0) {\n box.appendChild(el("div", "note", "Nothing to report: no startup warnings, no detector errors."));\n return;\n }\n for (const notice of notices.slice().reverse().slice(0, 40)) {\n const row = el("div", `notice ${notice.kind}`);\n const when = el("div", "when");\n when.appendChild(el("div", "tag", notice.kind));\n when.appendChild(el("div", null, clockTime(notice.at)));\n row.appendChild(when);\n const body = el("div");\n body.appendChild(el("div", null, notice.message));\n if (notice.source !== void 0) body.appendChild(el("div", "ev-meta", notice.source));\n row.appendChild(body);\n box.appendChild(row);\n }\n }\n function drawChanges() {\n if (!SECTIONS.changes) return;\n const box = $("stat-changes");\n const changes = state.snapshot?.changes ?? [];\n clear(box);\n $("change-count").textContent = changes.length > 0 ? `${changes.length} this run` : "";\n if (changes.length === 0) {\n box.appendChild(el("div", "note", "Nothing has been changed at runtime. Rules, guard and ranges are as the code that built this handler left them."));\n return;\n }\n for (const change of changes.slice().reverse()) {\n const row = el("div", "notice");\n const when = el("div", "when");\n when.appendChild(el("div", "tag", change.kind));\n when.appendChild(el("div", null, clockTime(change.at)));\n row.appendChild(when);\n const body = el("div");\n body.appendChild(el("div", null, change.summary));\n body.appendChild(el("div", "ev-meta", change.by === void 0 || change.by === "" ? "by an unnamed viewer \u2014 this listener\'s auth carries no identity" : `by ${change.by}`));\n row.appendChild(body);\n box.appendChild(row);\n }\n }\n function drawPeers() {\n const box = $("peers");\n if (BOOT.peers.length === 0) {\n box.hidden = true;\n return;\n }\n if (box.childElementCount > 0) return;\n box.appendChild(el("span", "hint", "also:"));\n for (const peer of BOOT.peers) {\n const link = el("a", "linkbtn", peer.label);\n link.href = peer.href;\n link.rel = "noreferrer noopener";\n box.appendChild(link);\n }\n }\n\n // src/dashboard/client/charts.ts\n var BUCKETS = 60;\n var LATENCY_BOUNDS = [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 25, 50, 100];\n var SCORE_BOUNDS = 10;\n function positionTip(tip, host, clientX, boxLeft) {\n tip.style.opacity = "1";\n tip.style.left = `${Math.min(host.clientWidth - 150, Math.max(4, clientX - boxLeft - 60))}px`;\n tip.style.top = "14px";\n }\n function tipRow(label2, value, colour) {\n const line = el("div", "r");\n const left = el("em");\n if (colour !== void 0) {\n const swatch = el("span", "swatch");\n swatch.style.background = colour;\n left.appendChild(swatch);\n }\n left.appendChild(document.createTextNode(label2));\n line.appendChild(left);\n line.appendChild(el("b", null, value));\n return line;\n }\n function timeline() {\n const bucketMs = state.rangeMs / BUCKETS;\n const now = Date.now();\n const start2 = now - state.rangeMs;\n const buckets = [];\n for (let i = 0; i < BUCKETS; i++) buckets.push({ at: start2 + i * bucketMs, served: 0, mitigated: 0, denied: 0, total: 0 });\n for (const { entry } of state.rows) {\n const index = Math.floor((entry.at - start2) / bucketMs);\n if (index < 0 || index >= BUCKETS) continue;\n const bucket = buckets[index];\n if (bucket === void 0) continue;\n bucket.total++;\n const out = outcome(entry);\n if (out === "deny") bucket.denied++;\n else if (out === "mitigate") bucket.mitigated++;\n else bucket.served++;\n }\n return buckets;\n }\n function drawTraffic() {\n const host = $("traffic-chart");\n const svg = $("traffic");\n const width = Math.max(320, host.clientWidth - 30);\n const height = 190;\n const padBottom = 26;\n const markerRow = 8;\n const plot = height - padBottom - markerRow;\n const buckets = timeline();\n const now = Date.now();\n const start2 = now - state.rangeMs;\n let peak = 1;\n for (const bucket of buckets) if (bucket.total > peak) peak = bucket.total;\n const max = Math.max(2, Math.ceil(peak / 2) * 2);\n clear(svg);\n svg.setAttribute("viewBox", `0 0 ${width} ${height}`);\n svg.setAttribute("height", String(height));\n const s1 = css("--s1");\n const s2 = css("--s2");\n const crit = css("--crit");\n const grid = css("--grid");\n const muted = css("--muted");\n const step = width / BUCKETS;\n const barWidth = Math.max(2, step - 2);\n for (const fraction of [0, 0.5, 1]) {\n const y = markerRow + plot - fraction * plot;\n svg.appendChild(svgEl("line", { class: "gridline", x1: 0, x2: width, y1: y, y2: y, stroke: grid, "stroke-width": 1 }));\n if (fraction > 0) svg.appendChild(svgText({ x: 2, y: y - 3, fill: muted, "font-size": 10 }, Math.round(max * fraction)));\n }\n buckets.forEach((bucket, index) => {\n const x = index * step + 1;\n const servedHeight = bucket.served / max * plot;\n const mitigatedHeight = bucket.mitigated / max * plot;\n let y = markerRow + plot;\n if (servedHeight > 0) {\n y -= servedHeight;\n svg.appendChild(svgEl("rect", { x, y, width: barWidth, height: servedHeight, fill: s1, rx: 2 }));\n }\n if (mitigatedHeight > 0) {\n y -= mitigatedHeight + (servedHeight > 0 ? 2 : 0);\n svg.appendChild(svgEl("rect", { x, y: Math.max(markerRow, y), width: barWidth, height: mitigatedHeight, fill: s2, rx: 2 }));\n }\n if (bucket.denied > 0) svg.appendChild(svgEl("rect", { x, y: 0, width: barWidth, height: 5, fill: crit, rx: 2 }));\n });\n svg.appendChild(svgEl("line", { x1: 0, x2: width, y1: markerRow + plot, y2: markerRow + plot, stroke: css("--line"), "stroke-width": 1 }));\n const span = rangeLabel(state.rangeMs);\n const labels = [\n [0, `${span} ago`],\n [BUCKETS / 2, rangeLabel(state.rangeMs / 2)],\n [BUCKETS - 1, "now"]\n ];\n for (const [position, text] of labels) {\n svg.appendChild(svgText({ x: Math.min(width - 26, Math.max(0, position * step)), y: height - 8, fill: muted, "font-size": 10 }, text));\n }\n const changes = (state.snapshot?.changes ?? []).filter((change) => change.at >= start2 && change.at <= now);\n const markColour = css("--proven-text");\n for (const change of changes) {\n const x = (change.at - start2) / state.rangeMs * width;\n svg.appendChild(svgEl("line", { x1: x, x2: x, y1: 0, y2: markerRow + plot, stroke: markColour, "stroke-width": 1.5, "stroke-dasharray": "3 2", opacity: 0.85 }));\n const dot = svgEl("circle", { cx: x, cy: 3, r: 3, fill: markColour });\n const title = svgEl("title");\n title.textContent = `${new Date(change.at).toLocaleTimeString()} \xB7 ${change.kind}: ${change.summary}${change.by === void 0 ? "" : ` (by ${change.by})`}`;\n dot.appendChild(title);\n svg.appendChild(dot);\n }\n const hover = svgEl("rect", { x: 0, y: 0, width: 0, height: markerRow + plot, fill: css("--ink"), opacity: 0.06 });\n svg.appendChild(hover);\n const tip = $("traffic-tip");\n host.onmousemove = (event) => {\n const box = svg.getBoundingClientRect();\n const index = Math.floor((event.clientX - box.left) / box.width * BUCKETS);\n const bucket = buckets[index];\n if (bucket === void 0) {\n tip.style.opacity = "0";\n hover.setAttribute("width", "0");\n return;\n }\n hover.setAttribute("x", String(index * step));\n hover.setAttribute("width", String(step));\n clear(tip);\n tip.appendChild(el("div", "t", `${new Date(bucket.at).toLocaleTimeString()} \xB7 ${Math.round(state.rangeMs / BUCKETS / 1e3)}s`));\n tip.appendChild(tipRow("Served", n(bucket.served), s1));\n tip.appendChild(tipRow("Mitigated", n(bucket.mitigated), s2));\n tip.appendChild(tipRow("Denied", n(bucket.denied), crit));\n positionTip(tip, host, event.clientX, box.left);\n };\n host.onmouseleave = () => {\n tip.style.opacity = "0";\n hover.setAttribute("width", "0");\n };\n const total = buckets.reduce((sum, bucket) => sum + bucket.total, 0);\n $("traffic-window").textContent = `last ${rangeLabel(state.rangeMs)}`;\n const served = buckets.reduce((sum, bucket) => sum + bucket.served, 0);\n const mitigated = buckets.reduce((sum, bucket) => sum + bucket.mitigated, 0);\n const denied = buckets.reduce((sum, bucket) => sum + bucket.denied, 0);\n const busiest = buckets.reduce((best, bucket) => bucket.total > best.total ? bucket : best, buckets[0] ?? { at: now, total: 0, served: 0, mitigated: 0, denied: 0 });\n $("traffic-alt").textContent = `Traffic over the last ${rangeLabel(state.rangeMs)}: ${n(total)} requests \u2014 ${n(served)} served, ${n(mitigated)} mitigated, ${n(denied)} denied. ` + (total === 0 ? "No traffic in this range." : `Busiest ${Math.round(state.rangeMs / BUCKETS / 1e3)}-second interval: ${n(busiest.total)} requests at ${new Date(busiest.at).toLocaleTimeString()}.`) + (changes.length === 0 ? "" : ` ${n(changes.length)} runtime change${changes.length === 1 ? "" : "s"} in this range: ${changes.map((change) => `${change.kind}, ${change.summary}`).join("; ")}.`);\n const legend = $("traffic-legend");\n clear(legend);\n legend.appendChild(el("span", null, `${n(total)} requests in the last ${rangeLabel(state.rangeMs)} \xB7`));\n const oldest = oldestAt();\n if (oldest !== void 0 && Date.now() - oldest < state.rangeMs * 0.9) {\n legend.appendChild(el("span", null, `window holds ${rangeLabel(Date.now() - oldest)} \xB7`));\n }\n for (const [label2, colour] of [\n ["Served", s1],\n ["Mitigated \u2014 challenged, limited or delayed", s2],\n ["Denied", crit]\n ]) {\n const item = el("span");\n const swatch = el("span", "swatch");\n swatch.style.background = colour;\n item.appendChild(swatch);\n item.appendChild(document.createTextNode(label2));\n legend.appendChild(item);\n }\n if (changes.length > 0) {\n const item = el("span");\n item.appendChild(el("span", "mark"));\n item.appendChild(document.createTextNode(`${n(changes.length)} runtime change${changes.length === 1 ? "" : "s"} \u2014 hover a marker`));\n legend.appendChild(item);\n }\n }\n function differences(cumulative) {\n const counts = [];\n for (let i = 0; i < cumulative.length; i++) counts.push((cumulative[i] ?? 0) - (i > 0 ? cumulative[i - 1] ?? 0 : 0));\n return counts;\n }\n function drawScores() {\n const host = $("score-chart");\n const svg = $("scores");\n clear(svg);\n const metrics = state.snapshot?.metrics;\n const fromRun = state.scoreScope === "run" && metrics !== void 0;\n let buckets;\n let scored;\n let proven;\n if (fromRun && metrics !== void 0) {\n buckets = differences(metrics.scores.buckets);\n scored = metrics.scores.count;\n proven = metrics.proven;\n } else {\n buckets = new Array(SCORE_BOUNDS).fill(0);\n scored = 0;\n proven = 0;\n for (const { entry } of state.rows) {\n if (entry.bypass !== void 0) continue;\n if (entry.certain) {\n proven++;\n continue;\n }\n const index = Math.min(SCORE_BOUNDS - 1, Math.floor(entry.score / 10));\n buckets[index] = (buckets[index] ?? 0) + 1;\n scored++;\n }\n }\n const width = Math.max(280, host.clientWidth - 30);\n const height = 190;\n const padBottom = 30;\n const plot = height - padBottom;\n let max = 1;\n for (const value of buckets) if (value > max) max = value;\n svg.setAttribute("viewBox", `0 0 ${width} ${height}`);\n svg.setAttribute("height", String(height));\n const fill = css("--s1");\n const muted = css("--muted");\n const grid = css("--grid");\n const crit = css("--crit");\n svg.appendChild(svgEl("line", { class: "gridline", x1: 0, x2: width, y1: plot, y2: plot, stroke: grid, "stroke-width": 1 }));\n const step = width / SCORE_BOUNDS;\n buckets.forEach((value, index) => {\n const barHeight = value / max * (plot - 8);\n const barWidth = Math.max(2, Math.min(step - 6, 56));\n if (barHeight > 0) {\n svg.appendChild(svgEl("rect", { x: index * step + (step - barWidth) / 2, y: plot - barHeight, width: barWidth, height: barHeight, fill, rx: 3 }));\n }\n svg.appendChild(svgText({ x: index * step + 1, y: height - 14, fill: muted, "font-size": 9.5 }, index * 10));\n });\n const threshold = state.snapshot?.policy.suspectThreshold ?? 60;\n const x = threshold / 100 * width;\n svg.appendChild(svgEl("line", { x1: x, x2: x, y1: 0, y2: plot, stroke: crit, "stroke-width": 2, "stroke-dasharray": "4 3" }));\n svg.appendChild(svgText({ x: Math.min(width - 92, x + 5), y: 11, fill: crit, "font-size": 10 }, `suspect at ${threshold}`));\n svg.appendChild(svgText({ x: 0, y: height - 2, fill: muted, "font-size": 10 }, "score (probabilistic requests only)"));\n const tip = $("score-tip");\n host.onmousemove = (event) => {\n const box = svg.getBoundingClientRect();\n const index = Math.floor((event.clientX - box.left) / box.width * SCORE_BOUNDS);\n const value = buckets[index];\n if (value === void 0) {\n tip.style.opacity = "0";\n return;\n }\n clear(tip);\n tip.appendChild(el("div", "t", `score ${index * 10}\u2013${index * 10 + 9}`));\n tip.appendChild(tipRow("requests", n(value)));\n tip.appendChild(tipRow("share", pct(value, scored)));\n positionTip(tip, host, event.clientX, box.left);\n };\n host.onmouseleave = () => {\n tip.style.opacity = "0";\n };\n let over = 0;\n for (let bucket = 0; bucket < SCORE_BOUNDS; bucket++) if (bucket * 10 >= threshold) over += buckets[bucket] ?? 0;\n const legend = $("score-legend");\n clear(legend);\n legend.appendChild(el("span", null, `${n(scored)} scored \xB7 ${n(over)} at or over the threshold \xB7 ${n(proven)} proven, which carry no score`));\n $("score-alt").textContent = `Distribution of probabilistic scores ${fromRun ? "since start" : "in the retained window"}, with the suspect threshold at ${threshold}. ${n(scored)} scored requests, ${n(over)} at or over the threshold, ${n(proven)} proven and therefore unscored. ` + (scored === 0 ? "Nothing scored yet." : `By ten-point band: ${buckets.map((value, index) => `${index * 10}\u2013${index * 10 + 9}: ${n(value)}`).join(", ")}.`);\n $("score-window").textContent = fromRun ? "since start" : windowLabel(state.rows.length, oldestAt(), Date.now());\n if (state.scoreScope === "run" && metrics === void 0) {\n legend.appendChild(el("span", null, "\xB7 counters are off on this handler, so this is the retained window"));\n }\n }\n function drawLatency() {\n const host = $("latency-chart");\n const svg = $("latency");\n clear(svg);\n const metrics = state.snapshot?.metrics;\n if (metrics === void 0 || metrics.duration.count === 0) {\n $("latency-summary").textContent = "No assessments yet.";\n $("latency-alt").textContent = "Assessment latency: no assessments yet.";\n svg.setAttribute("viewBox", "0 0 100 40");\n svg.setAttribute("height", "40");\n svg.appendChild(svgText({ x: 0, y: 20, fill: css("--muted"), "font-size": 11 }, "No assessments yet."));\n return;\n }\n const cumulative = metrics.duration.buckets;\n const counts = differences(cumulative);\n const width = Math.max(280, host.clientWidth - 30);\n const height = 190;\n const padBottom = 30;\n const plot = height - padBottom;\n let max = 1;\n for (const value of counts) if (value > max) max = value;\n svg.setAttribute("viewBox", `0 0 ${width} ${height}`);\n svg.setAttribute("height", String(height));\n const fill = css("--s1");\n const muted = css("--muted");\n const grid = css("--grid");\n svg.appendChild(svgEl("line", { class: "gridline", x1: 0, x2: width, y1: plot, y2: plot, stroke: grid, "stroke-width": 1 }));\n const step = width / counts.length;\n counts.forEach((value, index) => {\n const barHeight = value / max * (plot - 6);\n if (barHeight > 0) {\n svg.appendChild(svgEl("rect", { x: index * step + 1, y: plot - barHeight, width: Math.max(2, step - 3), height: barHeight, fill, rx: 3 }));\n }\n if (index % 2 === 0) {\n svg.appendChild(svgText({ x: index * step + 1, y: height - 14, fill: muted, "font-size": 9.5 }, index < LATENCY_BOUNDS.length ? String(LATENCY_BOUNDS[index]) : "more"));\n }\n });\n svg.appendChild(svgText({ x: 0, y: height - 2, fill: muted, "font-size": 10 }, "milliseconds (upper bound of each bucket)"));\n const tip = $("latency-tip");\n host.onmousemove = (event) => {\n const box = svg.getBoundingClientRect();\n const index = Math.floor((event.clientX - box.left) / box.width * counts.length);\n const value = counts[index];\n if (value === void 0) {\n tip.style.opacity = "0";\n return;\n }\n clear(tip);\n tip.appendChild(el("div", "t", index < LATENCY_BOUNDS.length ? `\u2264 ${LATENCY_BOUNDS[index]}ms` : "over 100ms"));\n tip.appendChild(tipRow("requests", n(value)));\n tip.appendChild(tipRow("share", pct(value, metrics.duration.count)));\n positionTip(tip, host, event.clientX, box.left);\n };\n host.onmouseleave = () => {\n tip.style.opacity = "0";\n };\n const mean = metrics.duration.totalMs / metrics.duration.count;\n const p95 = percentile(cumulative, metrics.duration.count, 0.95);\n $("latency-summary").textContent = `Time spent in detection, per request \xB7 mean ${ms(mean)} \xB7 p95 ${ms(p95)} \xB7 max ${ms(metrics.duration.maxMs)}`;\n $("latency-alt").textContent = `Assessment latency since start over ${n(metrics.duration.count)} requests: mean ${ms(mean)}, 95th percentile ${ms(p95)}, maximum ${ms(metrics.duration.maxMs)}. By bucket: ${counts.map((value, index) => `${index < LATENCY_BOUNDS.length ? `up to ${LATENCY_BOUNDS[index]}ms` : "over 100ms"}: ${n(value)}`).join(", ")}.`;\n }\n function percentile(cumulative, count, fraction) {\n const target = count * fraction;\n const last = LATENCY_BOUNDS[LATENCY_BOUNDS.length - 1] ?? 100;\n for (let i = 0; i < cumulative.length; i++) {\n if ((cumulative[i] ?? 0) >= target) return i < LATENCY_BOUNDS.length ? LATENCY_BOUNDS[i] ?? last : last;\n }\n return last;\n }\n\n // src/dashboard/client/registry.ts\n var timer;\n async function loadActors() {\n if (!SECTIONS.registry) return;\n try {\n const body = await getJson("/api/actors?limit=100");\n state.actors = body.actors;\n state.actorsTracked = body.tracked;\n drawActors();\n } catch {\n }\n }\n function trackActors() {\n if (timer !== void 0) clearInterval(timer);\n timer = void 0;\n if (state.tab !== "actors") return;\n void loadActors();\n timer = setInterval(() => {\n if (state.tab !== "actors" || state.paused || isConfirming()) return;\n void loadActors();\n }, 4e3);\n }\n function drawActors() {\n if (!SECTIONS.registry) return;\n if (isConfirming()) return;\n const body = byId("actor-rows");\n clear(body);\n const actors = state.actors;\n $("actors-count").textContent = `${n(actors.length)} shown \xB7 ${n(state.actorsTracked)} tracked`;\n byId("actors-empty").hidden = actors.length > 0;\n for (const actor of actors) {\n const row = el("tr");\n row.appendChild(el("td", "who", actor.key));\n row.appendChild(el("td", "num tnum", n(actor.requests)));\n row.appendChild(el("td", "num tnum", n(actor.recentRate)));\n row.appendChild(el("td", "num tnum", n(actor.distinctPaths)));\n const cadence = el("td", "num tnum", actor.cadenceCv === void 0 ? "\u2014" : actor.cadenceCv.toFixed(2));\n if (actor.cadenceCv !== void 0 && actor.cadenceCv < 0.15) cadence.className += " warn-text";\n row.appendChild(cadence);\n row.appendChild(el("td", "num tnum", n(actor.priorConfirmations)));\n const unsolved = el("td", "num tnum", n(actor.unsolvedChallenges));\n if (actor.unsolvedChallenges >= 3) unsolved.className += " warn-text";\n row.appendChild(unsolved);\n const stateCell = el("td");\n const tags = [];\n if (actor.cleared) tags.push("cleared as human");\n if (actor.distinctUserAgents > 1) tags.push(`${actor.distinctUserAgents} User-Agents`);\n tags.push(`first seen ${clockTime(actor.firstSeen)}`);\n stateCell.appendChild(el("div", "tagline", tags.join(" \xB7 ")));\n row.appendChild(stateCell);\n const actions = el("td", "acts");\n const inFeed = el("button", null, "In feed");\n inFeed.title = "Show this actor\'s requests in the live feed";\n inFeed.addEventListener("click", () => {\n setSearch(`actor:${actor.key}`);\n const search = byId("search");\n search.value = state.search;\n app.showTab("live");\n app.syncUrl();\n });\n actions.appendChild(inFeed);\n for (const button of actorActions(actor.key, () => void loadActors())) actions.appendChild(button);\n row.appendChild(actions);\n body.appendChild(row);\n }\n }\n\n // src/dashboard/client/tester.ts\n function initTester() {\n if (!SECTIONS.tester) return;\n $("test-run").addEventListener("click", () => void run());\n byId("test-input").addEventListener("keydown", (event) => {\n if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {\n event.preventDefault();\n void run();\n }\n });\n }\n async function run() {\n const raw2 = byId("test-input").value;\n if (raw2.trim() === "") {\n toast("warn", "Nothing to assess", "Paste a User-Agent, a curl command, or a header block.");\n return;\n }\n const result = await postJson("/api/test", {\n raw: raw2,\n ip: byId("test-ip").value.trim(),\n url: byId("test-url").value.trim()\n });\n const box = $("test-result");\n box.hidden = false;\n clear(box);\n if (!result.ok) {\n box.className = "result bad";\n box.appendChild(el("div", null, result.error ?? "The server could not read that."));\n return;\n }\n const { entry, reason, assumed } = result.data;\n const out = outcome(entry);\n box.className = `result ${out === "deny" ? "bad" : out === "mitigate" ? "warn" : "ok"}`;\n const head = el("div");\n const [badgeClass, badgeLabel] = verdictBadge(entry);\n head.appendChild(el("span", `badge ${badgeClass}`, badgeLabel));\n head.appendChild(document.createTextNode(" "));\n head.appendChild(el("b", null, entry.action ?? "no decision"));\n if (entry.rule !== void 0) head.appendChild(el("span", "ev-meta", ` via ${entry.rule}`));\n box.appendChild(head);\n box.appendChild(el("div", "ev-meta", `${entry.certain ? "proven" : `score ${entry.score}`} \xB7 assessed in ${ms(entry.durationMs)}`));\n if (entry.downgradedFrom !== void 0) {\n box.appendChild(el("div", "guard", `The guard stopped ${entry.downgradedFrom} here.`));\n if (entry.downgradeReason !== void 0) box.appendChild(el("div", "basis", entry.downgradeReason));\n }\n if (entry.evidence.length === 0) {\n box.appendChild(el("div", "ev-meta", entry.bypass !== void 0 ? `Detection was skipped: ${entry.bypass}.` : "No detector produced any evidence."));\n } else {\n const list = el("div", "ev");\n for (const item of entry.evidence) {\n const row = el("div", `ev-item${item.direction === "human" ? " human" : ""}`);\n row.appendChild(el("div", `tier t-${item.certainty}`, item.certainty));\n const detail = el("div");\n detail.appendChild(el("div", null, item.summary));\n detail.appendChild(el("div", "ev-meta", `${item.detector} \xB7 points to ${item.direction}`));\n if (item.deterministicBasis !== void 0) detail.appendChild(el("div", "basis", item.deterministicBasis));\n row.appendChild(detail);\n list.appendChild(row);\n }\n box.appendChild(list);\n }\n box.appendChild(el("div", "ev-meta", reason));\n const notes = [...assumed, "no history: this is assessed as a first request, so cadence and crawl breadth have nothing to read"];\n box.appendChild(el("div", "assumed", `Assumed \u2014 ${notes.join("; ")}.`));\n }\n\n // src/dashboard/client/index.ts\n var TABS = [\n ["tab-live", "live", SECTIONS.feed],\n ["tab-actors", "actors", SECTIONS.registry],\n ["tab-stats", "stats", SECTIONS.statistics],\n ["tab-policy", "policy", SECTIONS.policy]\n ];\n var available = TABS.filter(([, , enabled]) => enabled);\n var FIRST = available[0]?.[1] ?? "live";\n function tabIndexOf(name) {\n const at = available.findIndex(([, tab]) => tab === name);\n return at === -1 ? 0 : at;\n }\n function isTab(value) {\n return available.some(([, name]) => name === value);\n }\n function initSkipLink() {\n if (!isEmbedded()) return;\n const skip = rootNode().querySelector("a.skip");\n if (skip === null) return;\n skip.addEventListener("click", (event) => {\n event.preventDefault();\n const target = rootNode().querySelector(`#view-${state.tab}`) ?? rootNode().querySelector("#view-live");\n if (target === null) return;\n target.tabIndex = -1;\n target.focus();\n target.scrollIntoView({ block: "start" });\n });\n }\n function initHeader() {\n const box = $("links");\n for (const link of BOOT.links) {\n const anchor = el("a", "linkbtn", link.label);\n anchor.href = link.href;\n anchor.rel = "noreferrer noopener";\n box.appendChild(anchor);\n }\n let stored = null;\n try {\n stored = localStorage.getItem("bothandler-dashboard-theme");\n } catch {\n stored = null;\n }\n if (stored === "dark" || stored === "light") themeElement().setAttribute("data-theme", stored);\n $("theme").addEventListener("click", () => {\n let current = themeElement().getAttribute("data-theme");\n if (current === null) current = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";\n const next = current === "dark" ? "light" : "dark";\n themeElement().setAttribute("data-theme", next);\n try {\n localStorage.setItem("bothandler-dashboard-theme", next);\n } catch {\n }\n drawNow();\n });\n const pause = byId("pause");\n pause.hidden = !SECTIONS.feed;\n pause.addEventListener("click", () => {\n state.paused = !state.paused;\n pause.setAttribute("aria-pressed", String(state.paused));\n pause.textContent = state.paused ? `Resume${state.bufferedWhilePaused > 0 ? ` (${state.bufferedWhilePaused})` : ""}` : "Pause";\n if (!state.paused) {\n state.bufferedWhilePaused = 0;\n drawNow();\n }\n });\n if (BOOT.allowReset) {\n const reset = byId("reset");\n reset.hidden = false;\n reset.addEventListener("click", () => {\n reset.disabled = true;\n void fetch(`${API}/api/reset`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }).then(async (response) => {\n if (!response.ok) {\n const body = await response.json().catch(() => ({}));\n toast("bad", "Reset refused", body.error ?? String(response.status));\n return;\n }\n clearFeed();\n resetFeedCache();\n drawNow();\n }).catch(() => {\n }).finally(() => {\n reset.disabled = false;\n });\n });\n }\n const header = rootNode().querySelector("header");\n if (header !== null) {\n const apply = () => {\n themeElement().style.setProperty("--header-h", `${header.getBoundingClientRect().height}px`);\n };\n apply();\n if (typeof ResizeObserver === "function") new ResizeObserver(apply).observe(header);\n else addEventListener("resize", apply);\n }\n }\n function syncUrl(replace = true) {\n if (isEmbedded()) return;\n const params = new URLSearchParams();\n if (state.filter !== "all") params.set("f", state.filter);\n if (state.search !== "") params.set("q", state.search);\n const query = params.toString();\n const hash = `#${state.tab}${query === "" ? "" : `?${query}`}`;\n if (location.hash === hash) return;\n history[replace ? "replaceState" : "pushState"]({ tab: state.tab }, "", hash);\n }\n function readUrl() {\n const raw2 = isEmbedded() ? "" : location.hash.slice(1);\n const split = raw2.indexOf("?");\n const name = split === -1 ? raw2 : raw2.slice(0, split);\n const params = new URLSearchParams(split === -1 ? "" : raw2.slice(split + 1));\n const filter = params.get("f") ?? "all";\n return {\n tab: isTab(name) ? name : FIRST,\n filter,\n search: params.get("q") ?? ""\n };\n }\n function showTab(name, options = {}) {\n const target = isTab(name) ? name : FIRST;\n state.tab = target;\n for (const [id, tab, enabled] of TABS) {\n const selected = tab === target;\n const node = $(id);\n node.hidden = !enabled;\n node.setAttribute("aria-selected", String(selected));\n node.tabIndex = selected ? 0 : -1;\n $(`view-${tab}`).hidden = !selected || !enabled;\n }\n if (options.focus === true) $(available[tabIndexOf(target)]?.[0] ?? "tab-live").focus();\n if (target === "policy" && state.policy === void 0) {\n void loadPolicy();\n void loadRanges();\n }\n trackActors();\n if (options.push !== false) syncUrl(options.replace !== false);\n drawNow();\n }\n function initTabs() {\n available.forEach(([id, name], index) => {\n const tab = $(id);\n tab.addEventListener("click", () => showTab(name, { replace: false }));\n tab.addEventListener("keydown", (event) => {\n let next = -1;\n if (event.key === "ArrowRight") next = (index + 1) % available.length;\n else if (event.key === "ArrowLeft") next = (index - 1 + available.length) % available.length;\n else if (event.key === "Home") next = 0;\n else if (event.key === "End") next = available.length - 1;\n if (next === -1) return;\n event.preventDefault();\n showTab(available[next]?.[1] ?? FIRST, { focus: true, replace: false });\n });\n });\n if (isEmbedded()) return;\n addEventListener("popstate", () => {\n const url = readUrl();\n state.filter = url.filter;\n setSearch(url.search);\n reflectFilterButtons();\n showTab(url.tab, { push: false });\n });\n }\n function initKeyboard() {\n eventTarget().addEventListener("keydown", ((event) => {\n const target = event.target;\n const typing = target !== null && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT");\n if (event.key === "Escape") {\n if (typing && target?.id === "search" && target.value !== "") {\n target.value = "";\n setSearch("");\n syncUrl();\n drawNow();\n return;\n }\n if (typing) {\n target?.blur();\n return;\n }\n if (state.actor !== void 0) {\n state.actor = void 0;\n drawNow();\n return;\n }\n if (state.open.size > 0) {\n state.open.clear();\n drawNow();\n }\n return;\n }\n if (typing || event.metaKey || event.ctrlKey || event.altKey) return;\n if (event.key === "/" && SECTIONS.feed) {\n event.preventDefault();\n showTab("live");\n const search = byId("search");\n search.focus();\n search.select();\n return;\n }\n const digit = ["1", "2", "3", "4"].indexOf(event.key);\n if (digit !== -1 && digit < available.length) {\n event.preventDefault();\n showTab(available[digit]?.[1] ?? FIRST, { focus: true, replace: false });\n }\n }));\n }\n function initRanges() {\n const ranges = [\n ["1m", 6e4],\n ["5m", 3e5],\n ["15m", 9e5],\n ["1h", 36e5]\n ];\n const host = $("traffic-range");\n for (const [label2, value] of ranges) {\n const button = el("button", null, label2);\n button.setAttribute("aria-pressed", String(value === state.rangeMs));\n button.addEventListener("click", () => {\n state.rangeMs = value;\n for (const other of Array.from(host.children)) other.setAttribute("aria-pressed", "false");\n button.setAttribute("aria-pressed", "true");\n drawTraffic();\n });\n host.appendChild(button);\n }\n const scopes = [\n ["since start", "run"],\n ["this window", "window"]\n ];\n const scopeHost = $("score-scope");\n for (const [label2, value] of scopes) {\n const button = el("button", null, label2);\n button.setAttribute("aria-pressed", String(value === state.scoreScope));\n button.addEventListener("click", () => {\n state.scoreScope = value;\n for (const other of Array.from(scopeHost.children)) other.setAttribute("aria-pressed", "false");\n button.setAttribute("aria-pressed", "true");\n drawScores();\n });\n scopeHost.appendChild(button);\n }\n }\n var pending = false;\n function schedule() {\n if (state.paused || pending) return;\n pending = true;\n requestAnimationFrame(() => {\n pending = false;\n draw();\n });\n }\n function draw() {\n drawChips();\n drawTiles();\n drawNoticeBadge();\n updateWindowLabels();\n if (state.tab === "live") {\n drawActor();\n drawFeed();\n drawLivePanels();\n } else if (state.tab === "actors") {\n drawActors();\n } else if (state.tab === "stats") {\n drawTraffic();\n drawLatency();\n drawScores();\n drawStatsPanels();\n if (SECTIONS.audit) drawAudit();\n } else {\n drawPolicyTab();\n if (SECTIONS.notices) drawNotices();\n if (SECTIONS.changes) drawChanges();\n }\n }\n function drawNow() {\n draw();\n }\n function applySections() {\n const gated = [\n ["tiles", SECTIONS.statistics],\n ["tester-panel", SECTIONS.tester],\n ["ranges-panel", SECTIONS.ranges],\n ["changes-panel", SECTIONS.changes],\n ["actor-panel", SECTIONS.actors],\n ["live-actors-panel", SECTIONS.actors],\n ["audit-panel", SECTIONS.audit],\n ["audit-checks-panel", SECTIONS.audit],\n ["notices-panel", SECTIONS.notices],\n ["guard-panel", SECTIONS.guard],\n ["robots-panel", SECTIONS.robots],\n ["identities-panel", SECTIONS.actors],\n ["evidence-legend", SECTIONS.evidence]\n ];\n for (const [id, enabled] of gated) {\n const node = rootNode().querySelector(`#${id}`);\n if (node !== null && !enabled) node.remove();\n }\n }\n function start() {\n app.draw = schedule;\n app.drawNow = drawNow;\n app.showTab = showTab;\n app.syncUrl = () => syncUrl();\n applySections();\n initHeader();\n drawPeers();\n initTabs();\n initSkipLink();\n initKeyboard();\n initRanges();\n if (SECTIONS.feed) initFeed();\n initActor();\n initTester();\n initPolicy();\n let resizeTimer;\n addEventListener("resize", () => {\n if (resizeTimer !== void 0) clearTimeout(resizeTimer);\n resizeTimer = setTimeout(() => {\n if (state.tab === "stats") {\n drawTraffic();\n drawLatency();\n drawScores();\n }\n }, 120);\n });\n const url = readUrl();\n state.filter = url.filter;\n setSearch(url.search);\n if (SECTIONS.feed) reflectFilterButtons();\n showTab(url.tab, { replace: true });\n void loadInitialSnapshot();\n connectStream();\n }\n start();\n})();\n';
6865
+ CLIENT_SCRIPT = '"use strict";\n(() => {\n // src/dashboard/client/css.ts\n function cssEscape(value) {\n return typeof CSS !== "undefined" && typeof CSS.escape === "function" ? CSS.escape(value) : String(value).replace(/[^\\w-]/g, "\\\\$&");\n }\n\n // src/dashboard/client/dom.ts\n function el(tag, className, value) {\n const node = document.createElement(tag);\n if (className !== void 0 && className !== null && className !== "") node.className = className;\n if (value !== void 0 && value !== null) node.textContent = String(value);\n return node;\n }\n function svgEl(name, attributes = {}) {\n const node = document.createElementNS("http://www.w3.org/2000/svg", name);\n for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, String(value));\n return node;\n }\n function svgText(attributes, text) {\n const node = svgEl("text", attributes);\n node.textContent = String(text);\n return node;\n }\n function clear(node) {\n while (node.firstChild) node.removeChild(node.firstChild);\n }\n var root = typeof document === "undefined" ? void 0 : document;\n var themeHost = typeof document === "undefined" ? void 0 : document.documentElement;\n var embedded = false;\n function isEmbedded() {\n return embedded;\n }\n function eventTarget() {\n return embedded ? root : globalThis;\n }\n function rootNode() {\n return root;\n }\n function themeElement() {\n return themeHost;\n }\n function $(id) {\n const node = root.querySelector(`#${cssEscape(id)}`);\n if (node === null || node === void 0) throw new Error(`dashboard: no element #${id}`);\n return node;\n }\n function byId(id) {\n return $(id);\n }\n function css(name) {\n return getComputedStyle(themeHost).getPropertyValue(name).trim();\n }\n var sequence = 0;\n function label(text, control) {\n const node = document.createElement("label");\n node.textContent = text;\n const single = Array.isArray(control) ? control.length === 1 ? control[0] : void 0 : control;\n if (single !== void 0 && /^(input|select|textarea)$/i.test(single.tagName)) {\n if (single.id === "") single.id = `field-${++sequence}`;\n node.htmlFor = single.id;\n } else {\n const group = Array.isArray(control) ? control : [control];\n for (const node_ of group) if (!node_.hasAttribute("aria-label")) node_.setAttribute("aria-label", text);\n }\n return node;\n }\n\n // src/dashboard/client/boot.ts\n var raw = globalThis.__BOOTSTRAP__;\n var BOOT = raw ?? {\n base: "",\n title: "bothandlerjs",\n allowReset: false,\n allowEdit: false,\n allowGuardEdit: false,\n allowActing: false,\n peers: [],\n sections: { feed: true, evidence: true, actors: true, registry: true, tester: true, statistics: true, audit: true, notices: true, changes: true, policy: true, guard: true, robots: true, ranges: true },\n links: []\n };\n var API = BOOT.base;\n var SECTIONS = BOOT.sections;\n\n // src/dashboard/client/app.ts\n var app = {\n draw: () => {\n },\n drawNow: () => {\n },\n showTab: () => {\n },\n syncUrl: () => {\n }\n };\n function toast(kind, title, detail = "") {\n const node = el("div", `toast ${kind}`);\n node.appendChild(el("b", null, title));\n if (detail !== "") node.appendChild(el("span", null, detail));\n const host = rootNode().querySelector("#toasts");\n if (host === null) return;\n host.appendChild(node);\n setTimeout(() => node.remove(), 6e3);\n }\n function download(text, filename, type) {\n const blob = new Blob([text], { type });\n const url = URL.createObjectURL(blob);\n const anchor = el("a");\n anchor.href = url;\n anchor.download = filename;\n anchor.click();\n setTimeout(() => URL.revokeObjectURL(url), 1e3);\n }\n function today() {\n return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);\n }\n\n // src/dashboard/client/outcome.ts\n var DENY = /* @__PURE__ */ new Set(["block", "drop", "redirect"]);\n var MITIGATE = /* @__PURE__ */ new Set(["challenge", "rate-limit", "delay"]);\n function outcome(entry) {\n return actionKind(entry.action);\n }\n function actionKind(action) {\n if (action === void 0) return "pending";\n if (DENY.has(action)) return "deny";\n if (MITIGATE.has(action)) return "mitigate";\n return "allow";\n }\n function verdictBadge(entry) {\n if (entry.verdict === "verified-bot" || entry.verdict === "confirmed-bot") return ["b-proven", entry.verdict];\n if (entry.verdict === "suspected-bot") return ["b-suspected", "suspected-bot"];\n if (entry.verdict === "human") return ["b-human", "human"];\n return ["b-unknown", entry.bypass !== void 0 ? `skipped \xB7 ${entry.bypass}` : "unknown"];\n }\n function provenBots(verdicts) {\n return (verdicts["confirmed-bot"] ?? 0) + (verdicts["verified-bot"] ?? 0);\n }\n\n // src/dashboard/client/query.ts\n var FIELDS = {\n path: "path",\n url: "path",\n actor: "actor",\n ip: "actor",\n ua: "userAgent",\n useragent: "userAgent",\n agent: "userAgent",\n verdict: "verdict",\n action: "action",\n rule: "rule",\n detector: "detector",\n identity: "identity",\n method: "method",\n class: "botClass",\n botclass: "botClass",\n id: "requestId",\n request: "requestId",\n bypass: "bypass",\n score: "score",\n outcome: "outcome",\n certain: "certain"\n };\n var NUMERIC = /* @__PURE__ */ new Set(["score"]);\n function tokenize(input) {\n const tokens = [];\n let current = "";\n let quoted = false;\n for (const character of input) {\n if (character === \'"\') {\n quoted = !quoted;\n continue;\n }\n if (!quoted && /\\s/.test(character)) {\n if (current !== "") tokens.push(current);\n current = "";\n continue;\n }\n current += character;\n }\n if (current !== "") tokens.push(current);\n return tokens;\n }\n function parseQuery(input) {\n const terms = [];\n for (const token of tokenize(input.trim())) {\n const negated = token.startsWith("-") || token.startsWith("!");\n const body = negated ? token.slice(1) : token;\n if (body === "") continue;\n const colon = body.indexOf(":");\n const name = colon === -1 ? "" : body.slice(0, colon).toLowerCase();\n const field2 = FIELDS[name];\n if (colon === -1 || field2 === void 0) {\n terms.push({ field: void 0, value: body.toLowerCase(), negated });\n continue;\n }\n let value = body.slice(colon + 1).toLowerCase();\n let compare;\n if (NUMERIC.has(field2)) {\n compare = value.startsWith(">") ? ">" : value.startsWith("<") ? "<" : "=";\n if (compare !== "=") value = value.slice(1);\n }\n if (value === "") continue;\n terms.push({ field: field2, value, negated, compare });\n }\n return terms;\n }\n function searchableText(entry) {\n const parts = [\n entry.method,\n entry.path,\n entry.actor,\n entry.userAgent,\n entry.verdict,\n entry.botClass,\n entry.identity ?? "",\n entry.action ?? "",\n entry.rule ?? "",\n entry.requestId\n ];\n for (const item of entry.evidence) parts.push(item.detector, item.summary);\n return parts.join(" ").toLowerCase();\n }\n function fieldValue(entry, field2) {\n switch (field2) {\n case "path":\n return entry.path;\n case "actor":\n return entry.actor;\n case "userAgent":\n return entry.userAgent;\n case "verdict":\n return entry.verdict;\n case "action":\n return entry.action ?? "";\n case "rule":\n return entry.rule ?? "";\n case "identity":\n return entry.identity ?? "";\n case "method":\n return entry.method;\n case "botClass":\n return entry.botClass;\n case "requestId":\n return entry.requestId;\n case "bypass":\n return entry.bypass ?? "";\n case "outcome":\n return outcome(entry);\n case "certain":\n return String(entry.certain);\n case "detector":\n return entry.evidence.map((item) => item.detector).join(" ");\n default:\n return "";\n }\n }\n function matchesTerm(term, entry, haystack) {\n if (term.field === void 0) return haystack.includes(term.value);\n if (term.field === "score") {\n const wanted = Number(term.value);\n if (Number.isNaN(wanted)) return false;\n if (term.compare === ">") return entry.score > wanted;\n if (term.compare === "<") return entry.score < wanted;\n return entry.score === wanted;\n }\n return fieldValue(entry, term.field).toLowerCase().includes(term.value);\n }\n function matchesQuery(terms, entry, haystack) {\n for (const term of terms) {\n if (matchesTerm(term, entry, haystack) === term.negated) return false;\n }\n return true;\n }\n function matchesFilter(filter, entry) {\n switch (filter) {\n case "proven":\n return entry.certain;\n case "suspected":\n return entry.verdict === "suspected-bot";\n case "human":\n return entry.verdict === "human";\n case "guard":\n return entry.downgradedFrom !== void 0;\n case "deny":\n return outcome(entry) === "deny";\n case "mitigate":\n return outcome(entry) === "mitigate";\n case "allow":\n return outcome(entry) === "allow";\n default:\n return true;\n }\n }\n\n // src/dashboard/client/store.ts\n var MAX_ROWS = 1e3;\n var state = {\n rows: [],\n byId: /* @__PURE__ */ new Map(),\n snapshot: void 0,\n policy: void 0,\n actors: [],\n actorsTracked: 0,\n paused: false,\n filter: "all",\n search: "",\n terms: [],\n tab: "live",\n open: /* @__PURE__ */ new Set(),\n actor: void 0,\n rangeMs: 3e5,\n scoreScope: "run",\n editorRules: [],\n editorDirty: false,\n editorMode: "gui",\n guardDirty: false,\n bufferedWhilePaused: 0,\n laggedDrops: 0,\n feedPage: 0,\n feedFrozen: void 0,\n actorsPage: 0,\n feedPageSize: 50,\n actorsPageSize: 25,\n caughtUp: 0\n };\n function ingest(entry) {\n const existing = state.byId.get(entry.requestId);\n if (existing !== void 0) {\n existing.entry = entry;\n existing.rev++;\n existing.text = void 0;\n return;\n }\n const row = { entry, rev: 0 };\n state.byId.set(entry.requestId, row);\n state.rows.push(row);\n if (state.rows.length > MAX_ROWS) {\n for (const dropped of state.rows.splice(0, state.rows.length - MAX_ROWS)) {\n state.byId.delete(dropped.entry.requestId);\n state.open.delete(dropped.entry.requestId);\n }\n }\n }\n function clearFeed() {\n state.rows = [];\n state.byId = /* @__PURE__ */ new Map();\n state.open.clear();\n state.actor = void 0;\n state.bufferedWhilePaused = 0;\n state.laggedDrops = 0;\n resetPaging();\n }\n function setSearch(value) {\n state.search = value;\n state.terms = parseQuery(value);\n resetPaging();\n }\n function resetPaging() {\n state.feedPage = 0;\n state.feedFrozen = void 0;\n }\n function textOf(row) {\n if (row.text === void 0) row.text = searchableText(row.entry);\n return row.text;\n }\n function sortRows() {\n state.rows.sort((a, b) => a.entry.at - b.entry.at);\n }\n function matches(row) {\n return matchesFilter(state.filter, row.entry) && matchesQuery(state.terms, row.entry, textOf(row));\n }\n function matchingRows(limit = Number.POSITIVE_INFINITY) {\n const shown = [];\n for (let i = state.rows.length - 1; i >= 0 && shown.length < limit; i--) {\n const row = state.rows[i];\n if (row !== void 0 && matches(row)) shown.push(row);\n }\n return shown;\n }\n function feedPage(size) {\n const all = state.feedPage === 0 || state.feedFrozen === void 0 ? matchingRows() : state.feedFrozen;\n const pages = Math.max(1, Math.ceil(all.length / size));\n const page = Math.min(Math.max(0, state.feedPage), pages - 1);\n if (page !== state.feedPage) state.feedPage = page;\n return { rows: all.slice(page * size, page * size + size), page, pages, total: all.length };\n }\n function goToFeedPage(page) {\n const next = Math.max(0, page);\n if (next === 0) {\n state.feedFrozen = void 0;\n } else if (state.feedFrozen === void 0) {\n state.feedFrozen = matchingRows();\n }\n state.feedPage = next;\n }\n function matchingCount() {\n let count = 0;\n for (const row of state.rows) if (matches(row)) count++;\n return count;\n }\n function oldestAt() {\n return state.rows[0]?.entry.at;\n }\n function bump(counter, key) {\n counter.set(key, (counter.get(key) ?? 0) + 1);\n }\n function aggregate(rows) {\n const totals = {\n detectors: /* @__PURE__ */ new Map(),\n actors: /* @__PURE__ */ new Map(),\n identities: /* @__PURE__ */ new Map(),\n paths: /* @__PURE__ */ new Map(),\n deniedPaths: /* @__PURE__ */ new Map(),\n guardStops: /* @__PURE__ */ new Map(),\n ruleHits: /* @__PURE__ */ new Map(),\n bypassed: /* @__PURE__ */ new Map()\n };\n for (const { entry } of rows) {\n for (const item of entry.evidence) bump(totals.detectors, item.detector);\n bump(totals.actors, entry.actor);\n if (entry.bypass !== void 0) {\n bump(totals.bypassed, `${entry.path} (${entry.bypass})`);\n continue;\n }\n bump(totals.paths, entry.path);\n if (entry.identity !== void 0 && entry.identity !== "") {\n bump(totals.identities, `${entry.identity} \xB7 ${entry.verdict === "verified-bot" ? "verified" : "claimed"}`);\n }\n if (outcome(entry) === "deny") bump(totals.deniedPaths, entry.path);\n if (entry.downgradedFrom !== void 0 && entry.rule !== void 0) bump(totals.guardStops, `${entry.rule} \u2192 ${entry.downgradedFrom}`);\n if (entry.rule !== void 0) bump(totals.ruleHits, entry.rule);\n }\n return totals;\n }\n\n // src/dashboard/client/api.ts\n async function getJson(path) {\n const response = await fetch(API + path);\n if (!response.ok) throw new Error(await errorFrom(response));\n return await response.json();\n }\n async function postJson(path, body) {\n const response = await fetch(API + path, {\n method: "POST",\n headers: { "content-type": "application/json" },\n body: JSON.stringify(body)\n });\n const data = await response.json().catch(() => ({}));\n return response.ok ? { ok: true, data } : { ok: false, data, error: String(data.error ?? "The server refused this.") };\n }\n async function errorFrom(response) {\n const body = await response.json().catch(() => ({}));\n return body.error ?? `${response.status} ${response.statusText}`;\n }\n\n // src/dashboard/client/actions.ts\n var armed = 0;\n function isConfirming() {\n return armed > 0;\n }\n function actorActions(key, after) {\n if (!BOOT.allowActing) return [];\n const forget = el("button", null, "Forget");\n forget.title = "Discard this actor\'s history \u2014 the cure for a false positive that has stuck";\n forget.addEventListener("click", () => {\n void act({ key, action: "forget" }, `Forgot ${key}`, "Its next request is assessed as a first request.", after);\n });\n const clear2 = el("button", null, "Clear as human");\n clear2.title = "Grant this actor human clearance for an hour, as though it had solved a challenge";\n clear2.addEventListener("click", () => {\n void act({ key, action: "clear", forMs: 60 * 6e4 }, `Cleared ${key}`, "Held as human for an hour, then reassessed.", after);\n });\n return [confirmingButton("Allowlist", `Allowlist ${key} \u2014 it stops being assessed at all`, () => allowlist(key, after)), forget, clear2];\n }\n function confirmingButton(label2, confirmation, run2) {\n const button = el("button", "danger", label2);\n let pending2 = false;\n let timer2;\n const disarm = () => {\n if (!pending2) return;\n pending2 = false;\n armed--;\n button.textContent = label2;\n button.className = "danger";\n };\n button.addEventListener("click", () => {\n if (pending2) {\n if (timer2 !== void 0) clearTimeout(timer2);\n disarm();\n run2();\n return;\n }\n pending2 = true;\n armed++;\n button.textContent = confirmation;\n button.className = "danger primary";\n timer2 = setTimeout(disarm, 5e3);\n });\n return button;\n }\n async function allowlist(key, after) {\n const result = await postJson("/api/ranges", { name: "allowlist", add: [key] });\n if (!result.ok) {\n toast("bad", "Not allowlisted", result.error ?? "");\n return;\n }\n toast("warn", `Allowlisted ${key}`, "Requests from it are no longer assessed at all.");\n after();\n }\n async function act(body, title, detail, after) {\n const result = await postJson("/api/actor", body);\n if (!result.ok) {\n toast("bad", "Refused", result.error ?? "");\n return;\n }\n toast("ok", title, detail);\n after();\n }\n\n // src/dashboard/client/format.ts\n var numbers = new Intl.NumberFormat();\n function n(value) {\n return numbers.format(value ?? 0);\n }\n function pct(part, whole) {\n return whole > 0 ? `${Math.round(part / whole * 100)}%` : "\u2014";\n }\n function ms(value) {\n return value >= 10 ? `${value.toFixed(1)}ms` : `${value.toFixed(2)}ms`;\n }\n function uptime(milliseconds) {\n const seconds = Math.max(0, Math.round(milliseconds / 1e3));\n if (seconds < 90) return `${seconds}s`;\n const minutes = Math.round(seconds / 60);\n if (minutes < 90) return `${minutes}m`;\n return `${Math.round(minutes / 60)}h`;\n }\n function rangeLabel(milliseconds) {\n const seconds = Math.round(milliseconds / 1e3);\n if (seconds < 90) return `${seconds}s`;\n const minutes = Math.round(seconds / 60);\n return minutes < 90 ? `${minutes} min` : `${Math.round(minutes / 60)}h`;\n }\n function clockTime(at) {\n const when = new Date(at);\n return `${pad(when.getHours())}:${pad(when.getMinutes())}:${pad(when.getSeconds())}`;\n }\n function clockDate(at) {\n const when = new Date(at);\n return `${pad(when.getDate())}-${pad(when.getMonth() + 1)}-${when.getFullYear()}`;\n }\n function clockStamp(at) {\n return `${clockDate(at)} ${clockTime(at)}`;\n }\n function pad(value) {\n return String(value).padStart(2, "0");\n }\n function windowLabel(count, oldestAt2, now) {\n if (count === 0) return "this window \xB7 empty";\n const span = oldestAt2 === void 0 ? 0 : Math.max(0, now - oldestAt2);\n return `last ${n(count)} requests \xB7 ${rangeLabel(span)}`;\n }\n\n // src/dashboard/client/bars.ts\n function drawBars(target, rows, emptyText) {\n clear(target);\n const filtered = [...rows].filter(([, value]) => value > 0);\n if (filtered.length === 0) {\n target.appendChild(el("div", "note", emptyText));\n return;\n }\n filtered.sort((a, b) => b[1] - a[1]);\n const max = filtered[0]?.[1] ?? 1;\n for (const [label2, value] of filtered.slice(0, 14)) {\n const bar = el("div", "bar");\n const track = el("div", "track");\n const fill = el("div", "fill");\n fill.style.width = `${Math.max(2, Math.round(value / max * 100))}%`;\n track.appendChild(fill);\n track.appendChild(el("div", "lbl", label2));\n bar.appendChild(track);\n bar.appendChild(el("div", "v tnum", n(value)));\n target.appendChild(bar);\n }\n }\n function pairs(record) {\n return Object.entries(record ?? {});\n }\n\n // src/dashboard/client/actor.ts\n function initActor() {\n if (!SECTIONS.actors) return;\n $("actor-close").addEventListener("click", closeActor);\n }\n function openActor(key) {\n state.actor = key;\n app.drawNow();\n $("actor-panel").scrollIntoView({ block: "nearest" });\n }\n function closeActor() {\n state.actor = void 0;\n app.drawNow();\n }\n function drawActor() {\n if (!SECTIONS.actors) return;\n const panel = $("actor-panel");\n if (state.actor === void 0) {\n panel.hidden = true;\n return;\n }\n panel.hidden = false;\n $("actor-key").textContent = state.actor;\n const mine = state.rows.filter((row) => row.entry.actor === state.actor).map((row) => row.entry);\n const latest = mine[mine.length - 1];\n const stats = latest?.actorStats;\n const gaps = [];\n for (let i = 1; i < mine.length; i++) gaps.push(mine[i].at - mine[i - 1].at);\n const meanGap = gaps.length > 0 ? gaps.reduce((a, b) => a + b, 0) / gaps.length : 0;\n const box = $("actor-stats");\n clear(box);\n const rows = [\n ["In this window", `${n(mine.length)} requests`],\n ["Engine sees", stats !== void 0 ? `${n(stats.requests)} requests, ${n(stats.distinctPaths)} distinct paths` : "\u2014"],\n ["Prior confirmations", stats !== void 0 ? n(stats.priorConfirmations) : "\u2014"],\n ["Holds clearance", stats !== void 0 ? stats.cleared ? "yes" : "no" : "\u2014"],\n ["First seen", stats !== void 0 ? clockStamp(stats.firstSeen) : "\u2014"],\n ["Mean gap", gaps.length > 0 ? `${Math.round(meanGap)}ms over ${n(gaps.length)} gaps` : "one request only"]\n ];\n for (const [key, value] of rows) {\n box.appendChild(el("dt", null, key));\n box.appendChild(el("dd", null, value));\n }\n const verdicts = /* @__PURE__ */ new Map();\n const actions = /* @__PURE__ */ new Map();\n for (const entry of mine) {\n verdicts.set(entry.verdict, (verdicts.get(entry.verdict) ?? 0) + 1);\n if (entry.action !== void 0) actions.set(entry.action, (actions.get(entry.action) ?? 0) + 1);\n }\n drawBars($("actor-mix"), [...verdicts, ...actions], "Nothing yet.");\n const bar = $("actor-actions");\n clear(bar);\n const buttons = actorActions(state.actor, () => app.drawNow());\n bar.hidden = buttons.length === 0;\n for (const button of buttons) bar.appendChild(button);\n }\n\n // src/dashboard/client/pager.ts\n var painted = /* @__PURE__ */ new WeakMap();\n function renderPager(host, model, options) {\n const signature = JSON.stringify([model.page, model.from, model.to, model.total, model.atStart, model.atEnd, model.held ?? "", options.withSize, model.size?.current]);\n if (painted.get(host) === signature && host.childElementCount > 0) return;\n painted.set(host, signature);\n clear(host);\n const steps = [\n { to: model.page - 1, glyph: "\u2039", label: "Previous page", disabled: model.atStart },\n { to: model.page + 1, glyph: "\u203A", label: "Next page", disabled: model.atEnd }\n ];\n const [previous, next] = steps;\n host.appendChild(stepButton(previous, model));\n const range = model.total === void 0 ? `${n(model.from)}\u2013${n(model.to)}` : `${n(model.from)}\u2013${n(model.to)} of ${n(model.total)}`;\n const where = el("span", "where", range);\n where.setAttribute("aria-live", "polite");\n host.appendChild(where);\n host.appendChild(stepButton(next, model));\n if (model.held !== void 0) {\n const held = el("span", "held", model.held);\n held.title = "New requests are still arriving and are still counted. They appear when you return to the first page.";\n host.appendChild(held);\n }\n if (options.withSize && model.size !== void 0) {\n const size = model.size;\n const label2 = el("label", "size");\n label2.appendChild(document.createTextNode("Per page"));\n const select2 = document.createElement("select");\n for (const choice of size.choices) {\n const option = document.createElement("option");\n option.value = String(choice);\n option.textContent = String(choice);\n option.selected = choice === size.current;\n select2.appendChild(option);\n }\n select2.addEventListener("change", () => {\n const chosen = Number(select2.value);\n if (Number.isFinite(chosen) && chosen > 0) size.set(chosen);\n });\n label2.appendChild(select2);\n host.appendChild(label2);\n }\n }\n function stepButton(step, model) {\n const button = el("button", "step", step.glyph);\n const element = button;\n element.type = "button";\n element.disabled = step.disabled;\n button.setAttribute("aria-label", step.label);\n button.title = step.label;\n button.addEventListener("click", () => model.go(Math.max(0, step.to)));\n return button;\n }\n\n // src/dashboard/client/replay.ts\n function replayLine(entry) {\n const headers = {};\n for (const [name, value] of entry.headers ?? []) headers[name] = value;\n const query = Object.keys(entry.query).map((name) => `${encodeURIComponent(name)}=${encodeURIComponent(entry.query[name] ?? "")}`).join("&");\n return JSON.stringify({\n method: entry.method,\n url: entry.path + (query === "" ? "" : `?${query}`),\n headers,\n ip: entry.actor,\n timestamp: new Date(entry.at).toISOString(),\n protocol: entry.protocol ?? "https",\n httpVersion: entry.httpVersion ?? "1.1"\n });\n }\n function replayFile(entries) {\n return entries.map(replayLine).join("\\n");\n }\n function corpusCase(entry) {\n const headers = (entry.headers ?? []).map((pair) => ` [${JSON.stringify(pair[0])}, ${JSON.stringify(pair[1])}]`).join(",\\n");\n return [\n "bot({",\n ` id: ${JSON.stringify(`case-${entry.requestId.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`)},`,\n ` title: ${JSON.stringify(`${entry.method} ${entry.path} from ${entry.userAgent.slice(0, 60)}`)},`,\n \' audience: "unwanted-bot", // human | benign-bot | declared-bot | unwanted-bot | hostile | infrastructure\',\n \' category: "observed",\',\n ` provenance: "Captured from the live dashboard on ${new Date(entry.at).toISOString().slice(0, 10)}",`,\n " requests: [",\n " {",\n " headers: [",\n headers,\n " ],",\n ` protocol: ${JSON.stringify(entry.protocol ?? "https")},`,\n ` httpVersion: ${JSON.stringify(entry.httpVersion ?? "1.1")},`,\n ` path: ${JSON.stringify(entry.path)},`,\n " },",\n " ],",\n ` expect: { verdict: ${JSON.stringify(entry.verdict)}, certain: ${String(entry.certain)} },`,\n "}),"\n ].join("\\n");\n }\n\n // src/dashboard/client/result.ts\n function showResult(kind, build) {\n const box = $("policy-result");\n box.hidden = false;\n box.className = `result ${kind}`;\n clear(box);\n build(box);\n }\n function renderPreview(preview, notes = []) {\n showResult(preview.newDenials > 0 ? "warn" : "ok", (box) => {\n const head = el("div");\n head.appendChild(el("b", null, `${n(preview.changed)} of ${n(preview.evaluated)} requests would be treated differently.`));\n box.appendChild(head);\n for (const note of notes) box.appendChild(el("div", "ev-meta", note));\n if (preview.newDenials > 0) {\n box.appendChild(el("div", null, `${n(preview.newDenials)} request(s) that are served today would be denied. Read the samples before applying this.`));\n }\n for (const warning of preview.warnings) box.appendChild(el("div", "ev-meta", warning));\n const dead = preview.ruleHits.filter((row) => row.hits === 0 && row.rule !== "default");\n if (dead.length > 0) box.appendChild(el("div", "ev-meta", `Never matched in this window: ${dead.map((row) => row.rule).join(", ")}`));\n if (preview.samples.length > 0) {\n const table = el("table", "diff");\n const head2 = el("tr");\n for (const label2 of ["Request", "Now", "Would be"]) head2.appendChild(el("th", null, label2));\n table.appendChild(head2);\n for (const sample of preview.samples) {\n const row = el("tr");\n const what = el("td");\n what.appendChild(el("div", "mono", sample.path));\n what.appendChild(el("div", "ev-meta", `${sample.verdict} \xB7 ${sample.userAgent.slice(0, 48)}`));\n row.appendChild(what);\n const from = el("td", "from");\n from.appendChild(el("div", null, sample.from));\n from.appendChild(el("div", "ev-meta", sample.fromRule));\n row.appendChild(from);\n const kind = sample.to === "block" || sample.to === "drop" || sample.to === "redirect" ? "deny" : sample.to === "allow" ? "allow" : "";\n const to = el("td", `to ${kind}`);\n to.appendChild(el("div", null, sample.to));\n to.appendChild(el("div", "ev-meta", sample.toRule));\n row.appendChild(to);\n table.appendChild(row);\n }\n box.appendChild(table);\n } else if (preview.evaluated === 0) {\n box.appendChild(el("div", "ev-meta", "No traffic in the window to preview against \u2014 send some requests first."));\n }\n });\n }\n\n // src/dashboard/client/guard.ts\n var draft;\n var MODE_NOTES = {\n strict: "A terminal action survives only on proven evidence. Nothing is ever denied on a guess. This is the default, and it is the claim this library makes about itself.",\n balanced: "A terminal action also survives on a probabilistic verdict that clears the score threshold with at least two independent strong signals. Real people do trip two signals \u2014 a hardened browser behind a corporate proxy is the usual pair \u2014 so this setting will eventually deny somebody who should have been served.",\n aggressive: "The guard is off. Every rule does exactly what it says, on proof or on suspicion alike, and the people it turns away first are the ones with the most unusual and most legitimate setups."\n };\n function drawGuard() {\n if (!SECTIONS.guard) return;\n const document_ = state.policy;\n const snapshot = state.snapshot;\n if (snapshot === void 0) return;\n const editable = document_?.guardEditable === true;\n const panel = $("stat-policy");\n clear(panel);\n if (!editable || document_ === void 0) {\n const rows = [\n ["False-positive policy", snapshot.policy.falsePositivePolicy],\n ["Fallback when the guard stops a rule", snapshot.policy.fallbackAction],\n ["Terminal score threshold", String(snapshot.policy.terminalScoreThreshold)],\n ["Suspect threshold", String(snapshot.policy.suspectThreshold)],\n ["Action when no rule matches", snapshot.policy.defaultAction],\n ["Challenge configured", snapshot.policy.challengeEnabled ? "yes" : "no"],\n ["Range sets", snapshot.ranges.length === 0 ? "none" : snapshot.ranges.map((range) => `${range.name} (${range.size})`).join(", ")]\n ];\n for (const [key, value] of rows) {\n const line = el("div", "stat-row");\n line.appendChild(el("span", "k", key));\n line.appendChild(el("span", "v", value));\n panel.appendChild(line);\n }\n $("guard-mode").textContent = "not editable here";\n $("guard-note").textContent = "These are fixed at construction on this dashboard. It can change which rules exist; it cannot change how far a rule is allowed to go, because relaxing that is the one edit that can start denying people. Enable it deliberately with controls: { editGuard: true }.";\n return;\n }\n if (draft === void 0) draft = { ...document_.guard };\n const vocabulary = document_.vocabulary;\n $("guard-mode").textContent = state.guardDirty ? "unsaved changes" : "editable";\n $("guard-note").textContent = "Changing these changes what every rule is allowed to do, on the next request. Preview it against the traffic in the window first \u2014 that is the only view you get of who it would start turning away.";\n panel.appendChild(\n guardField(\n // "Mode" rather than "Guard": the panel is already called that, and a field\n // repeating its own panel\'s name reads as a heading rather than as a control.\n "Mode",\n segmented(\n vocabulary.falsePositivePolicies.map((mode) => [mode, mode]),\n draft.falsePositivePolicy,\n (value) => {\n setDraft({ falsePositivePolicy: value });\n drawGuard();\n }\n )\n )\n );\n panel.appendChild(el("div", "note guard-explains", MODE_NOTES[draft.falsePositivePolicy] ?? ""));\n panel.appendChild(\n guardField(\n "Fallback",\n select(vocabulary.fallbackActions, draft.fallbackAction, (value) => setDraft({ fallbackAction: value })),\n "what a stopped rule becomes \u2014 the terminal actions are absent because a terminal fallback would deny the request the guard just protected"\n )\n );\n panel.appendChild(\n guardField(\n "Default action",\n select(vocabulary.actions, draft.defaultAction, (value) => setDraft({ defaultAction: value })),\n "when no rule matches"\n )\n );\n panel.appendChild(\n guardField(\n "Terminal score",\n number(draft.terminalScoreThreshold, (value) => setDraft({ terminalScoreThreshold: value })),\n "balanced mode only: the score a probabilistic verdict must clear"\n )\n );\n panel.appendChild(\n guardField(\n "Suspect at",\n number(draft.suspectThreshold, (value) => setDraft({ suspectThreshold: value })),\n "the score at which a request becomes suspected-bot"\n )\n );\n const bar = el("div", "bar-actions");\n const preview = el("button", null, "Preview");\n preview.addEventListener("click", () => {\n void previewGuard();\n });\n const apply = el("button", "primary", "Apply");\n apply.addEventListener("click", () => {\n void applyGuard();\n });\n const revert = el("button", null, "Revert");\n revert.addEventListener("click", () => {\n draft = { ...document_.guard };\n state.guardDirty = false;\n drawGuard();\n });\n bar.appendChild(preview);\n bar.appendChild(apply);\n bar.appendChild(revert);\n panel.appendChild(bar);\n }\n function setDraft(change) {\n if (draft === void 0) return;\n draft = { ...draft, ...change };\n state.guardDirty = true;\n $("guard-mode").textContent = "unsaved changes";\n }\n function resetGuardDraft() {\n draft = void 0;\n state.guardDirty = false;\n }\n function liveRules() {\n return (state.policy?.rules ?? []).filter((row) => row.editable && row.rule !== void 0).map((row) => row.rule);\n }\n async function previewGuard() {\n if (draft === void 0) return;\n const result = await postJson("/api/policy/preview", { rules: liveRules(), guard: draft });\n if (!result.ok) {\n showResult("bad", (box) => box.appendChild(el("div", null, result.error ?? "")));\n toast("bad", "Refused", result.error ?? "");\n return;\n }\n renderPreview(result.data, state.editorDirty ? ["Previewed against the rules currently in force, not the unsaved edits in the editor."] : []);\n app.showTab("policy");\n }\n async function applyGuard() {\n if (draft === void 0) return;\n const result = await postJson("/api/guard", draft);\n if (!result.ok) {\n showResult("bad", (box) => box.appendChild(el("div", null, result.error ?? "")));\n toast("bad", "Guard unchanged", result.error ?? "");\n return;\n }\n state.guardDirty = false;\n draft = { ...result.data.guard };\n if (state.policy !== void 0) state.policy.guard = { ...result.data.guard };\n toast("ok", "Guard changed", `${result.data.guard.falsePositivePolicy}, falling back to ${result.data.guard.fallbackAction}.`);\n showResult("warn", (box) => {\n box.appendChild(el("div", null, "The guard changed. It applies from the next request, and it is in the notices panel and in your logs."));\n if (result.data.guard.falsePositivePolicy !== "strict") {\n box.appendChild(\n el(\n "div",\n "ev-meta",\n "Requests can now be denied without proof. The guard-stop count is the series to watch: every stop that no longer happens is a request that used to be recoverable and is not any more."\n )\n );\n }\n });\n app.draw();\n }\n function guardField(name, control, hint) {\n const row = el("div", "field");\n row.appendChild(label(name, control));\n const right = el("div", "field-row");\n right.appendChild(control);\n if (hint !== void 0) right.appendChild(el("span", "hint", hint));\n row.appendChild(right);\n return row;\n }\n function segmented(options, value, onChange) {\n const box = el("div", "seg");\n for (const [label2, option] of options) {\n const button = el("button", null, label2);\n button.type = "button";\n button.setAttribute("aria-pressed", String(option === value));\n button.addEventListener("click", () => onChange(option));\n box.appendChild(button);\n }\n return box;\n }\n function select(options, value, onChange) {\n const node = el("select");\n for (const option of options) {\n const item = el("option", null, option);\n item.value = option;\n if (option === value) item.selected = true;\n node.appendChild(item);\n }\n node.addEventListener("change", () => onChange(node.value));\n return node;\n }\n function number(value, onChange) {\n const input = el("input");\n input.type = "number";\n input.min = "1";\n input.max = "100";\n input.value = String(value);\n input.addEventListener("input", () => {\n if (input.value !== "") onChange(Number(input.value));\n });\n return input;\n }\n\n // src/dashboard/client/ranges.ts\n var sets = [];\n async function loadRanges() {\n if (!SECTIONS.ranges) return;\n try {\n const body = await getJson("/api/ranges");\n sets = body.ranges;\n drawRanges();\n } catch {\n }\n }\n function drawRanges() {\n if (!SECTIONS.ranges) return;\n const body = $("ranges-body");\n clear(body);\n $("ranges-mode").textContent = BOOT.allowActing ? "editable" : "read-only";\n $("ranges-note").textContent = BOOT.allowActing ? "An address on the allowlist is not judged leniently \u2014 it is not judged at all. Detection does not run on it, no evidence is produced, and no rule sees it." : "These come from the code that constructed the handler. Enable controls.editRanges to add an address from here.";\n if (sets.length === 0) {\n body.appendChild(el("div", "note", "No range sets are configured. Add an address to the allowlist and one appears."));\n }\n for (const set of sets) {\n const block = el("div", "rangeset");\n const heading = el("h3");\n heading.appendChild(document.createTextNode(set.name));\n heading.appendChild(el("span", null, `${n(set.size)} entr${set.size === 1 ? "y" : "ies"}`));\n block.appendChild(heading);\n const list = el("div", "cidrs");\n for (const entry of set.entries) {\n const chip = el("span", BOOT.allowActing ? "cidr" : "cidr readonly");\n chip.appendChild(document.createTextNode(entry));\n if (BOOT.allowActing) {\n const remove = el("button", null, "\xD7");\n remove.title = `Remove ${entry} from ${set.name}`;\n remove.setAttribute("aria-label", `Remove ${entry} from ${set.name}`);\n remove.addEventListener("click", () => void update(set.name, { remove: [entry] }));\n chip.appendChild(remove);\n }\n list.appendChild(chip);\n }\n if (set.entries.length === 0) list.appendChild(el("span", "hint", "empty"));\n block.appendChild(list);\n body.appendChild(block);\n }\n if (!BOOT.allowActing) return;\n const form = el("div", "rangeset");\n const row = el("div", "field-row");\n const name = el("input", "mono-input");\n name.type = "text";\n name.value = "allowlist";\n name.setAttribute("aria-label", "Range set");\n name.style.maxWidth = "150px";\n const value = el("input", "mono-input");\n value.type = "text";\n value.placeholder = "203.0.113.0/24";\n value.setAttribute("aria-label", "Address or CIDR to add");\n const add = el("button", null, "Add");\n const submit = () => {\n const entry = value.value.trim();\n if (entry === "") return;\n value.value = "";\n void update(name.value.trim(), { add: [entry] });\n };\n add.addEventListener("click", submit);\n value.addEventListener("keydown", (event) => {\n if (event.key === "Enter") submit();\n });\n row.appendChild(name);\n row.appendChild(value);\n row.appendChild(add);\n form.appendChild(row);\n body.appendChild(form);\n }\n async function update(name, change) {\n const result = await postJson("/api/ranges", { name, ...change });\n if (!result.ok) {\n toast("bad", "Ranges unchanged", result.error ?? "");\n return;\n }\n toast(name === "allowlist" && change.add !== void 0 ? "warn" : "ok", `\u201C${name}\u201D updated`, `${n(result.data.entries?.length ?? 0)} entr${result.data.entries?.length === 1 ? "y" : "ies"} now.`);\n await loadRanges();\n }\n\n // src/dashboard/client/draft.ts\n function draftRule(entry, existingIds = []) {\n const match = {};\n let because;\n let stem;\n const proven = entry.evidence.filter((item) => item.certainty === "certain" && item.direction !== "human");\n const detectors = [...new Set((proven.length > 0 ? proven : entry.evidence.filter((item) => item.direction !== "human")).map((item) => item.detector))];\n if (entry.identity !== void 0 && entry.identity !== "") {\n match["identity"] = [entry.identity];\n if (entry.certain) match["certain"] = true;\n stem = entry.identity;\n because = entry.certain ? `Matched on the identity \u201C${entry.identity}\u201D, and on proof \u2014 so a client merely claiming that name does not match.` : `Matched on the claimed identity \u201C${entry.identity}\u201D. Nothing has verified it, so this matches anything that says so.`;\n } else if (proven.length > 0) {\n match["detector"] = detectors;\n match["certain"] = true;\n stem = detectors[0] ?? "proven";\n because = `Matched on proof from ${detectors.join(", ")}. Only requests that carry the same proof match.`;\n } else if (detectors.length > 0) {\n match["verdict"] = [entry.verdict];\n match["detector"] = detectors;\n match["minScore"] = Math.max(0, Math.floor(entry.score / 10) * 10);\n stem = detectors[0] ?? entry.verdict;\n because = `Matched on ${entry.verdict} at score ${String(match["minScore"])} or more, from ${detectors.join(", ")}. Every one of those is probabilistic, so the guard will not let this rule deny anybody.`;\n } else {\n match["verdict"] = [entry.verdict];\n stem = entry.verdict;\n because = `Nothing fired on this request, so there is nothing sharper to match on than the verdict itself. Narrow it before you use it.`;\n }\n return {\n rule: {\n id: uniqueId(`from-${slug(stem)}`, existingIds),\n match,\n action: "tag",\n reason: "Drafted from a request on the dashboard.",\n _open: true\n },\n because\n };\n }\n function slug(value) {\n const cleaned = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");\n return cleaned === "" ? "request" : cleaned.slice(0, 40);\n }\n function uniqueId(wanted, taken) {\n if (!taken.includes(wanted)) return wanted;\n for (let suffix = 2; suffix < 1e3; suffix++) {\n const candidate = `${wanted}-${suffix}`;\n if (!taken.includes(candidate)) return candidate;\n }\n return `${wanted}-${Date.now()}`;\n }\n\n // src/dashboard/client/policy.ts\n async function loadPolicy() {\n if (!SECTIONS.policy) return;\n try {\n const document_ = await getJson("/api/policy");\n state.policy = document_;\n if (!state.editorDirty) {\n setEditorRules(document_.rules.filter((row) => row.editable).map((row) => row.rule));\n }\n if (!state.guardDirty) resetGuardDraft();\n drawPolicyTab();\n } catch {\n }\n }\n function setEditorRules(rules) {\n state.editorRules = JSON.parse(JSON.stringify(rules ?? []));\n renderEditor();\n }\n function markDirty() {\n state.editorDirty = true;\n $("policy-dirty").hidden = false;\n }\n function editorRules() {\n if (state.editorMode === "json") {\n try {\n const parsed = JSON.parse(byId("policy-json").value);\n if (!Array.isArray(parsed)) return { error: "The JSON must be an array of rules." };\n return { rules: parsed };\n } catch (error) {\n return { error: `The editor does not contain valid JSON: ${String(error)}` };\n }\n }\n return { rules: state.editorRules };\n }\n function cleanRule(rule) {\n const match = {};\n for (const [key, value] of Object.entries(rule.match ?? {})) {\n if (value === void 0 || value === null || value === "") continue;\n if (Array.isArray(value) && value.length === 0) continue;\n match[key] = value;\n }\n const params = {};\n for (const [key, value] of Object.entries(rule.params ?? {})) {\n if (value === void 0 || value === null || value === "") continue;\n if (key === "limit") {\n const limit = value;\n if (limit.max === void 0 || limit.windowMs === void 0) continue;\n }\n params[key] = value;\n }\n const out = { id: rule.id, match, action: rule.action };\n if (Object.keys(params).length > 0) out["params"] = params;\n if (rule.reason !== void 0 && rule.reason !== "") out["reason"] = rule.reason;\n return out;\n }\n function cleanRules(rules) {\n return rules.map(cleanRule);\n }\n function field(name, control, hint) {\n const row = el("div", "field");\n row.appendChild(label(name, control));\n const right = el("div", "field-row");\n for (const node of Array.isArray(control) ? control : [control]) right.appendChild(node);\n if (hint !== void 0) right.appendChild(el("span", "hint", hint));\n row.appendChild(right);\n return row;\n }\n function chipSelect(options, selected, onChange) {\n const box = el("div", "chips-select");\n const chosen = Array.isArray(selected) ? [...selected] : selected === void 0 ? [] : [String(selected)];\n for (const option of options) {\n const button = el("button", null, option);\n button.type = "button";\n button.setAttribute("aria-pressed", String(chosen.includes(option)));\n button.addEventListener("click", () => {\n const at = chosen.indexOf(option);\n if (at === -1) chosen.push(option);\n else chosen.splice(at, 1);\n button.setAttribute("aria-pressed", String(at === -1));\n onChange(chosen.length === 0 ? void 0 : [...chosen]);\n });\n box.appendChild(button);\n }\n return box;\n }\n function segmented2(options, value, onChange) {\n const box = el("div", "seg");\n for (const [label2, option] of options) {\n const button = el("button", null, label2);\n button.type = "button";\n button.setAttribute("aria-pressed", String(option === value));\n button.addEventListener("click", () => {\n for (const other of Array.from(box.children)) other.setAttribute("aria-pressed", "false");\n button.setAttribute("aria-pressed", "true");\n onChange(option);\n });\n box.appendChild(button);\n }\n return box;\n }\n function textInput(value, placeholder, onChange, mono = false) {\n const input = el("input", mono ? "mono-input" : null);\n input.type = "text";\n input.value = value === void 0 || value === null ? "" : String(value);\n input.placeholder = placeholder;\n input.addEventListener("input", () => onChange(input.value.trim() === "" ? void 0 : input.value));\n return input;\n }\n function listInput(value, placeholder, onChange) {\n const current = value === void 0 ? "" : Array.isArray(value) ? value.join(", ") : String(value);\n const input = textInput(current, placeholder, () => {\n }, true);\n input.addEventListener("input", () => {\n const parts = input.value.split(",").map((part) => part.trim()).filter(Boolean);\n onChange(parts.length === 0 ? void 0 : parts.length === 1 ? parts[0] : parts);\n });\n return input;\n }\n function numberInput(value, placeholder, onChange) {\n const input = el("input");\n input.type = "number";\n input.value = value === void 0 || value === null ? "" : String(value);\n input.placeholder = placeholder;\n input.addEventListener("input", () => onChange(input.value === "" ? void 0 : Number(input.value)));\n return input;\n }\n function matchSummary(rule) {\n const box = el("div", "rule-summary");\n const match = rule.match ?? {};\n const parts = [];\n for (const key of ["verdict", "botClass", "category", "identity", "detector", "method", "path"]) {\n const value = match[key];\n if (value === void 0) continue;\n parts.push([key, Array.isArray(value) ? value.join(", ") : String(value)]);\n }\n if (match["certain"] !== void 0) parts.push(["certain", String(match["certain"])]);\n if (match["minScore"] !== void 0 || match["maxScore"] !== void 0) {\n parts.push(["score", `${String(match["minScore"] ?? 0)}\u2013${String(match["maxScore"] ?? 99)}`]);\n }\n if (match["minPriorConfirmations"] !== void 0) parts.push(["prior", String(match["minPriorConfirmations"])]);\n if (match["minUnsolvedChallenges"] !== void 0) parts.push(["unsolved", String(match["minUnsolvedChallenges"])]);\n if (parts.length === 0) {\n box.appendChild(el("span", "none", "matches everything"));\n return box;\n }\n for (const [key, value] of parts.slice(0, 4)) box.appendChild(el("span", "t", `${key}: ${value}`));\n if (parts.length > 4) box.appendChild(el("span", "k", `+${parts.length - 4} more`));\n return box;\n }\n function ruleCard(rule, index) {\n const vocabulary = state.policy?.vocabulary;\n const open = rule._open === true;\n const card = el("div", `rule${open ? "" : " collapsed"}`);\n const head = el("div", "rule-head");\n const chevron = el("button", "chev", open ? "\u25BE" : "\u25B8");\n chevron.title = open ? "Collapse" : "Expand";\n chevron.setAttribute("aria-expanded", String(open));\n chevron.addEventListener("click", () => {\n rule._open = !open;\n renderEditor();\n });\n head.appendChild(chevron);\n head.appendChild(el("span", "ord", index + 1));\n const id = textInput(rule.id, "rule-id", (value) => {\n rule.id = value ?? "";\n markDirty();\n }, true);\n id.setAttribute("aria-label", "Rule id");\n head.appendChild(id);\n if (open) {\n const action = el("select");\n action.setAttribute("aria-label", "Action");\n for (const name of vocabulary?.actions ?? []) {\n const option = el("option", null, name);\n option.value = name;\n if (name === rule.action) option.selected = true;\n action.appendChild(option);\n }\n action.addEventListener("change", () => {\n rule.action = action.value;\n rule.params = {};\n markDirty();\n renderEditor();\n });\n head.appendChild(action);\n } else {\n head.appendChild(matchSummary(rule));\n head.appendChild(el("span", `act-pill ${actionKind(rule.action)}`, rule.action));\n }\n const up = el("button", "icon", "\u2191");\n up.title = "Move earlier \u2014 the first matching rule wins";\n up.addEventListener("click", () => moveRule(index, -1));\n const down = el("button", "icon", "\u2193");\n down.title = "Move later";\n down.addEventListener("click", () => moveRule(index, 1));\n const remove = el("button", "icon danger", "Remove");\n remove.addEventListener("click", () => {\n state.editorRules.splice(index, 1);\n markDirty();\n renderEditor();\n });\n head.appendChild(up);\n head.appendChild(down);\n head.appendChild(remove);\n card.appendChild(head);\n if (!open) return card;\n const body = el("div", "rule-body");\n rule.match = rule.match ?? {};\n const match = rule.match;\n body.appendChild(field("Verdict", chipSelect(vocabulary?.verdicts ?? [], match["verdict"], (value) => {\n match["verdict"] = value;\n markDirty();\n })));\n body.appendChild(field("Bot class", chipSelect(vocabulary?.botClasses ?? [], match["botClass"], (value) => {\n match["botClass"] = value;\n markDirty();\n })));\n body.appendChild(field("Category", chipSelect(vocabulary?.categories ?? [], match["category"], (value) => {\n match["category"] = value;\n markDirty();\n })));\n body.appendChild(field("Detector", chipSelect(vocabulary?.detectors ?? [], match["detector"], (value) => {\n match["detector"] = value;\n markDirty();\n })));\n body.appendChild(field("Method", chipSelect(vocabulary?.methods ?? [], match["method"], (value) => {\n match["method"] = value;\n markDirty();\n })));\n body.appendChild(\n field(\n "Evidence",\n segmented2(\n [\n ["any", void 0],\n ["proven", true],\n ["unproven", false]\n ],\n match["certain"],\n (value) => {\n match["certain"] = value;\n markDirty();\n }\n ),\n "proven means at least one piece of certain evidence \u2014 including a proven human"\n )\n );\n body.appendChild(\n field("Score", [\n numberInput(match["minScore"], "min", (value) => {\n match["minScore"] = value;\n markDirty();\n }),\n el("span", "hint", "to"),\n numberInput(match["maxScore"], "max", (value) => {\n match["maxScore"] = value;\n markDirty();\n })\n ])\n );\n body.appendChild(field("Identity", listInput(match["identity"], "googlebot, gptbot", (value) => {\n match["identity"] = value;\n markDirty();\n })));\n body.appendChild(field("Path", listInput(match["path"], "/api/, /search", (value) => {\n match["path"] = value;\n markDirty();\n }), "a string matches as a prefix"));\n body.appendChild(\n field(\n "Prior bots",\n numberInput(match["minPriorConfirmations"], "0", (value) => {\n match["minPriorConfirmations"] = value;\n markDirty();\n }),\n "times this actor was already proven a bot"\n )\n );\n body.appendChild(\n field(\n "Unsolved",\n numberInput(match["minUnsolvedChallenges"], "0", (value) => {\n match["minUnsolvedChallenges"] = value;\n markDirty();\n }),\n "challenges issued to this actor that were never answered \u2014 solving one clears the count"\n )\n );\n rule.params = rule.params ?? {};\n const params = rule.params;\n for (const node of paramFields(rule.action, params)) body.appendChild(node);\n body.appendChild(field("Reason", textInput(rule.reason, "shown in the decision and in your logs", (value) => {\n rule.reason = value ?? "";\n markDirty();\n })));\n card.appendChild(body);\n return card;\n }\n function paramFields(action, params) {\n switch (action) {\n case "block":\n return [\n field("Status", numberInput(params["status"], "403", (value) => {\n params["status"] = value;\n markDirty();\n })),\n field("Body", textInput(params["body"], "Automated traffic is not served here.", (value) => {\n params["body"] = value;\n markDirty();\n }))\n ];\n case "redirect":\n return [field("Location", textInput(params["location"], "/too-fast", (value) => {\n params["location"] = value;\n markDirty();\n }, true))];\n case "delay":\n return [field("Delay", numberInput(params["delayMs"], "250", (value) => {\n params["delayMs"] = value;\n markDirty();\n }), "milliseconds")];\n case "rate-limit": {\n const limit = params["limit"] ?? {};\n params["limit"] = limit;\n return [\n field("Limit", [\n numberInput(limit["max"], "60", (value) => {\n limit["max"] = value;\n markDirty();\n }),\n el("span", "hint", "requests per"),\n numberInput(limit["windowMs"], "60000", (value) => {\n limit["windowMs"] = value;\n markDirty();\n }),\n el("span", "hint", "ms")\n ])\n ];\n }\n case "custom":\n return [field("Handler", textInput(params["handler"], "handler-id", (value) => {\n params["handler"] = value;\n markDirty();\n }, true), "id of a handler you registered")];\n default:\n return [];\n }\n }\n function lockedCard(row) {\n const card = el("div", "rule locked");\n const head = el("div", "rule-head");\n head.appendChild(el("span", "ord", `#${row.index + 1}`));\n head.appendChild(el("span", "mono", row.id));\n head.appendChild(el("span", "grow"));\n head.appendChild(el("span", "pill", "predicate \u2014 locked"));\n card.appendChild(head);\n card.appendChild(\n el("div", "rule-body", "This rule matches with a function, which cannot be represented here or sent over HTTP. It stays exactly as it is, at this position, whatever else you change.")\n );\n return card;\n }\n function moveRule(index, delta) {\n const target = index + delta;\n if (target < 0 || target >= state.editorRules.length) return;\n const moved = state.editorRules.splice(index, 1)[0];\n if (moved === void 0) return;\n state.editorRules.splice(target, 0, moved);\n markDirty();\n renderEditor();\n }\n function renderEditor() {\n if (!SECTIONS.policy) return;\n const list = $("rulelist");\n clear(list);\n const locked = (state.policy?.rules ?? []).filter((row) => !row.editable);\n if (state.editorRules.length === 0 && locked.length === 0) {\n list.appendChild(el("div", "note", "No rules. Every request takes the default action \u2014 add one, or import a set."));\n }\n const rendered2 = state.editorRules.map((rule, index) => ruleCard(rule, index));\n for (const row of locked) rendered2.splice(Math.min(row.index, rendered2.length), 0, lockedCard(row));\n for (const node of rendered2) list.appendChild(node);\n if (state.editorMode === "json") byId("policy-json").value = JSON.stringify(cleanRules(state.editorRules), null, 2);\n }\n function drawPolicyTab() {\n const document_ = state.policy;\n if (document_ === void 0) return;\n const editable = document_.editable;\n $("policy-apply").hidden = !editable;\n byId("policy-json").readOnly = !editable;\n $("policy-mode").textContent = editable ? "editable" : "read-only";\n $("rule-add").hidden = !editable;\n $("policy-import").hidden = !editable;\n const preserved = document_.rules.filter((row) => !row.editable);\n let note = editable ? "First match wins \u2014 order matters." : "Read-only. Enable controls.editPolicy to change these.";\n if (preserved.length > 0) note += ` ${preserved.length} rule(s) use a predicate function and are locked.`;\n $("policy-note").textContent = note;\n renderPresetButtons();\n drawGuard();\n drawRanges();\n if (SECTIONS.robots) {\n $("robots-preview").textContent = document_.robots === "" ? "(this policy declines no crawler by name)" : document_.robots;\n const notes = $("robots-notes");\n clear(notes);\n for (const note_ of document_.robotsNotes) notes.appendChild(el("div", "ev-meta", `${note_.rule}: ${note_.reason}`));\n }\n const rules = $("stat-rules");\n clear(rules);\n const installed = state.snapshot?.rules ?? [];\n if (installed.length === 0) rules.appendChild(el("div", "note", "No rules configured \u2014 every request takes the default action."));\n else installed.forEach((rule, index) => rules.appendChild(el("span", "chip", `${index + 1}. ${rule}`)));\n }\n async function draftIntoEditor(entry) {\n if (state.policy === void 0) await loadPolicy();\n const drafted = draftRule(entry, state.editorRules.map((rule) => rule.id));\n state.editorRules.push(drafted.rule);\n markDirty();\n if (state.editorMode === "json") byId("policy-json").value = JSON.stringify(cleanRules(state.editorRules), null, 2);\n renderEditor();\n app.showTab("policy");\n const notes = [\n `Drafted \u201C${drafted.rule.id}\u201D, tagging only. Nothing is applied \u2014 read it, choose the action, then apply.`,\n drafted.because,\n "It was added last, which is the only position that cannot change what an existing rule does. Move it up with \u2191 if it needs to win."\n ];\n showResult("warn", (box) => {\n for (const note of notes) box.appendChild(el("div", note === notes[0] ? null : "ev-meta", note));\n });\n toast("ok", "Rule drafted", "In the editor, tagging only, not applied.");\n await runPreview(notes);\n }\n function switchToGui() {\n if (state.editorMode === "gui") return;\n const parsed = editorRules();\n if (parsed.error !== void 0) {\n toast("bad", "That JSON will not parse", parsed.error);\n return;\n }\n state.editorRules = parsed.rules ?? [];\n state.editorMode = "gui";\n $("mode-gui").setAttribute("aria-pressed", "true");\n $("mode-json").setAttribute("aria-pressed", "false");\n $("editor-gui").hidden = false;\n $("editor-json").hidden = true;\n renderEditor();\n }\n function switchToJson() {\n state.editorMode = "json";\n $("mode-gui").setAttribute("aria-pressed", "false");\n $("mode-json").setAttribute("aria-pressed", "true");\n $("editor-gui").hidden = true;\n $("editor-json").hidden = false;\n byId("policy-json").value = JSON.stringify(cleanRules(state.editorRules), null, 2);\n }\n async function exportSettings() {\n try {\n const response = await fetch(`${API}/api/settings`);\n const text = await response.text();\n if (!response.ok) throw new Error(text);\n download(text, `bothandler-settings-${today()}.json`, "application/json");\n toast("ok", "Settings exported", "Rules, plus a record of the configuration around them.");\n } catch (error) {\n toast("bad", "Export failed", String(error));\n }\n }\n function readSettingsFile(file) {\n const reader = new FileReader();\n reader.onload = () => {\n try {\n applyImported(JSON.parse(String(reader.result)), file.name);\n } catch (error) {\n toast("bad", "That file is not JSON", String(error));\n }\n };\n reader.onerror = () => toast("bad", "Could not read that file", "");\n reader.readAsText(file);\n }\n function applyImported(document_, name) {\n const rules = Array.isArray(document_) ? document_ : Array.isArray(document_.rules) ? document_.rules : void 0;\n if (rules === void 0) {\n toast("bad", "Nothing to import", "Expected an array of rules, or a settings file with a rules array.");\n return;\n }\n setEditorRules(rules);\n markDirty();\n switchToGui();\n const ignored = [];\n const readOnly = document_.readOnly;\n if (readOnly !== void 0) {\n ignored.push("the guard, the detectors, the ranges and the audit \u2014 those come from the code that built the handler, not from a file");\n if (Array.isArray(readOnly.lockedRules) && readOnly.lockedRules.length > 0) {\n ignored.push(`${readOnly.lockedRules.length} predicate rule(s), which stay as they are`);\n }\n }\n showResult("warn", (box) => {\n box.appendChild(el("div", null, `Loaded ${rules.length} rule(s) from ${name}. Nothing has been applied yet \u2014 preview it first.`));\n for (const line of ignored) box.appendChild(el("div", "ev-meta", `Ignored: ${line}`));\n });\n toast("ok", `Imported ${rules.length} rule(s)`, "Review, preview, then apply.");\n }\n function collectForSubmit() {\n const parsed = editorRules();\n if (parsed.error !== void 0) {\n showResult("bad", (box) => box.appendChild(el("div", null, parsed.error ?? "")));\n toast("bad", "That JSON will not parse", parsed.error);\n return void 0;\n }\n const cleaned = cleanRules(parsed.rules ?? []);\n const blank = cleaned.filter((rule) => rule["id"] === void 0 || rule["id"] === "").length;\n if (blank > 0) {\n toast("bad", "Every rule needs an id", "It is what every decision and log line names.");\n return void 0;\n }\n return cleaned;\n }\n async function runPreview(notes = []) {\n const rules = collectForSubmit();\n if (rules === void 0) return;\n const result = await postJson("/api/policy/preview", { rules });\n if (!result.ok) {\n showResult("bad", (box) => box.appendChild(el("div", null, result.error ?? "")));\n toast("bad", "Refused", result.error ?? "");\n return;\n }\n renderPreview(result.data, notes);\n }\n async function applyPolicy() {\n const rules = collectForSubmit();\n if (rules === void 0) return;\n const result = await postJson("/api/policy/apply", { rules });\n if (!result.ok) {\n showResult("bad", (box) => box.appendChild(el("div", null, result.error ?? "")));\n toast("bad", "Refused", result.error ?? "");\n return;\n }\n state.editorDirty = false;\n $("policy-dirty").hidden = true;\n await loadPolicy();\n toast("ok", "Applied", `${n(result.data.rules)} rule(s) now in force.`);\n showResult("ok", (box) => {\n box.appendChild(el("div", null, `Applied. ${n(result.data.rules)} rule(s) are now in force.`));\n for (const warning of result.data.warnings ?? []) box.appendChild(el("div", "ev-meta", warning));\n });\n }\n function renderPresetButtons() {\n const box = $("preset-buttons");\n const presets = state.policy?.vocabulary.presets ?? [];\n if (box.childElementCount === presets.length) return;\n clear(box);\n for (const preset of presets) {\n const button = el("button", null, preset);\n button.addEventListener("click", () => {\n void (async () => {\n const result = await postJson("/api/policy/preview", { preset });\n if (!result.ok) {\n toast("bad", "Refused", result.error ?? "");\n return;\n }\n renderPreview(result.data);\n })();\n });\n box.appendChild(button);\n }\n }\n function initPolicy() {\n if (!SECTIONS.policy) return;\n $("mode-gui").addEventListener("click", switchToGui);\n $("mode-json").addEventListener("click", switchToJson);\n $("rule-add").addEventListener("click", () => {\n state.editorRules.push({ id: `new-rule-${state.editorRules.length + 1}`, match: {}, action: "tag", params: {}, _open: true });\n markDirty();\n renderEditor();\n });\n $("rule-expand").addEventListener("click", () => {\n const anyClosed = state.editorRules.some((rule) => rule._open !== true);\n for (const rule of state.editorRules) rule._open = anyClosed;\n $("rule-expand").textContent = anyClosed ? "Collapse all" : "Expand all";\n renderEditor();\n });\n byId("policy-json").addEventListener("input", markDirty);\n $("policy-preview").addEventListener("click", () => void runPreview());\n $("policy-apply").addEventListener("click", () => void applyPolicy());\n $("policy-revert").addEventListener("click", () => {\n state.editorDirty = false;\n $("policy-dirty").hidden = true;\n $("policy-result").hidden = true;\n resetGuardDraft();\n void loadPolicy();\n });\n $("policy-export").addEventListener("click", () => void exportSettings());\n $("policy-import").addEventListener("click", () => byId("policy-file").click());\n byId("policy-file").addEventListener("change", () => {\n const input = byId("policy-file");\n const file = input.files?.[0];\n if (file !== void 0) readSettingsFile(file);\n input.value = "";\n });\n const panel = $("editor-panel");\n for (const name of ["dragenter", "dragover"]) {\n panel.addEventListener(name, (event) => {\n if (state.policy?.editable !== true) return;\n event.preventDefault();\n panel.classList.add("drop");\n });\n }\n for (const name of ["dragleave", "drop"]) {\n panel.addEventListener(name, (event) => {\n panel.classList.remove("drop");\n if (name !== "drop") return;\n event.preventDefault();\n const file = event.dataTransfer?.files?.[0];\n if (file !== void 0) readSettingsFile(file);\n });\n }\n }\n\n // src/dashboard/client/feed.ts\n var FILTERS = [\n ["all", "All"],\n ["proven", "Proven"],\n ["suspected", "Suspected"],\n ["human", "Human"],\n ["guard", "Guard stops"],\n ["deny", "Denied"],\n ["mitigate", "Mitigated"],\n ["allow", "Served"]\n ];\n var rendered = /* @__PURE__ */ new Map();\n function initFeed() {\n const loadThem = byId("feed-load-skipped");\n loadThem.addEventListener("click", () => {\n loadThem.disabled = true;\n void loadSkipped().finally(() => {\n loadThem.disabled = false;\n });\n });\n const filters = $("filters");\n for (const [name, label2] of FILTERS) {\n const button = el("button", null, label2);\n button.setAttribute("aria-pressed", String(name === state.filter));\n button.dataset["filter"] = name;\n button.addEventListener("click", () => {\n state.filter = name;\n resetPaging();\n for (const other of Array.from(filters.children)) other.setAttribute("aria-pressed", "false");\n button.setAttribute("aria-pressed", "true");\n app.syncUrl();\n app.drawNow();\n });\n filters.appendChild(button);\n }\n const search = byId("search");\n search.addEventListener("input", () => {\n setSearch(search.value.trim());\n app.syncUrl();\n app.drawNow();\n });\n const exportShown = byId("feed-export");\n exportShown.hidden = !SECTIONS.evidence;\n exportShown.addEventListener("click", () => {\n const rows = matchingRows();\n if (rows.length === 0) {\n toast("warn", "Nothing to export", "No request in the window matches this filter.");\n return;\n }\n const entries = rows.map((row) => row.entry).reverse();\n download(`${replayFile(entries)}\n`, `bothandler-feed-${today()}.jsonl`, "application/x-ndjson");\n toast("ok", `Exported ${n(entries.length)} request(s)`, "Replay them with `bothandlerjs replay`.");\n });\n }\n function reflectFilterButtons() {\n for (const button of Array.from($("filters").children)) {\n button.setAttribute("aria-pressed", String(button instanceof HTMLElement && button.dataset["filter"] === state.filter));\n }\n const search = byId("search");\n if (search.value !== state.search) search.value = state.search;\n }\n async function loadSkipped() {\n const before = state.rows.length;\n try {\n const body = await getJson("/api/feed");\n for (const entry of body.entries) ingest(entry);\n sortRows();\n } catch {\n toast("bad", "Could not load them", "The dashboard did not answer. The entries are still in the window; try again.");\n return;\n }\n state.caughtUp = (state.snapshot?.skipped ?? 0) + state.laggedDrops;\n const added = state.rows.length - before;\n resetPaging();\n app.drawNow();\n toast(\n added > 0 ? "ok" : "warn",\n added > 0 ? `Loaded ${n(added)}` : "Nothing left to load",\n added > 0 ? "They are in the feed now, in the order they happened." : "The window no longer holds them; the ring had already rotated past."\n );\n }\n var FEED_PAGE_SIZES = [25, 50, 100, 200];\n function drawPager(paged) {\n const size = state.feedPageSize;\n const hidden = paged.pages <= 1;\n const model = {\n page: paged.page,\n from: paged.page * size + 1,\n to: Math.min(paged.total, (paged.page + 1) * size),\n total: paged.total,\n atStart: paged.page === 0,\n atEnd: paged.page >= paged.pages - 1,\n ...paged.page > 0 ? { held: "held while you read" } : {},\n go: (page) => {\n goToFeedPage(page);\n app.drawNow();\n },\n size: {\n current: size,\n choices: FEED_PAGE_SIZES,\n set: (next) => {\n state.feedPageSize = next;\n resetPaging();\n app.drawNow();\n }\n }\n };\n for (const [id, withSize] of [\n ["feed-pager-top", true],\n ["feed-pager", false]\n ]) {\n const host = $(id);\n host.hidden = hidden;\n if (hidden) clear(host);\n else renderPager(host, model, { withSize });\n }\n }\n function drawFeed() {\n const body = byId("rows");\n const paged = feedPage(state.feedPageSize);\n const shown = paged.rows;\n let index = 0;\n const place = (node) => {\n const current = body.childNodes[index] ?? null;\n if (current !== node) body.insertBefore(node, current);\n index++;\n };\n for (const row of shown) {\n const id = row.entry.requestId;\n const open = state.open.has(id);\n let cached = rendered.get(id);\n if (cached === void 0 || cached.rev !== row.rev || cached.open !== open) {\n cached = {\n row: buildRow(row.entry, open),\n detail: open ? buildDetail(row.entry) : void 0,\n rev: row.rev,\n open\n };\n rendered.set(id, cached);\n }\n place(cached.row);\n if (cached.detail !== void 0) place(cached.detail);\n }\n while (body.childNodes.length > index) body.removeChild(body.childNodes[index]);\n if (rendered.size > shown.length * 2 + 100) {\n const live = new Set(shown.map((row) => row.entry.requestId));\n for (const id of Array.from(rendered.keys())) if (!live.has(id)) rendered.delete(id);\n }\n const total = state.rows.length;\n const matching = matchingCount();\n $("empty").hidden = total > 0;\n $("feed-count").textContent = matching === total ? `${n(total)} in this window` : `${n(matching)} of ${n(total)}`;\n drawPager(paged);\n const skipped = Math.max(0, (state.snapshot?.skipped ?? 0) + state.laggedDrops - state.caughtUp);\n const note = $("feed-skipped");\n note.hidden = skipped === 0;\n note.textContent = `${n(skipped)} not streamed`;\n byId("feed-load-skipped").hidden = skipped === 0;\n note.title = state.laggedDrops > 0 ? `${n(state.laggedDrops)} were skipped because this connection could not keep up, and the rest by the rate cap. All of them are still in the window, the preview and the export.` : "Entries the rate cap kept off this stream. They are still in the window, the preview and the export \u2014 raise maxEventsPerSecond to see them live.";\n }\n function resetFeedCache() {\n rendered.clear();\n }\n function buildRow(entry, open) {\n const out = outcome(entry);\n const tr = el("tr", `row a-${entry.downgradedFrom !== void 0 ? "guard" : out}${open ? " open" : ""}`);\n tr.appendChild(el("td", "num mono tnum when", clockTime(entry.at)));\n const request = el("td", "edge req");\n const toggle = el("button", "row-toggle", `${entry.method} ${entry.path}`);\n toggle.type = "button";\n toggle.setAttribute("aria-expanded", String(open));\n toggle.setAttribute("aria-label", `${entry.method} ${entry.path}, ${entry.verdict}. Evidence.`);\n toggle.dataset["request"] = entry.requestId;\n request.appendChild(toggle);\n const ua = el("span", "ua");\n if (SECTIONS.actors) {\n const actorLink = el("a", null, entry.actor);\n actorLink.href = "#actor";\n actorLink.title = "Show everything from this actor";\n actorLink.addEventListener("click", (event) => {\n event.preventDefault();\n event.stopPropagation();\n openActor(entry.actor);\n });\n ua.appendChild(actorLink);\n ua.appendChild(document.createTextNode(` \xB7 ${entry.userAgent}`));\n } else {\n ua.appendChild(document.createTextNode(`${entry.actor} \xB7 ${entry.userAgent}`));\n }\n ua.title = `${entry.actor} \xB7 ${entry.userAgent}`;\n request.appendChild(ua);\n tr.appendChild(request);\n const verdictCell = el("td");\n const [badgeClass, badgeLabel] = verdictBadge(entry);\n verdictCell.appendChild(el("span", `badge ${badgeClass}`, badgeLabel));\n if (entry.identity !== void 0) verdictCell.appendChild(el("span", "sub", entry.identity));\n tr.appendChild(verdictCell);\n tr.appendChild(el("td", "num mono tnum", entry.certain ? "proven" : String(entry.score)));\n const actionCell = el("td");\n if (entry.action !== void 0) {\n const kind = out === "deny" ? "act-deny" : out === "mitigate" ? "act-mitigate" : out === "allow" ? "act-allow" : "act-tag";\n actionCell.appendChild(el("span", `act ${kind}`, entry.action));\n if (entry.rule !== void 0) {\n const ruleLabel = el("span", "sub", entry.rule);\n ruleLabel.title = entry.rule;\n actionCell.appendChild(ruleLabel);\n }\n if (entry.downgradedFrom !== void 0) actionCell.appendChild(el("span", "guard", `guard stopped ${entry.downgradedFrom}`));\n } else {\n actionCell.appendChild(el("span", "sub", "assessed only"));\n }\n tr.appendChild(actionCell);\n tr.appendChild(el("td", "num mono tnum", entry.durationMs.toFixed(2)));\n tr.dataset["request"] = entry.requestId;\n const flip = () => {\n if (state.open.has(entry.requestId)) state.open.delete(entry.requestId);\n else state.open.add(entry.requestId);\n app.drawNow();\n rootNode().querySelector(`button.row-toggle[data-request="${cssEscape(entry.requestId)}"]`)?.focus();\n };\n toggle.addEventListener("click", (event) => {\n event.stopPropagation();\n flip();\n });\n tr.addEventListener("click", flip);\n return tr;\n }\n function buildDetail(entry) {\n const tr = el("tr", "detail");\n const cell = el("td");\n cell.colSpan = 6;\n if (!SECTIONS.evidence) {\n cell.appendChild(\n el(\n "div",\n "ev-meta",\n "The evidence section is switched off on this dashboard, so the reasons behind this verdict are not sent to it. What fired, and why, is on a dashboard that has `sections: { evidence: true }`."\n )\n );\n } else if (entry.evidence.length === 0) {\n cell.appendChild(\n el(\n "div",\n "ev-meta",\n entry.bypass !== void 0 ? `Detection was skipped for this request: ${entry.bypass}.` : "No detector produced any evidence. This is what ordinary traffic looks like."\n )\n );\n } else {\n const list = el("div", "ev");\n for (const item of entry.evidence) {\n const row = el("div", `ev-item${item.direction === "human" ? " human" : ""}`);\n row.appendChild(el("div", `tier t-${item.certainty}`, item.certainty));\n const body = el("div");\n body.appendChild(el("div", null, item.summary));\n let meta = `${item.detector} \xB7 points to ${item.direction}`;\n if (item.family !== void 0) meta += ` \xB7 family \u201C${item.family}\u201D, counted once with its siblings`;\n body.appendChild(el("div", "ev-meta", meta));\n if (item.deterministicBasis !== void 0) body.appendChild(el("div", "basis", item.deterministicBasis));\n row.appendChild(body);\n list.appendChild(row);\n }\n cell.appendChild(list);\n }\n for (const failure of entry.failures) {\n cell.appendChild(el("div", "ev-meta", `Detector ${failure.detector} ${failure.reason}: ${failure.message}`));\n }\n if (entry.downgradeReason !== void 0) cell.appendChild(el("div", "basis", `Guard: ${entry.downgradeReason}`));\n const queryNames = Object.keys(entry.query);\n if (queryNames.length > 0) {\n const queryTable = el("table", "hdr");\n for (const name of queryNames) {\n const value = entry.query[name] ?? "";\n const row = el("tr");\n row.appendChild(el("td", "n", `?${name}`));\n row.appendChild(el("td", `v${value === "[redacted]" ? " red" : ""}`, value));\n queryTable.appendChild(row);\n }\n cell.appendChild(queryTable);\n }\n if (entry.headers !== void 0 && entry.headers.length > 0) {\n const table = el("table", "hdr");\n for (const [name, value] of entry.headers) {\n const row = el("tr");\n row.appendChild(el("td", "n", `${name}:`));\n row.appendChild(el("td", `v${value === "[redacted]" ? " red" : ""}`, value));\n table.appendChild(row);\n }\n cell.appendChild(table);\n }\n const tools = el("div", "tools");\n if (SECTIONS.evidence) {\n tools.appendChild(copyButton("Copy replay line", () => replayLine(entry)));\n tools.appendChild(downloadButton("Download replay line", () => replayLine(entry), `request-${entry.requestId}.jsonl`));\n tools.appendChild(copyButton("Copy corpus case", () => corpusCase(entry)));\n }\n if (SECTIONS.policy) {\n const draft2 = el("button", null, "Draft a rule");\n draft2.title = "Start a rule from this request, in the policy editor";\n draft2.addEventListener("click", (event) => {\n event.stopPropagation();\n void draftIntoEditor(entry);\n });\n tools.appendChild(draft2);\n }\n if (SECTIONS.actors) {\n const actorButton = el("button", null, "Show this actor");\n actorButton.addEventListener("click", (event) => {\n event.stopPropagation();\n openActor(entry.actor);\n });\n tools.appendChild(actorButton);\n }\n cell.appendChild(tools);\n const foot = el("div", "detail-foot");\n foot.appendChild(el("span", null, clockTime(entry.at)));\n foot.appendChild(el("span", null, `actor ${entry.actor}`));\n if (entry.rule !== void 0) foot.appendChild(el("span", null, `rule \u201C${entry.rule}\u201D`));\n foot.appendChild(el("span", null, `assessed in ${entry.durationMs.toFixed(3)}ms`));\n foot.appendChild(el("span", "mono", entry.requestId));\n cell.appendChild(foot);\n tr.appendChild(cell);\n return tr;\n }\n function copyButton(label2, produce) {\n const button = el("button", null, label2);\n button.addEventListener("click", (event) => {\n event.stopPropagation();\n const text = produce();\n const done = () => {\n button.textContent = "Copied";\n setTimeout(() => {\n button.textContent = label2;\n }, 1200);\n };\n if (navigator.clipboard?.writeText !== void 0) navigator.clipboard.writeText(text).then(done, () => showText(text));\n else showText(text);\n });\n return button;\n }\n function downloadButton(label2, produce, filename) {\n const button = el("button", null, label2);\n button.addEventListener("click", (event) => {\n event.stopPropagation();\n download(`${produce()}\n`, filename, "application/x-ndjson");\n });\n return button;\n }\n function showText(text) {\n const box = el("pre", "code", text);\n const host = $("policy-result");\n host.hidden = false;\n host.className = "result";\n clear(host);\n host.appendChild(el("div", "ev-meta", "Copying needs a secure context; here is the text."));\n host.appendChild(box);\n const selection = getSelection();\n if (selection !== null) {\n const range = document.createRange();\n range.selectNodeContents(box);\n selection.removeAllRanges();\n selection.addRange(range);\n }\n }\n\n // src/dashboard/client/stream.ts\n var source;\n function connectStream() {\n if (!SECTIONS.feed) {\n $("dot").className = "dot";\n $("conn").textContent = "feed off";\n return;\n }\n if (source !== void 0) return;\n source = new EventSource(`${API}/api/stream`);\n source.addEventListener("open", () => {\n $("dot").className = "dot on";\n $("conn").textContent = "live";\n });\n source.addEventListener("sync", (event) => {\n const detail = JSON.parse(event.data);\n if (!detail.replace) return;\n clearFeed();\n resetFeedCache();\n });\n source.addEventListener("entry", (event) => {\n ingest(JSON.parse(event.data));\n if (state.paused) {\n state.bufferedWhilePaused++;\n $("pause").textContent = `Resume (${state.bufferedWhilePaused})`;\n }\n app.draw();\n });\n source.addEventListener("update", (event) => {\n ingest(JSON.parse(event.data));\n app.draw();\n });\n source.addEventListener("reset", () => {\n clearFeed();\n resetFeedCache();\n app.draw();\n });\n source.addEventListener("lagged", (event) => {\n const detail = JSON.parse(event.data);\n state.laggedDrops += detail.dropped;\n app.draw();\n });\n source.addEventListener("stats", (event) => {\n state.snapshot = JSON.parse(event.data);\n app.draw();\n });\n source.addEventListener("error", () => {\n $("dot").className = "dot off";\n $("conn").textContent = "reconnecting\u2026";\n });\n }\n async function loadInitialSnapshot() {\n try {\n state.snapshot = await getJson("/api/stats");\n app.drawNow();\n } catch {\n }\n }\n\n // src/dashboard/client/panels.ts\n var paintedSnapshot;\n function drawTiles(force = false) {\n if (!SECTIONS.statistics) return;\n const box = $("tiles");\n const snapshot = state.snapshot;\n if (snapshot === void 0) return;\n if (!force && paintedSnapshot === snapshot) return;\n paintedSnapshot = snapshot;\n const metrics = snapshot.metrics;\n clear(box);\n if (metrics === void 0) {\n box.appendChild(el("div", "note", "Counters are switched off on this handler (metrics: false). The live feed still works."));\n return;\n }\n const actions = metrics.actions;\n const denied = actions.block + actions.drop + actions.redirect;\n const mitigated = actions.challenge + actions["rate-limit"] + actions.delay;\n const served = actions.allow + actions.tag + actions.log;\n const total = metrics.requests;\n const unremarkable = metrics.verdicts.unknown + metrics.verdicts.human;\n const provenBotCount = provenBots(metrics.verdicts);\n const actors = SECTIONS.registry ? { tab: "actors", label: "Actors" } : void 0;\n const tiles = [\n ["", n(total), "Requests", "since start", void 0],\n ["proven", n(provenBotCount), "Proven bots", `${pct(provenBotCount, total)} of traffic`, void 0],\n ["warn", n(metrics.verdicts["suspected-bot"]), "Suspected", "never denied alone", void 0],\n ["", n(unremarkable), "Unremarkable", `${pct(unremarkable, total)} of traffic`, void 0],\n ["warn", n(metrics.downgrades), "Guard stops", metrics.downgrades > 0 ? "a rule over-reached" : "no rule overreached", void 0],\n ["crit", n(denied), "Denied", `${pct(denied, total)} of traffic`, void 0],\n [\n "",\n n(mitigated),\n "Mitigated",\n metrics.challenges.issued > 0 ? `${n(metrics.challenges.solved)} of ${n(metrics.challenges.issued)} challenges solved` : "challenged or limited",\n void 0\n ],\n ["good", n(served), "Served", `${pct(served, total)} of traffic`, void 0],\n ["", n(metrics.actorsTracked), "Actors tracked", "in the registry now", actors]\n ];\n for (const [kind, value, key, sub, goes] of tiles) {\n const tile = goes === void 0 ? el("div", `tile ${kind}`) : el("button", `tile ${kind} go`);\n tile.appendChild(el("div", "v tnum", value));\n tile.appendChild(el("div", "k", key));\n tile.appendChild(el("div", "s", sub));\n if (goes !== void 0) {\n tile.type = "button";\n tile.appendChild(el("span", "sr-only", `. Show the ${goes.label} screen`));\n tile.addEventListener("click", () => app.showTab(goes.tab, { replace: false }));\n }\n box.appendChild(tile);\n }\n }\n function drawChips() {\n const snapshot = state.snapshot;\n if (snapshot === void 0) return;\n const box = $("chips");\n clear(box);\n const facts = [\n // Which process this is, first, because everything after it is a fact about this\n // process and nothing else. Behind a load balancer there are as many of these\n // dashboards as there are pods, each showing its own share of the traffic.\n ["instance", snapshot.instance],\n ["guard", snapshot.policy.falsePositivePolicy],\n ["suspect at", String(snapshot.policy.suspectThreshold)]\n ];\n if (SECTIONS.statistics) facts.push(["detectors", String(snapshot.detectors.length)]);\n if (SECTIONS.policy) facts.push(["rules", String(snapshot.rules.length)]);\n facts.push(["uptime", uptime(snapshot.now - snapshot.startedAt)]);\n for (const [label2, value] of facts) {\n const fact = el("span");\n fact.appendChild(document.createTextNode(`${label2} `));\n fact.appendChild(el("b", null, value));\n box.appendChild(fact);\n }\n }\n function updateWindowLabels() {\n const label2 = windowLabel(state.rows.length, oldestAt(), Date.now());\n for (const node of Array.from(rootNode().querySelectorAll(".win"))) node.textContent = label2;\n }\n function drawLivePanels() {\n if (!SECTIONS.statistics) return;\n const totals = aggregate(state.rows);\n drawBars($("live-detectors"), totals.detectors, "Nothing has fired in this window.");\n if (SECTIONS.actors) drawBars($("live-actors"), totals.actors, "No traffic in this window.");\n }\n function drawStatsPanels() {\n const snapshot = state.snapshot;\n if (snapshot === void 0) return;\n const metrics = snapshot.metrics;\n if (metrics !== void 0) {\n drawBars($("stat-verdicts"), pairs(metrics.verdicts), "Nothing assessed yet.");\n drawBars($("stat-actions"), pairs(metrics.actions), "No decisions yet.");\n drawBars($("stat-classes"), pairs(metrics.botClasses), "Nothing classified yet.");\n drawBars($("stat-detectors"), pairs(metrics.detectorFirings), "No detector has produced evidence yet.");\n const challenges = $("stat-challenges");\n clear(challenges);\n if (!snapshot.policy.challengeEnabled) {\n challenges.appendChild(el("div", "note", "No challenge is configured, so a rule asking for one degrades to a tag. Set challenge.secrets to enable it."));\n } else {\n const funnel = [\n ["Issued", n(metrics.challenges.issued)],\n ["Solved", n(metrics.challenges.solved)],\n ["Rejected", n(metrics.challenges.rejected)],\n ["Solve rate", metrics.challenges.issued > 0 ? pct(metrics.challenges.solved, metrics.challenges.issued) : "\u2014"]\n ];\n for (const [key, value] of funnel) challenges.appendChild(statRow(key, value));\n }\n const health = $("stat-health");\n clear(health);\n const bypassed = metrics.bypassed.allowlist + metrics.bypassed["ignored-path"];\n const rows = [\n ["Requests assessed", n(metrics.requests)],\n ["Bypassed \u2014 allowlist", n(metrics.bypassed.allowlist)],\n ["Bypassed \u2014 ignored path", n(metrics.bypassed["ignored-path"])],\n ["Detection ran on", pct(metrics.requests - bypassed, metrics.requests)],\n ["Actors tracked", n(metrics.actorsTracked)],\n ["Guard stops", n(metrics.downgrades)]\n ];\n const failures = pairs(metrics.detectorFailures);\n for (const [detector, count] of failures) rows.push([`Detector failures \u2014 ${detector}`, n(count)]);\n for (const [key, value] of rows) health.appendChild(statRow(key, value));\n if (failures.length === 0) health.appendChild(el("div", "note", "No detector has thrown or timed out."));\n }\n const totals = aggregate(state.rows);\n if (SECTIONS.actors) drawBars($("stat-identities"), totals.identities, "No client has named itself in this window.");\n drawBars($("stat-paths"), totals.paths, "No traffic in this window.");\n drawBars($("stat-denied-paths"), totals.deniedPaths, "Nothing has been denied in this window.");\n drawBars($("stat-guard"), totals.guardStops, "No rule has asked for more than its evidence supports.");\n drawBars($("stat-bypassed"), totals.bypassed, "Nothing bypassed detection.");\n drawRuleHits(totals);\n const list = $("stat-detector-list");\n clear(list);\n $("detector-count").textContent = `${snapshot.detectors.length} installed`;\n for (const detector of snapshot.detectors) {\n const row = el("div", "det");\n const left = el("div");\n left.appendChild(el("div", "mono", detector.id));\n left.appendChild(el("div", "d", detector.description));\n row.appendChild(left);\n const fires = metrics?.detectorFirings[detector.id] ?? 0;\n const timing = metrics?.detectorTimings[detector.id];\n let right = `${n(fires)} \xB7 ${detector.cost} \xB7 ${detector.stage}`;\n if (timing !== void 0 && timing.count > 0) right += ` \xB7 ${ms(timing.totalMs / timing.count)} avg`;\n row.appendChild(el("div", "n", right));\n list.appendChild(row);\n }\n }\n function statRow(key, value) {\n const line = el("div", "stat-row");\n line.appendChild(el("span", "k", key));\n line.appendChild(el("span", "v", value));\n return line;\n }\n function drawRuleHits(totals) {\n const target = $("stat-rule-hits");\n clear(target);\n const rules = state.snapshot?.rules ?? [];\n if (rules.length === 0) {\n target.appendChild(el("div", "note", "No rules configured."));\n return;\n }\n let max = 1;\n for (const rule of rules) max = Math.max(max, totals.ruleHits.get(rule) ?? 0);\n for (const rule of rules) {\n const value = totals.ruleHits.get(rule) ?? 0;\n const bar = el("div", `bar${value === 0 ? " dead" : ""}`);\n const track = el("div", "track");\n if (value > 0) {\n const fill = el("div", "fill");\n fill.style.width = `${Math.max(2, Math.round(value / max * 100))}%`;\n track.appendChild(fill);\n }\n track.appendChild(el("div", "lbl", rule));\n bar.appendChild(track);\n bar.appendChild(el("div", "v tnum", value === 0 ? "never" : n(value)));\n target.appendChild(bar);\n }\n }\n function drawAudit() {\n const snapshot = state.snapshot;\n if (snapshot === void 0) return;\n const body = $("audit-body");\n const checks = $("audit-checks");\n clear(body);\n clear(checks);\n const audit = snapshot.audit;\n if (audit === void 0) {\n $("audit-spans").textContent = "";\n body.appendChild(\n el(\n "div",\n "note",\n "The traffic audit is switched off on this handler (audit: false). It watches the shape of your traffic rather than any one request \u2014 a spike in automation, a collapse in human traffic, a policy suddenly denying far more than usual."\n )\n );\n return;\n }\n const current = audit.window;\n const baseline = audit.baseline;\n $("audit-spans").textContent = `last ${rangeLabel(current.spanMs)} against the ${rangeLabel(baseline.spanMs)} before`;\n const rows = [\n ["Requests", n(current.requests), n(baseline.requests)],\n ["Rate", `${current.rate.toFixed(1)}/min`, `${baseline.rate.toFixed(1)}/min`],\n ["Bot share", `${Math.round(current.botShare * 100)}%`, `${Math.round(baseline.botShare * 100)}%`],\n ["Bots", n(current.bots), n(baseline.bots)],\n ["Humans", n(current.humans), n(baseline.humans)],\n ["Denials", n(current.denials), n(baseline.denials)],\n ["Challenges", n(current.challenges), n(baseline.challenges)],\n ["Guard stops", n(current.downgrades), n(baseline.downgrades)],\n ["Detector failures", n(current.failures), n(baseline.failures)],\n ["Bypassed", n(current.bypassed), n(baseline.bypassed)]\n ];\n for (const [key, now, was] of rows) {\n const line = el("div", "stat-row");\n line.appendChild(el("span", "k", key));\n const values = el("span", "v");\n values.appendChild(el("b", null, now));\n values.appendChild(el("span", "was", ` was ${was}`));\n line.appendChild(values);\n body.appendChild(line);\n }\n if (audit.checks.length === 0) {\n checks.appendChild(el("div", "note", "No checks are installed, so nothing here will ever raise an anomaly."));\n return;\n }\n for (const check of audit.checks) {\n const row = el("div", "det");\n const left = el("div");\n left.appendChild(el("div", "mono", check.id));\n left.appendChild(el("div", "d", check.description));\n row.appendChild(left);\n checks.appendChild(row);\n }\n }\n function drawNoticeBadge() {\n const notices = state.snapshot?.notices ?? [];\n const badge = $("notice-badge");\n badge.hidden = notices.length === 0 || !SECTIONS.notices;\n badge.textContent = String(notices.length);\n }\n function drawNotices() {\n const box = $("stat-notices");\n const notices = state.snapshot?.notices ?? [];\n clear(box);\n $("notice-count").textContent = notices.length > 0 ? `${notices.length} total` : "";\n if (notices.length === 0) {\n box.appendChild(el("div", "note", "Nothing to report: no startup warnings, no detector errors."));\n return;\n }\n for (const notice of notices.slice().reverse().slice(0, 40)) {\n const row = el("div", `notice ${notice.kind}`);\n const when = el("div", "when");\n when.appendChild(el("div", "tag", notice.kind));\n when.appendChild(el("div", null, clockTime(notice.at)));\n row.appendChild(when);\n const body = el("div");\n body.appendChild(el("div", null, notice.message));\n if (notice.source !== void 0) body.appendChild(el("div", "ev-meta", notice.source));\n row.appendChild(body);\n box.appendChild(row);\n }\n }\n function drawChanges() {\n if (!SECTIONS.changes) return;\n const box = $("stat-changes");\n const changes = state.snapshot?.changes ?? [];\n clear(box);\n $("change-count").textContent = changes.length > 0 ? `${changes.length} this run` : "";\n if (changes.length === 0) {\n box.appendChild(el("div", "note", "Nothing has been changed at runtime. Rules, guard and ranges are as the code that built this handler left them."));\n return;\n }\n for (const change of changes.slice().reverse()) {\n const row = el("div", "notice");\n const when = el("div", "when");\n when.appendChild(el("div", "tag", change.kind));\n when.appendChild(el("div", null, clockTime(change.at)));\n row.appendChild(when);\n const body = el("div");\n body.appendChild(el("div", null, change.summary));\n body.appendChild(el("div", "ev-meta", change.by === void 0 || change.by === "" ? "by an unnamed viewer \u2014 this listener\'s auth carries no identity" : `by ${change.by}`));\n row.appendChild(body);\n box.appendChild(row);\n }\n }\n function drawPeers() {\n const box = $("peers");\n if (BOOT.peers.length === 0) {\n box.hidden = true;\n return;\n }\n if (box.childElementCount > 0) return;\n box.appendChild(el("span", "hint", "also:"));\n for (const peer of BOOT.peers) {\n const link = el("a", "linkbtn", peer.label);\n link.href = peer.href;\n link.rel = "noreferrer noopener";\n box.appendChild(link);\n }\n }\n\n // src/dashboard/client/charts.ts\n var BUCKETS = 60;\n var LATENCY_BOUNDS = [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 25, 50, 100];\n var SCORE_BOUNDS = 10;\n function positionTip(tip, host, clientX, boxLeft) {\n tip.style.opacity = "1";\n tip.style.left = `${Math.min(host.clientWidth - 150, Math.max(4, clientX - boxLeft - 60))}px`;\n tip.style.top = "14px";\n }\n function tipRow(label2, value, colour) {\n const line = el("div", "r");\n const left = el("em");\n if (colour !== void 0) {\n const swatch = el("span", "swatch");\n swatch.style.background = colour;\n left.appendChild(swatch);\n }\n left.appendChild(document.createTextNode(label2));\n line.appendChild(left);\n line.appendChild(el("b", null, value));\n return line;\n }\n function timeline() {\n const bucketMs = state.rangeMs / BUCKETS;\n const now = Date.now();\n const start2 = now - state.rangeMs;\n const buckets = [];\n for (let i = 0; i < BUCKETS; i++) buckets.push({ at: start2 + i * bucketMs, served: 0, mitigated: 0, denied: 0, total: 0 });\n for (const { entry } of state.rows) {\n const index = Math.floor((entry.at - start2) / bucketMs);\n if (index < 0 || index >= BUCKETS) continue;\n const bucket = buckets[index];\n if (bucket === void 0) continue;\n bucket.total++;\n const out = outcome(entry);\n if (out === "deny") bucket.denied++;\n else if (out === "mitigate") bucket.mitigated++;\n else bucket.served++;\n }\n return buckets;\n }\n function drawTraffic() {\n const host = $("traffic-chart");\n const svg = $("traffic");\n const width = Math.max(320, host.clientWidth - 30);\n const height = 190;\n const padBottom = 26;\n const markerRow = 8;\n const plot = height - padBottom - markerRow;\n const buckets = timeline();\n const now = Date.now();\n const start2 = now - state.rangeMs;\n let peak = 1;\n for (const bucket of buckets) if (bucket.total > peak) peak = bucket.total;\n const max = Math.max(2, Math.ceil(peak / 2) * 2);\n clear(svg);\n svg.setAttribute("viewBox", `0 0 ${width} ${height}`);\n svg.setAttribute("height", String(height));\n const s1 = css("--s1");\n const s2 = css("--s2");\n const crit = css("--crit");\n const grid = css("--grid");\n const muted = css("--muted");\n const step = width / BUCKETS;\n const barWidth = Math.max(2, step - 2);\n for (const fraction of [0, 0.5, 1]) {\n const y = markerRow + plot - fraction * plot;\n svg.appendChild(svgEl("line", { class: "gridline", x1: 0, x2: width, y1: y, y2: y, stroke: grid, "stroke-width": 1 }));\n if (fraction > 0) svg.appendChild(svgText({ x: 2, y: y - 3, fill: muted, "font-size": 10 }, Math.round(max * fraction)));\n }\n buckets.forEach((bucket, index) => {\n const x = index * step + 1;\n const servedHeight = bucket.served / max * plot;\n const mitigatedHeight = bucket.mitigated / max * plot;\n let y = markerRow + plot;\n if (servedHeight > 0) {\n y -= servedHeight;\n svg.appendChild(svgEl("rect", { x, y, width: barWidth, height: servedHeight, fill: s1, rx: 2 }));\n }\n if (mitigatedHeight > 0) {\n y -= mitigatedHeight + (servedHeight > 0 ? 2 : 0);\n svg.appendChild(svgEl("rect", { x, y: Math.max(markerRow, y), width: barWidth, height: mitigatedHeight, fill: s2, rx: 2 }));\n }\n if (bucket.denied > 0) svg.appendChild(svgEl("rect", { x, y: 0, width: barWidth, height: 5, fill: crit, rx: 2 }));\n });\n svg.appendChild(svgEl("line", { x1: 0, x2: width, y1: markerRow + plot, y2: markerRow + plot, stroke: css("--line"), "stroke-width": 1 }));\n const span = rangeLabel(state.rangeMs);\n const labels = [\n [0, `${span} ago`],\n [BUCKETS / 2, rangeLabel(state.rangeMs / 2)],\n [BUCKETS - 1, "now"]\n ];\n for (const [position, text] of labels) {\n svg.appendChild(svgText({ x: Math.min(width - 26, Math.max(0, position * step)), y: height - 8, fill: muted, "font-size": 10 }, text));\n }\n const changes = (state.snapshot?.changes ?? []).filter((change) => change.at >= start2 && change.at <= now);\n const markColour = css("--proven-text");\n for (const change of changes) {\n const x = (change.at - start2) / state.rangeMs * width;\n svg.appendChild(svgEl("line", { x1: x, x2: x, y1: 0, y2: markerRow + plot, stroke: markColour, "stroke-width": 1.5, "stroke-dasharray": "3 2", opacity: 0.85 }));\n const dot = svgEl("circle", { cx: x, cy: 3, r: 3, fill: markColour });\n const title = svgEl("title");\n title.textContent = `${clockTime(change.at)} \xB7 ${change.kind}: ${change.summary}${change.by === void 0 ? "" : ` (by ${change.by})`}`;\n dot.appendChild(title);\n svg.appendChild(dot);\n }\n const hover = svgEl("rect", { x: 0, y: 0, width: 0, height: markerRow + plot, fill: css("--ink"), opacity: 0.06 });\n svg.appendChild(hover);\n const tip = $("traffic-tip");\n host.onmousemove = (event) => {\n const box = svg.getBoundingClientRect();\n const index = Math.floor((event.clientX - box.left) / box.width * BUCKETS);\n const bucket = buckets[index];\n if (bucket === void 0) {\n tip.style.opacity = "0";\n hover.setAttribute("width", "0");\n return;\n }\n hover.setAttribute("x", String(index * step));\n hover.setAttribute("width", String(step));\n clear(tip);\n tip.appendChild(el("div", "t", `${clockTime(bucket.at)} \xB7 ${Math.round(state.rangeMs / BUCKETS / 1e3)}s`));\n tip.appendChild(tipRow("Served", n(bucket.served), s1));\n tip.appendChild(tipRow("Mitigated", n(bucket.mitigated), s2));\n tip.appendChild(tipRow("Denied", n(bucket.denied), crit));\n positionTip(tip, host, event.clientX, box.left);\n };\n host.onmouseleave = () => {\n tip.style.opacity = "0";\n hover.setAttribute("width", "0");\n };\n const total = buckets.reduce((sum, bucket) => sum + bucket.total, 0);\n $("traffic-window").textContent = `last ${rangeLabel(state.rangeMs)}`;\n const served = buckets.reduce((sum, bucket) => sum + bucket.served, 0);\n const mitigated = buckets.reduce((sum, bucket) => sum + bucket.mitigated, 0);\n const denied = buckets.reduce((sum, bucket) => sum + bucket.denied, 0);\n const busiest = buckets.reduce((best, bucket) => bucket.total > best.total ? bucket : best, buckets[0] ?? { at: now, total: 0, served: 0, mitigated: 0, denied: 0 });\n $("traffic-alt").textContent = `Traffic over the last ${rangeLabel(state.rangeMs)}: ${n(total)} requests \u2014 ${n(served)} served, ${n(mitigated)} mitigated, ${n(denied)} denied. ` + (total === 0 ? "No traffic in this range." : `Busiest ${Math.round(state.rangeMs / BUCKETS / 1e3)}-second interval: ${n(busiest.total)} requests at ${clockTime(busiest.at)}.`) + (changes.length === 0 ? "" : ` ${n(changes.length)} runtime change${changes.length === 1 ? "" : "s"} in this range: ${changes.map((change) => `${change.kind}, ${change.summary}`).join("; ")}.`);\n const legend = $("traffic-legend");\n clear(legend);\n legend.appendChild(el("span", null, `${n(total)} requests in the last ${rangeLabel(state.rangeMs)} \xB7`));\n const oldest = oldestAt();\n if (oldest !== void 0 && Date.now() - oldest < state.rangeMs * 0.9) {\n legend.appendChild(el("span", null, `window holds ${rangeLabel(Date.now() - oldest)} \xB7`));\n }\n for (const [label2, colour] of [\n ["Served", s1],\n ["Mitigated \u2014 challenged, limited or delayed", s2],\n ["Denied", crit]\n ]) {\n const item = el("span");\n const swatch = el("span", "swatch");\n swatch.style.background = colour;\n item.appendChild(swatch);\n item.appendChild(document.createTextNode(label2));\n legend.appendChild(item);\n }\n if (changes.length > 0) {\n const item = el("span");\n item.appendChild(el("span", "mark"));\n item.appendChild(document.createTextNode(`${n(changes.length)} runtime change${changes.length === 1 ? "" : "s"} \u2014 hover a marker`));\n legend.appendChild(item);\n }\n }\n function differences(cumulative) {\n const counts = [];\n for (let i = 0; i < cumulative.length; i++) counts.push((cumulative[i] ?? 0) - (i > 0 ? cumulative[i - 1] ?? 0 : 0));\n return counts;\n }\n function drawScores() {\n const host = $("score-chart");\n const svg = $("scores");\n clear(svg);\n const metrics = state.snapshot?.metrics;\n const fromRun = state.scoreScope === "run" && metrics !== void 0;\n let buckets;\n let scored;\n let proven;\n if (fromRun && metrics !== void 0) {\n buckets = differences(metrics.scores.buckets);\n scored = metrics.scores.count;\n proven = metrics.proven;\n } else {\n buckets = new Array(SCORE_BOUNDS).fill(0);\n scored = 0;\n proven = 0;\n for (const { entry } of state.rows) {\n if (entry.bypass !== void 0) continue;\n if (entry.certain) {\n proven++;\n continue;\n }\n const index = Math.min(SCORE_BOUNDS - 1, Math.floor(entry.score / 10));\n buckets[index] = (buckets[index] ?? 0) + 1;\n scored++;\n }\n }\n const width = Math.max(280, host.clientWidth - 30);\n const height = 190;\n const padBottom = 30;\n const plot = height - padBottom;\n let max = 1;\n for (const value of buckets) if (value > max) max = value;\n svg.setAttribute("viewBox", `0 0 ${width} ${height}`);\n svg.setAttribute("height", String(height));\n const fill = css("--s1");\n const muted = css("--muted");\n const grid = css("--grid");\n const crit = css("--crit");\n svg.appendChild(svgEl("line", { class: "gridline", x1: 0, x2: width, y1: plot, y2: plot, stroke: grid, "stroke-width": 1 }));\n const step = width / SCORE_BOUNDS;\n buckets.forEach((value, index) => {\n const barHeight = value / max * (plot - 8);\n const barWidth = Math.max(2, Math.min(step - 6, 56));\n if (barHeight > 0) {\n svg.appendChild(svgEl("rect", { x: index * step + (step - barWidth) / 2, y: plot - barHeight, width: barWidth, height: barHeight, fill, rx: 3 }));\n }\n svg.appendChild(svgText({ x: index * step + 1, y: height - 14, fill: muted, "font-size": 9.5 }, index * 10));\n });\n const threshold = state.snapshot?.policy.suspectThreshold ?? 60;\n const x = threshold / 100 * width;\n svg.appendChild(svgEl("line", { x1: x, x2: x, y1: 0, y2: plot, stroke: crit, "stroke-width": 2, "stroke-dasharray": "4 3" }));\n svg.appendChild(svgText({ x: Math.min(width - 92, x + 5), y: 11, fill: crit, "font-size": 10 }, `suspect at ${threshold}`));\n svg.appendChild(svgText({ x: 0, y: height - 2, fill: muted, "font-size": 10 }, "score (probabilistic requests only)"));\n const tip = $("score-tip");\n host.onmousemove = (event) => {\n const box = svg.getBoundingClientRect();\n const index = Math.floor((event.clientX - box.left) / box.width * SCORE_BOUNDS);\n const value = buckets[index];\n if (value === void 0) {\n tip.style.opacity = "0";\n return;\n }\n clear(tip);\n tip.appendChild(el("div", "t", `score ${index * 10}\u2013${index * 10 + 9}`));\n tip.appendChild(tipRow("requests", n(value)));\n tip.appendChild(tipRow("share", pct(value, scored)));\n positionTip(tip, host, event.clientX, box.left);\n };\n host.onmouseleave = () => {\n tip.style.opacity = "0";\n };\n let over = 0;\n for (let bucket = 0; bucket < SCORE_BOUNDS; bucket++) if (bucket * 10 >= threshold) over += buckets[bucket] ?? 0;\n const legend = $("score-legend");\n clear(legend);\n legend.appendChild(el("span", null, `${n(scored)} scored \xB7 ${n(over)} at or over the threshold \xB7 ${n(proven)} proven, which carry no score`));\n $("score-alt").textContent = `Distribution of probabilistic scores ${fromRun ? "since start" : "in the retained window"}, with the suspect threshold at ${threshold}. ${n(scored)} scored requests, ${n(over)} at or over the threshold, ${n(proven)} proven and therefore unscored. ` + (scored === 0 ? "Nothing scored yet." : `By ten-point band: ${buckets.map((value, index) => `${index * 10}\u2013${index * 10 + 9}: ${n(value)}`).join(", ")}.`);\n $("score-window").textContent = fromRun ? "since start" : windowLabel(state.rows.length, oldestAt(), Date.now());\n if (state.scoreScope === "run" && metrics === void 0) {\n legend.appendChild(el("span", null, "\xB7 counters are off on this handler, so this is the retained window"));\n }\n }\n function drawLatency() {\n const host = $("latency-chart");\n const svg = $("latency");\n clear(svg);\n const metrics = state.snapshot?.metrics;\n if (metrics === void 0 || metrics.duration.count === 0) {\n $("latency-summary").textContent = "No assessments yet.";\n $("latency-alt").textContent = "Assessment latency: no assessments yet.";\n svg.setAttribute("viewBox", "0 0 100 40");\n svg.setAttribute("height", "40");\n svg.appendChild(svgText({ x: 0, y: 20, fill: css("--muted"), "font-size": 11 }, "No assessments yet."));\n return;\n }\n const cumulative = metrics.duration.buckets;\n const counts = differences(cumulative);\n const width = Math.max(280, host.clientWidth - 30);\n const height = 190;\n const padBottom = 30;\n const plot = height - padBottom;\n let max = 1;\n for (const value of counts) if (value > max) max = value;\n svg.setAttribute("viewBox", `0 0 ${width} ${height}`);\n svg.setAttribute("height", String(height));\n const fill = css("--s1");\n const muted = css("--muted");\n const grid = css("--grid");\n svg.appendChild(svgEl("line", { class: "gridline", x1: 0, x2: width, y1: plot, y2: plot, stroke: grid, "stroke-width": 1 }));\n const step = width / counts.length;\n counts.forEach((value, index) => {\n const barHeight = value / max * (plot - 6);\n if (barHeight > 0) {\n svg.appendChild(svgEl("rect", { x: index * step + 1, y: plot - barHeight, width: Math.max(2, step - 3), height: barHeight, fill, rx: 3 }));\n }\n if (index % 2 === 0) {\n svg.appendChild(svgText({ x: index * step + 1, y: height - 14, fill: muted, "font-size": 9.5 }, index < LATENCY_BOUNDS.length ? String(LATENCY_BOUNDS[index]) : "more"));\n }\n });\n svg.appendChild(svgText({ x: 0, y: height - 2, fill: muted, "font-size": 10 }, "milliseconds (upper bound of each bucket)"));\n const tip = $("latency-tip");\n host.onmousemove = (event) => {\n const box = svg.getBoundingClientRect();\n const index = Math.floor((event.clientX - box.left) / box.width * counts.length);\n const value = counts[index];\n if (value === void 0) {\n tip.style.opacity = "0";\n return;\n }\n clear(tip);\n tip.appendChild(el("div", "t", index < LATENCY_BOUNDS.length ? `\u2264 ${LATENCY_BOUNDS[index]}ms` : "over 100ms"));\n tip.appendChild(tipRow("requests", n(value)));\n tip.appendChild(tipRow("share", pct(value, metrics.duration.count)));\n positionTip(tip, host, event.clientX, box.left);\n };\n host.onmouseleave = () => {\n tip.style.opacity = "0";\n };\n const mean = metrics.duration.totalMs / metrics.duration.count;\n const p95 = percentile(cumulative, metrics.duration.count, 0.95);\n $("latency-summary").textContent = `Time spent in detection, per request \xB7 mean ${ms(mean)} \xB7 p95 ${ms(p95)} \xB7 max ${ms(metrics.duration.maxMs)}`;\n $("latency-alt").textContent = `Assessment latency since start over ${n(metrics.duration.count)} requests: mean ${ms(mean)}, 95th percentile ${ms(p95)}, maximum ${ms(metrics.duration.maxMs)}. By bucket: ${counts.map((value, index) => `${index < LATENCY_BOUNDS.length ? `up to ${LATENCY_BOUNDS[index]}ms` : "over 100ms"}: ${n(value)}`).join(", ")}.`;\n }\n function percentile(cumulative, count, fraction) {\n const target = count * fraction;\n const last = LATENCY_BOUNDS[LATENCY_BOUNDS.length - 1] ?? 100;\n for (let i = 0; i < cumulative.length; i++) {\n if ((cumulative[i] ?? 0) >= target) return i < LATENCY_BOUNDS.length ? LATENCY_BOUNDS[i] ?? last : last;\n }\n return last;\n }\n\n // src/dashboard/client/registry.ts\n var timer;\n async function loadActors() {\n if (!SECTIONS.registry) return;\n try {\n const body = await getJson(`/api/actors?limit=${state.actorsPageSize}&offset=${state.actorsPage * state.actorsPageSize}`);\n state.actors = body.actors;\n state.actorsTracked = body.tracked;\n drawActors();\n } catch {\n }\n }\n function trackActors() {\n if (timer !== void 0) clearInterval(timer);\n timer = void 0;\n if (state.tab !== "actors") return;\n void loadActors();\n timer = setInterval(() => {\n if (state.tab !== "actors" || state.paused || isConfirming()) return;\n void loadActors();\n }, 4e3);\n }\n var ACTORS_PAGE_SIZES = [25, 50, 100, 200];\n function drawActorsPager(full) {\n const page = state.actorsPage;\n const hidden = page === 0 && !full;\n const from = page * state.actorsPageSize + 1;\n const model = {\n page,\n from,\n to: from + state.actors.length - 1,\n total: state.actorsTracked,\n atStart: page === 0,\n atEnd: !full,\n go: (next) => {\n state.actorsPage = Math.max(0, next);\n void loadActors();\n },\n size: {\n current: state.actorsPageSize,\n choices: ACTORS_PAGE_SIZES,\n set: (next) => {\n state.actorsPageSize = next;\n state.actorsPage = 0;\n void loadActors();\n }\n }\n };\n for (const [id, withSize] of [\n ["actors-pager-top", true],\n ["actors-pager", false]\n ]) {\n const host = $(id);\n host.hidden = hidden;\n if (hidden) clear(host);\n else renderPager(host, model, { withSize });\n }\n }\n function drawActors() {\n if (!SECTIONS.registry) return;\n if (isConfirming()) return;\n const body = byId("actor-rows");\n clear(body);\n const actors = state.actors;\n $("actors-count").textContent = `${n(actors.length)} shown \xB7 ${n(state.actorsTracked)} tracked`;\n drawActorsPager(actors.length === state.actorsPageSize);\n byId("actors-empty").hidden = actors.length > 0;\n for (const actor of actors) {\n const row = el("tr");\n row.appendChild(el("td", "who", actor.key));\n row.appendChild(el("td", "num tnum", n(actor.requests)));\n row.appendChild(el("td", "num tnum", n(actor.recentRate)));\n row.appendChild(el("td", "num tnum", n(actor.distinctPaths)));\n const cadence = el("td", "num tnum", actor.cadenceCv === void 0 ? "\u2014" : actor.cadenceCv.toFixed(2));\n if (actor.cadenceCv !== void 0 && actor.cadenceCv < 0.15) cadence.className += " warn-text";\n row.appendChild(cadence);\n row.appendChild(el("td", "num tnum", n(actor.priorConfirmations)));\n const unsolved = el("td", "num tnum", n(actor.unsolvedChallenges));\n if (actor.unsolvedChallenges >= 3) unsolved.className += " warn-text";\n row.appendChild(unsolved);\n const stateCell = el("td");\n const tags = [];\n if (actor.cleared) tags.push("cleared as human");\n if (actor.distinctUserAgents > 1) tags.push(`${actor.distinctUserAgents} User-Agents`);\n tags.push(`first seen ${clockStamp(actor.firstSeen)}`);\n stateCell.appendChild(el("div", "tagline", tags.join(" \xB7 ")));\n row.appendChild(stateCell);\n const actions = el("td", "acts");\n const inFeed = el("button", null, "In feed");\n inFeed.title = "Show this actor\'s requests in the live feed";\n inFeed.addEventListener("click", () => {\n setSearch(`actor:${actor.key}`);\n const search = byId("search");\n search.value = state.search;\n app.showTab("live");\n app.syncUrl();\n });\n actions.appendChild(inFeed);\n for (const button of actorActions(actor.key, () => void loadActors())) actions.appendChild(button);\n row.appendChild(actions);\n body.appendChild(row);\n }\n }\n\n // src/dashboard/client/tester.ts\n function initTester() {\n if (!SECTIONS.tester) return;\n $("test-run").addEventListener("click", () => void run());\n byId("test-input").addEventListener("keydown", (event) => {\n if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {\n event.preventDefault();\n void run();\n }\n });\n }\n async function run() {\n const raw2 = byId("test-input").value;\n if (raw2.trim() === "") {\n toast("warn", "Nothing to assess", "Paste a User-Agent, a curl command, or a header block.");\n return;\n }\n const result = await postJson("/api/test", {\n raw: raw2,\n ip: byId("test-ip").value.trim(),\n url: byId("test-url").value.trim()\n });\n const box = $("test-result");\n box.hidden = false;\n clear(box);\n if (!result.ok) {\n box.className = "result bad";\n box.appendChild(el("div", null, result.error ?? "The server could not read that."));\n return;\n }\n const { entry, reason, assumed } = result.data;\n const out = outcome(entry);\n box.className = `result ${out === "deny" ? "bad" : out === "mitigate" ? "warn" : "ok"}`;\n const head = el("div");\n const [badgeClass, badgeLabel] = verdictBadge(entry);\n head.appendChild(el("span", `badge ${badgeClass}`, badgeLabel));\n head.appendChild(document.createTextNode(" "));\n head.appendChild(el("b", null, entry.action ?? "no decision"));\n if (entry.rule !== void 0) head.appendChild(el("span", "ev-meta", ` via ${entry.rule}`));\n box.appendChild(head);\n box.appendChild(el("div", "ev-meta", `${entry.certain ? "proven" : `score ${entry.score}`} \xB7 assessed in ${ms(entry.durationMs)}`));\n if (entry.downgradedFrom !== void 0) {\n box.appendChild(el("div", "guard", `The guard stopped ${entry.downgradedFrom} here.`));\n if (entry.downgradeReason !== void 0) box.appendChild(el("div", "basis", entry.downgradeReason));\n }\n if (entry.evidence.length === 0) {\n box.appendChild(el("div", "ev-meta", entry.bypass !== void 0 ? `Detection was skipped: ${entry.bypass}.` : "No detector produced any evidence."));\n } else {\n const list = el("div", "ev");\n for (const item of entry.evidence) {\n const row = el("div", `ev-item${item.direction === "human" ? " human" : ""}`);\n row.appendChild(el("div", `tier t-${item.certainty}`, item.certainty));\n const detail = el("div");\n detail.appendChild(el("div", null, item.summary));\n detail.appendChild(el("div", "ev-meta", `${item.detector} \xB7 points to ${item.direction}`));\n if (item.deterministicBasis !== void 0) detail.appendChild(el("div", "basis", item.deterministicBasis));\n row.appendChild(detail);\n list.appendChild(row);\n }\n box.appendChild(list);\n }\n box.appendChild(el("div", "ev-meta", reason));\n const notes = [...assumed, "no history: this is assessed as a first request, so cadence and crawl breadth have nothing to read"];\n box.appendChild(el("div", "assumed", `Assumed \u2014 ${notes.join("; ")}.`));\n }\n\n // src/dashboard/client/index.ts\n var TABS = [\n ["tab-live", "live", SECTIONS.feed],\n ["tab-actors", "actors", SECTIONS.registry],\n ["tab-stats", "stats", SECTIONS.statistics],\n ["tab-policy", "policy", SECTIONS.policy]\n ];\n var available = TABS.filter(([, , enabled]) => enabled);\n var FIRST = available[0]?.[1] ?? "live";\n function tabIndexOf(name) {\n const at = available.findIndex(([, tab]) => tab === name);\n return at === -1 ? 0 : at;\n }\n function isTab(value) {\n return available.some(([, name]) => name === value);\n }\n function initSkipLink() {\n if (!isEmbedded()) return;\n const skip = rootNode().querySelector("a.skip");\n if (skip === null) return;\n skip.addEventListener("click", (event) => {\n event.preventDefault();\n const target = rootNode().querySelector(`#view-${state.tab}`) ?? rootNode().querySelector("#view-live");\n if (target === null) return;\n target.tabIndex = -1;\n target.focus();\n target.scrollIntoView({ block: "start" });\n });\n }\n function initHeader() {\n const box = $("links");\n for (const link of BOOT.links) {\n const anchor = el("a", "linkbtn", link.label);\n anchor.href = link.href;\n anchor.rel = "noreferrer noopener";\n box.appendChild(anchor);\n }\n let stored = null;\n try {\n stored = localStorage.getItem("bothandler-dashboard-theme");\n } catch {\n stored = null;\n }\n if (stored === "dark" || stored === "light") themeElement().setAttribute("data-theme", stored);\n $("theme").addEventListener("click", () => {\n let current = themeElement().getAttribute("data-theme");\n if (current === null) current = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";\n const next = current === "dark" ? "light" : "dark";\n themeElement().setAttribute("data-theme", next);\n try {\n localStorage.setItem("bothandler-dashboard-theme", next);\n } catch {\n }\n drawNow();\n });\n const pause = byId("pause");\n pause.hidden = !SECTIONS.feed;\n pause.addEventListener("click", () => {\n state.paused = !state.paused;\n pause.setAttribute("aria-pressed", String(state.paused));\n pause.textContent = state.paused ? `Resume${state.bufferedWhilePaused > 0 ? ` (${state.bufferedWhilePaused})` : ""}` : "Pause";\n if (!state.paused) {\n state.bufferedWhilePaused = 0;\n drawNow();\n }\n });\n if (BOOT.allowReset) {\n const reset = byId("reset");\n reset.hidden = false;\n reset.addEventListener("click", () => {\n reset.disabled = true;\n void fetch(`${API}/api/reset`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }).then(async (response) => {\n if (!response.ok) {\n const body = await response.json().catch(() => ({}));\n toast("bad", "Reset refused", body.error ?? String(response.status));\n return;\n }\n clearFeed();\n resetFeedCache();\n drawNow();\n }).catch(() => {\n }).finally(() => {\n reset.disabled = false;\n });\n });\n }\n const header = rootNode().querySelector("header");\n if (header !== null) {\n const apply = () => {\n themeElement().style.setProperty("--header-h", `${header.getBoundingClientRect().height}px`);\n };\n apply();\n if (typeof ResizeObserver === "function") new ResizeObserver(apply).observe(header);\n else addEventListener("resize", apply);\n }\n }\n function syncUrl(replace = true) {\n if (isEmbedded()) return;\n const params = new URLSearchParams();\n if (state.filter !== "all") params.set("f", state.filter);\n if (state.search !== "") params.set("q", state.search);\n const query = params.toString();\n const hash = `#${state.tab}${query === "" ? "" : `?${query}`}`;\n if (location.hash === hash) return;\n history[replace ? "replaceState" : "pushState"]({ tab: state.tab }, "", hash);\n }\n function readUrl() {\n const raw2 = isEmbedded() ? "" : location.hash.slice(1);\n const split = raw2.indexOf("?");\n const name = split === -1 ? raw2 : raw2.slice(0, split);\n const params = new URLSearchParams(split === -1 ? "" : raw2.slice(split + 1));\n const filter = params.get("f") ?? "all";\n return {\n tab: isTab(name) ? name : FIRST,\n filter,\n search: params.get("q") ?? ""\n };\n }\n function showTab(name, options = {}) {\n const target = isTab(name) ? name : FIRST;\n state.tab = target;\n for (const [id, tab, enabled] of TABS) {\n const selected = tab === target;\n const node = $(id);\n node.hidden = !enabled;\n node.setAttribute("aria-selected", String(selected));\n node.tabIndex = selected ? 0 : -1;\n $(`view-${tab}`).hidden = !selected || !enabled;\n }\n if (options.focus === true) $(available[tabIndexOf(target)]?.[0] ?? "tab-live").focus();\n if (target === "policy" && state.policy === void 0) {\n void loadPolicy();\n void loadRanges();\n }\n trackActors();\n if (options.push !== false) syncUrl(options.replace !== false);\n drawNow();\n }\n function initTabs() {\n available.forEach(([id, name], index) => {\n const tab = $(id);\n tab.addEventListener("click", () => showTab(name, { replace: false }));\n tab.addEventListener("keydown", (event) => {\n let next = -1;\n if (event.key === "ArrowRight") next = (index + 1) % available.length;\n else if (event.key === "ArrowLeft") next = (index - 1 + available.length) % available.length;\n else if (event.key === "Home") next = 0;\n else if (event.key === "End") next = available.length - 1;\n if (next === -1) return;\n event.preventDefault();\n showTab(available[next]?.[1] ?? FIRST, { focus: true, replace: false });\n });\n });\n if (isEmbedded()) return;\n addEventListener("popstate", () => {\n const url = readUrl();\n state.filter = url.filter;\n setSearch(url.search);\n reflectFilterButtons();\n showTab(url.tab, { push: false });\n });\n }\n function initKeyboard() {\n eventTarget().addEventListener("keydown", ((event) => {\n const target = event.target;\n const typing = target !== null && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT");\n if (event.key === "Escape") {\n if (typing && target?.id === "search" && target.value !== "") {\n target.value = "";\n setSearch("");\n syncUrl();\n drawNow();\n return;\n }\n if (typing) {\n target?.blur();\n return;\n }\n if (state.actor !== void 0) {\n state.actor = void 0;\n drawNow();\n return;\n }\n if (state.open.size > 0) {\n state.open.clear();\n drawNow();\n }\n return;\n }\n if (typing || event.metaKey || event.ctrlKey || event.altKey) return;\n if (event.key === "/" && SECTIONS.feed) {\n event.preventDefault();\n showTab("live");\n const search = byId("search");\n search.focus();\n search.select();\n return;\n }\n const digit = ["1", "2", "3", "4"].indexOf(event.key);\n if (digit !== -1 && digit < available.length) {\n event.preventDefault();\n showTab(available[digit]?.[1] ?? FIRST, { focus: true, replace: false });\n }\n }));\n }\n function initRanges() {\n const ranges = [\n ["1m", 6e4],\n ["5m", 3e5],\n ["15m", 9e5],\n ["1h", 36e5]\n ];\n const host = $("traffic-range");\n for (const [label2, value] of ranges) {\n const button = el("button", null, label2);\n button.setAttribute("aria-pressed", String(value === state.rangeMs));\n button.addEventListener("click", () => {\n state.rangeMs = value;\n for (const other of Array.from(host.children)) other.setAttribute("aria-pressed", "false");\n button.setAttribute("aria-pressed", "true");\n drawTraffic();\n });\n host.appendChild(button);\n }\n const scopes = [\n ["since start", "run"],\n ["this window", "window"]\n ];\n const scopeHost = $("score-scope");\n for (const [label2, value] of scopes) {\n const button = el("button", null, label2);\n button.setAttribute("aria-pressed", String(value === state.scoreScope));\n button.addEventListener("click", () => {\n state.scoreScope = value;\n for (const other of Array.from(scopeHost.children)) other.setAttribute("aria-pressed", "false");\n button.setAttribute("aria-pressed", "true");\n drawScores();\n });\n scopeHost.appendChild(button);\n }\n }\n var pending = false;\n function schedule() {\n if (state.paused || pending) return;\n pending = true;\n requestAnimationFrame(() => {\n pending = false;\n draw();\n });\n }\n function draw() {\n drawChips();\n drawTiles();\n drawNoticeBadge();\n updateWindowLabels();\n if (state.tab === "live") {\n drawActor();\n drawFeed();\n drawLivePanels();\n } else if (state.tab === "actors") {\n drawActors();\n } else if (state.tab === "stats") {\n drawTraffic();\n drawLatency();\n drawScores();\n drawStatsPanels();\n if (SECTIONS.audit) drawAudit();\n } else {\n drawPolicyTab();\n if (SECTIONS.notices) drawNotices();\n if (SECTIONS.changes) drawChanges();\n }\n }\n function drawNow() {\n draw();\n }\n function applySections() {\n const gated = [\n ["tiles", SECTIONS.statistics],\n ["tester-panel", SECTIONS.tester],\n ["ranges-panel", SECTIONS.ranges],\n ["changes-panel", SECTIONS.changes],\n ["actor-panel", SECTIONS.actors],\n ["live-actors-panel", SECTIONS.actors],\n ["audit-panel", SECTIONS.audit],\n ["audit-checks-panel", SECTIONS.audit],\n ["notices-panel", SECTIONS.notices],\n ["guard-panel", SECTIONS.guard],\n ["robots-panel", SECTIONS.robots],\n ["identities-panel", SECTIONS.actors],\n ["evidence-legend", SECTIONS.evidence]\n ];\n for (const [id, enabled] of gated) {\n const node = rootNode().querySelector(`#${id}`);\n if (node !== null && !enabled) node.remove();\n }\n }\n function start() {\n app.draw = schedule;\n app.drawNow = drawNow;\n app.showTab = showTab;\n app.syncUrl = () => syncUrl();\n applySections();\n initHeader();\n drawPeers();\n initTabs();\n initSkipLink();\n initKeyboard();\n initRanges();\n if (SECTIONS.feed) initFeed();\n initActor();\n initTester();\n initPolicy();\n let resizeTimer;\n addEventListener("resize", () => {\n if (resizeTimer !== void 0) clearTimeout(resizeTimer);\n resizeTimer = setTimeout(() => {\n if (state.tab === "stats") {\n drawTraffic();\n drawLatency();\n drawScores();\n }\n }, 120);\n });\n const url = readUrl();\n state.filter = url.filter;\n setSearch(url.search);\n if (SECTIONS.feed) reflectFilterButtons();\n showTab(url.tab, { replace: true });\n suspendScrollAnchoring();\n void loadInitialSnapshot().finally(settleScrollAnchoring);\n connectStream();\n }\n function htmlElement() {\n const root2 = rootNode();\n return root2 instanceof Document ? root2.documentElement : void 0;\n }\n function suspendScrollAnchoring() {\n htmlElement()?.classList.add("settling");\n }\n function settleScrollAnchoring() {\n const html = htmlElement();\n if (html === void 0) return;\n requestAnimationFrame(() => {\n requestAnimationFrame(() => {\n html.classList.remove("settling");\n });\n });\n }\n start();\n})();\n';
6439
6866
  }
6440
6867
  });
6441
6868
 
@@ -6563,6 +6990,8 @@ var init_page2 = __esm({
6563
6990
 
6564
6991
  * { box-sizing: border-box; }
6565
6992
  html, body { height: 100%; }
6993
+ /* Restored by the client once the first render has settled. See the note on .tiles. */
6994
+ html.settling { overflow-anchor: none; }
6566
6995
  body {
6567
6996
  margin: 0; background: var(--page); color: var(--ink);
6568
6997
  font: 14px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
@@ -6653,16 +7082,73 @@ button[disabled] { opacity: .5; cursor: default; }
6653
7082
  .tab[aria-selected="true"] { color: var(--ink); border-bottom-color: var(--s1); }
6654
7083
 
6655
7084
  /* --- layout ------------------------------------------------------------- */
6656
- main { padding: 18px 20px 64px; max-width: 1680px; margin: 0 auto; }
7085
+ /* The top padding is the gap under the sticky header. At 18px the counter row sat almost
7086
+ against the header's border and read as part of it; the tiles carry their own border, so
7087
+ two lines were meeting with nothing between them. */
7088
+ main { padding: 28px 20px 64px; max-width: 1680px; margin: 0 auto; }
6657
7089
  .stack { display: grid; gap: 16px; }
6658
- .tiles { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(148px, 1fr)); }
7090
+ /* Everything above the feed is drawn by script once the first snapshot arrives, which
7091
+ inserts a block of content above what is already laid out. The browser's scroll
7092
+ anchoring compensates for that by scrolling down by its height — so on any window narrow
7093
+ enough for the counter row to wrap, the dashboard opened with its own counters already
7094
+ off the top of the screen, every time.
7095
+
7096
+ Anchoring is switched off for the first render and switched back on once it has settled
7097
+ (see settleScrollAnchoring in the client). It is not simply left off: the feed puts new requests at
7098
+ the top, and anchoring is exactly what keeps somebody's place while they read a screen
7099
+ that grows above them. */
7100
+ .tiles { display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(136px, 1fr)); }
6659
7101
  .tile {
6660
- background: var(--surface); border: 1px solid var(--line); border-radius: 12px;
6661
- padding: 12px 14px 13px; box-shadow: var(--shadow);
6662
- }
6663
- .tile .v { font-size: 26px; font-weight: 620; letter-spacing: -.025em; line-height: 1.1; }
6664
- .tile .k { font-size: 11.5px; color: var(--muted); text-transform: uppercase; letter-spacing: .055em; margin-top: 4px; font-weight: 560; }
6665
- .tile .s { font-size: 11.5px; color: var(--muted); margin-top: 3px; }
7102
+ background: var(--surface); border: 1px solid var(--line); border-radius: 10px;
7103
+ padding: 8px 12px 9px; box-shadow: var(--shadow);
7104
+ }
7105
+ .tile .v { font-size: 21px; font-weight: 620; letter-spacing: -.025em; line-height: 1.15; }
7106
+ .tile .k { font-size: 10.5px; color: var(--muted); text-transform: uppercase; letter-spacing: .055em; margin-top: 2px; font-weight: 560; }
7107
+ /* One line, always. These are grid items, so the tallest sets the height of all nine —
7108
+ two captions wrapping to a second line was costing every tile forty pixels of nothing.
7109
+ The full text stays available on hover rather than being cut from the page. */
7110
+ .tile .s { font-size: 11px; color: var(--muted); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
7111
+ /* A tile that navigates is a real button, so it arrives carrying the browser's own
7112
+ font, centring and chrome. Reset to match its inert neighbours exactly, then given
7113
+ back the one thing a div must not have: something that says it can be pressed. */
7114
+ button.tile {
7115
+ font: inherit; color: inherit; text-align: left; width: 100%; display: block;
7116
+ cursor: pointer; appearance: none; transition: border-color .12s, box-shadow .12s;
7117
+ }
7118
+ button.tile:hover { border-color: var(--focus); }
7119
+ /* The badge's companion: fetches the entries the stream skipped. Sits inline with the
7120
+ heading, so it is styled to read as part of the sentence rather than as a form control. */
7121
+ .load-skipped {
7122
+ font: inherit; margin-left: 6px; padding: 1px 8px; border-radius: 6px; cursor: pointer;
7123
+ border: 1px solid var(--warn-text); background: transparent; color: var(--warn-text);
7124
+ }
7125
+ .load-skipped:hover { background: color-mix(in srgb, var(--warn-text) 12%, transparent); }
7126
+ .load-skipped:disabled { opacity: .5; cursor: default; }
7127
+
7128
+ /* Prev/next under a table. Quiet: it is navigation for a list, not an action on it. */
7129
+ .pager {
7130
+ display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
7131
+ padding: 9px 2px 2px; font-size: 12px; color: var(--muted);
7132
+ }
7133
+ .pager button {
7134
+ font: inherit; padding: 4px 11px; border-radius: 7px;
7135
+ border: 1px solid var(--line); background: var(--surface); color: var(--ink); cursor: pointer;
7136
+ }
7137
+ .pager button:hover:not(:disabled) { border-color: var(--focus); }
7138
+ .pager button:disabled { opacity: .45; cursor: default; }
7139
+ .pager .where { font-variant-numeric: tabular-nums; }
7140
+ .pager.pager-top { padding: 2px 2px 9px; border-bottom: 1px solid var(--line); margin-bottom: 9px; }
7141
+ /* The feed's upper pager rides in the toolbar rather than owning a row of its own, which
7142
+ was thirty-six pixels of mostly empty rule above every screenful of requests. */
7143
+ .pager.pager-inline { padding: 0; margin-left: auto; gap: 8px; }
7144
+ .pager.pager-inline .size { margin-left: 0; }
7145
+ .pager .step { min-width: 30px; font-size: 15px; line-height: 1; padding: 3px 8px 5px; }
7146
+ .pager .size { display: flex; align-items: center; gap: 6px; margin-left: auto; }
7147
+ .pager .size select {
7148
+ font: inherit; padding: 3px 6px; border-radius: 6px;
7149
+ border: 1px solid var(--line); background: var(--surface); color: var(--ink);
7150
+ }
7151
+ .pager .held { color: var(--warn-text); }
6666
7152
  .tile.good .v { color: var(--good-text); }
6667
7153
  .tile.warn .v { color: var(--warn-text); }
6668
7154
  .tile.crit .v { color: var(--crit-text); }
@@ -6677,7 +7163,7 @@ main { padding: 18px 20px 64px; max-width: 1680px; margin: 0 auto; }
6677
7163
  the page header, where they were always meant to. */
6678
7164
  .panel { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; box-shadow: var(--shadow); overflow: clip; }
6679
7165
  .panel > h2 {
6680
- margin: 0; padding: 12px 15px; font-size: 12px; font-weight: 620; color: var(--muted);
7166
+ margin: 0; padding: 9px 14px; font-size: 11.5px; font-weight: 620; color: var(--muted);
6681
7167
  text-transform: uppercase; letter-spacing: .055em; border-bottom: 1px solid var(--line);
6682
7168
  display: flex; align-items: center; gap: 10px;
6683
7169
  }
@@ -6689,6 +7175,17 @@ main { padding: 18px 20px 64px; max-width: 1680px; margin: 0 auto; }
6689
7175
  Embedded in a 320px sidebar on a 1280px screen the media query never fired, the second
6690
7176
  column held its 280px minimum, and the feed was squeezed to twenty-two pixels. */
6691
7177
  .stack { container: dash / inline-size; }
7178
+ /* A grid item's min-width is auto, so a panel wrapping a wide table refuses to shrink and
7179
+ pushes the whole document sideways instead — which is what the Actors screen did under
7180
+ about 600px, where the table is widest and the viewport narrowest. The .two grid already
7181
+ says minmax(0, ...) for this reason; .stack needs the same permission. With it the panel
7182
+ shrinks and the scroller inside it does its job. */
7183
+ .stack > * { min-width: 0; }
7184
+ /* Numeric columns shrink to their contents, so the width goes to the two columns that
7185
+ carry text — the actor and what is known about it. Six counters of one or two digits
7186
+ were each taking about a hundred pixels while the State column was squeezed against the
7187
+ buttons. Same trick the feed's time column already uses. */
7188
+ #view-actors th.num, #view-actors td.num { width: 1%; white-space: nowrap; }
6692
7189
  .two { display: grid; gap: 16px; grid-template-columns: minmax(0, 1.9fr) minmax(280px, 1fr); align-items: start; }
6693
7190
  .grid3 { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); }
6694
7191
  @container dash (max-width: 1080px) { .two { grid-template-columns: minmax(0, 1fr); } }
@@ -6768,7 +7265,7 @@ thead th {
6768
7265
  text-align: left; font-size: 11px; font-weight: 600; color: var(--muted);
6769
7266
  text-transform: uppercase; letter-spacing: .05em; padding: 9px 15px; border-bottom: 1px solid var(--line);
6770
7267
  }
6771
- tbody td { padding: 9px 15px; border-bottom: 1px solid var(--line-soft); vertical-align: top; }
7268
+ tbody td { padding: 7px 14px; border-bottom: 1px solid var(--line-soft); vertical-align: top; }
6772
7269
  /* Narrow, quiet, and never the reason a row wraps: the time is for scanning down, not
6773
7270
  for reading across. */
6774
7271
  tbody td.when { color: var(--muted); font-size: 11.5px; white-space: nowrap; width: 1%; padding-right: 4px; }
@@ -7148,7 +7645,7 @@ input::placeholder { color: color-mix(in srgb, var(--muted) 80%, transparent); }
7148
7645
 
7149
7646
  <div class="two">
7150
7647
  <section class="panel feed-panel">
7151
- <h2>Requests <span class="sub" id="feed-count"></span><span class="sub win" id="feed-window"></span><span class="sub warn-text" id="feed-skipped" hidden></span></h2>
7648
+ <h2>Requests <span class="sub" id="feed-count"></span><span class="sub win" id="feed-window"></span><span class="sub warn-text" id="feed-skipped" hidden></span><button class="sub load-skipped" id="feed-load-skipped" type="button" hidden>Load them</button></h2>
7152
7649
  <div class="toolbar">
7153
7650
  <div class="filters" id="filters"></div>
7154
7651
  <div class="search">
@@ -7156,6 +7653,7 @@ input::placeholder { color: color-mix(in srgb, var(--muted) 80%, transparent); }
7156
7653
  <kbd aria-hidden="true">/</kbd>
7157
7654
  </div>
7158
7655
  <button id="feed-export" title="Download every request matching this filter as replay JSONL">Export</button>
7656
+ <div class="pager pager-inline" id="feed-pager-top" hidden></div>
7159
7657
  </div>
7160
7658
  <div class="feed-scroll">
7161
7659
  <table>
@@ -7167,6 +7665,7 @@ input::placeholder { color: color-mix(in srgb, var(--muted) 80%, transparent); }
7167
7665
  <tbody id="rows"></tbody>
7168
7666
  </table>
7169
7667
  </div>
7668
+ <div class="pager" id="feed-pager" hidden></div>
7170
7669
  <div class="empty" id="empty">Nothing assessed yet. Send some traffic through the handler and it appears here within a moment.</div>
7171
7670
  </section>
7172
7671
 
@@ -7215,6 +7714,7 @@ input::placeholder { color: color-mix(in srgb, var(--muted) 80%, transparent); }
7215
7714
  <div class="note" id="actors-note">Everyone the engine is currently remembering, busiest first — a far larger
7216
7715
  population than the feed's ring, which holds requests rather than clients. This is
7217
7716
  what <code>cadence</code>, <code>crawl-breadth</code> and <code>rate-anomaly</code> are reading.</div>
7717
+ <div class="pager pager-top" id="actors-pager-top" hidden></div>
7218
7718
  <div class="feed-scroll">
7219
7719
  <table>
7220
7720
  <thead>
@@ -7226,6 +7726,7 @@ input::placeholder { color: color-mix(in srgb, var(--muted) 80%, transparent); }
7226
7726
  <tbody id="actor-rows"></tbody>
7227
7727
  </table>
7228
7728
  </div>
7729
+ <div class="pager" id="actors-pager" hidden></div>
7229
7730
  <div class="empty" id="actors-empty" hidden>Nothing in the registry yet.</div>
7230
7731
  </section>
7231
7732
  </div>
@@ -7919,7 +8420,8 @@ function buildDashboard(handler, options, host) {
7919
8420
  case "/api/actors": {
7920
8421
  if (!sections.registry) return sectionOff(response, "registry");
7921
8422
  const limit = Math.min(MAX_ACTORS_LISTED, Math.max(1, Number(url.searchParams.get("limit") ?? 50) || 50));
7922
- const actors = handler.registry.top(limit, handler.config.clock.now()).map((actor) => ({
8423
+ const offset = Math.max(0, Math.floor(Number(url.searchParams.get("offset") ?? 0) || 0));
8424
+ const actors = handler.registry.top(limit, handler.config.clock.now(), offset).map((actor) => ({
7923
8425
  ...actor,
7924
8426
  key: maskIp ? networkKey(actor.key) ?? actor.key : actor.key
7925
8427
  }));
@@ -8991,6 +9493,24 @@ var init_core = __esm({
8991
9493
  this.warn(`Actor "${key}" was cleared as human at runtime${attribute(context)}, until ${new Date(until).toISOString()}.`);
8992
9494
  this.events.emit("actor-change", { key, action: "clear", until, by: context.by });
8993
9495
  }
9496
+ /**
9497
+ * Tells the engine what the application answered.
9498
+ *
9499
+ * The one thing detection cannot see for itself. Every verdict here is reached *before*
9500
+ * the response exists — that is what makes it useful, since it can shape the response —
9501
+ * and so the status is knowledge only the application holds. Handed back, it closes the
9502
+ * oldest gap in reading a scanner: an actor whose requests are almost all misses is
9503
+ * looking for something rather than reading anything, and no amount of header analysis
9504
+ * shows that.
9505
+ *
9506
+ * Optional, and silent when the actor has already been forgotten. Nothing about
9507
+ * detection depends on it being called; supplying it sharpens `probe-volume` and
9508
+ * nothing else. The bundled Node adapter wires it up for you.
9509
+ */
9510
+ recordOutcome(facts, status) {
9511
+ if (!Number.isFinite(status)) return;
9512
+ this.registry.peek(this.actorKeyFor(facts))?.recordOutcome(status);
9513
+ }
8994
9514
  /** Convenience for `updateRanges("crawler:<id>", …)`, matching a signature id. */
8995
9515
  updateCrawlerRanges(signatureId, entries, context = {}) {
8996
9516
  this.updateRanges(`crawler:${signatureId}`, entries, context);
@@ -9380,6 +9900,11 @@ var init_core = __esm({
9380
9900
  key: actorKey,
9381
9901
  requests: 0,
9382
9902
  distinctPaths: 0,
9903
+ distinctQueries: 0,
9904
+ queriesSaturated: false,
9905
+ methodsSeen: ["GET"],
9906
+ responses: 0,
9907
+ misses: 0,
9383
9908
  firstSeen: facts.timestamp,
9384
9909
  lastSeen: facts.timestamp,
9385
9910
  priorConfirmations: 0,
@@ -10190,6 +10715,89 @@ var init_adversarial = __esm({
10190
10715
  init_ranges();
10191
10716
  CHROME_UA = userAgentOf("chromeWindows");
10192
10717
  ADVERSARIAL_CASES = [
10718
+ bot({
10719
+ id: "id-harvest-contiguous",
10720
+ title: "Every profile id in order, with a copied browser header set",
10721
+ audience: "hostile",
10722
+ category: "scraping",
10723
+ provenance: "Harvesting by identifier rather than by link: the shape of an IDOR sweep and of profile collection. Distinct-path breadth reads it as somebody who visited a lot of pages, which is also what it reads when a person works through a documentation site.",
10724
+ requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.65" }, 40, 800, (index) => `/user/${index + 1}`),
10725
+ expect: {
10726
+ // One `moderate` signal against a flawless header set, like the others here. What
10727
+ // changed is that the walk is now *visible* — before this detector it was scored
10728
+ // identically to a hundred and twenty scattered ids and to ordinary article paths.
10729
+ verdict: "unknown",
10730
+ detectors: ["id-enumeration"]
10731
+ },
10732
+ notes: "What separates this from reading is not which ids were asked for but that they cover a range: people arrive at ids through links, and links do not densely enumerate an integer interval. Held at `moderate` because products in one category often carry consecutive ids, so somebody browsing a catalogue makes a smaller version of this shape."
10733
+ }),
10734
+ bot({
10735
+ id: "wordlist-scan-mostly-misses",
10736
+ title: "A wordlist walked with a copied browser header set, almost all of it missing",
10737
+ audience: "hostile",
10738
+ category: "scanning",
10739
+ provenance: "The oldest tell there is, and the one this library could not see: it decides before the response exists, which is what lets it shape the response and also what hides the status from it. A person browsing does not generate thirty misses in a row; a wordlist does almost nothing else.",
10740
+ requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.64", status: 404 }, 30, 700, (index) => `/${["admin", "backup", "old", "test", "config", "db"][index % 6]}-${index}`),
10741
+ expect: {
10742
+ // One `moderate` signal against an otherwise flawless header set does not cross the
10743
+ // line, and it should not: a site that has just moved its URLs produces the same
10744
+ // shape from ordinary readers. Raising the ceiling so this case reads better would
10745
+ // be tuning the detector to the test rather than to the traffic.
10746
+ verdict: "unknown",
10747
+ detectors: ["probe-volume"]
10748
+ },
10749
+ notes: "Only counts 404 and 410. A 403 is usually this library's own doing, and counting it would let a rule that challenges an actor manufacture the evidence for having challenged it; a 500 is the site's problem and says nothing about the client. Capped at `moderate` because a site that has just moved its URLs produces this from perfectly ordinary readers."
10750
+ }),
10751
+ bot({
10752
+ id: "browser-claim-over-http-1-0",
10753
+ title: "A perfect Chrome header set, arriving over HTTP/1.0",
10754
+ audience: "hostile",
10755
+ category: "impersonation",
10756
+ provenance: "Most tooling lets you set headers and does not let you choose an HTTP version, so the transport is the half a copied header set does not cover. No shipping browser has offered HTTP/1.0 to a server in well over a decade.",
10757
+ requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.62", httpVersion: "1.0" }, 30, 900, (index) => `/${["news", "about", "blog", "help", "terms"][index % 5]}`),
10758
+ expect: {
10759
+ // Contributes rather than concludes. On its own, against an otherwise flawless
10760
+ // header set, one `moderate` signal does not reach the threshold — and it should
10761
+ // not, because an intermediary can cause this. Beside anything sharper it does.
10762
+ verdict: "unknown",
10763
+ detectors: ["transport-coherence"]
10764
+ },
10765
+ notes: "Capped at `moderate` because it is not always the client's doing: a few older load balancers speak HTTP/1.0 to the origin, and behind one of those every request looks like this. That is what `transportCoherenceDetector({ legacyHttp: false })` is for, and why this may never deny anybody on its own."
10766
+ }),
10767
+ bot({
10768
+ id: "head-only-visit",
10769
+ title: "A visit made entirely of HEAD, claiming a browser",
10770
+ audience: "unwanted-bot",
10771
+ category: "scraping",
10772
+ provenance: "Checking what exists without reading any of it: link checkers, availability monitors and inventory watchers all do this, and a browser navigating never does.",
10773
+ requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.63", method: "HEAD" }, 30, 900, (index) => `/${["news", "about", "blog", "help", "terms"][index % 5]}`),
10774
+ expect: {
10775
+ // As above: a shape worth reporting, not worth concluding from alone.
10776
+ verdict: "unknown",
10777
+ detectors: ["transport-coherence"]
10778
+ },
10779
+ notes: "One HEAD is a browser checking a link it is about to follow, or a cache revalidating; the shape only means anything across a visit, which is why it is counted on the actor rather than on the request. A link checker is a real and mostly harmless thing to be, so this stays `moderate`."
10780
+ }),
10781
+ bot({
10782
+ id: "catalogue-sweep-by-page",
10783
+ title: "A catalogue taken a page at a time, with the path never changing",
10784
+ audience: "unwanted-bot",
10785
+ category: "scraping",
10786
+ provenance: "How a catalogue is actually taken. The collector copies a browser's headers exactly and walks ?page=1..N, which leaves the path constant \u2014 so distinct-path breadth reads it as somebody rereading one page rather than as enumeration.",
10787
+ requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.61" }, 40, 900, (index) => `/products?page=${index}`),
10788
+ expect: {
10789
+ // Not proven, and not even suspected at this pace. Said plainly because it is true:
10790
+ // headers this clean leave only behaviour, behaviour is weak by construction, and a
10791
+ // collector polite enough to space its requests stays under the line. What changed
10792
+ // is that it no longer scores *lower* than the identical crawl expressed as distinct
10793
+ // paths — measured at a faster pace before this detector existed, the two differed by
10794
+ // seven points and only the path version crossed; they now score the same at every
10795
+ // volume tried.
10796
+ verdict: "unknown",
10797
+ detectors: ["parameter-sweep"]
10798
+ },
10799
+ notes: "The counterpart to crawl-breadth rather than a replacement for it: breadth counts paths, this counts what is hung on them. Both stay weak, and both are worth having because a collector picks one shape or the other and nothing says which. Neither is a reason to deny anybody on its own."
10800
+ }),
10193
10801
  // ---------------------------------------------------------------------------
10194
10802
  // Forged identities. The narrow case where a lie is provable.
10195
10803
  // ---------------------------------------------------------------------------
@@ -13132,6 +13740,7 @@ async function runCase(handler, clock, item, startedAt, provides, assertActions
13132
13740
  const withClearance = clearanceCookie === void 0 ? request : { ...request, headers: [...request.headers, ["Cookie", clearanceCookie]] };
13133
13741
  const facts = toFacts(withClearance, fallbackIp, clock.now());
13134
13742
  const { assessment, decision, outcome } = await handler.handle(facts);
13743
+ if (request.status !== void 0) handler.recordOutcome(facts, request.status);
13135
13744
  requests.push({ assessment, decision, outcome });
13136
13745
  }
13137
13746
  const final = requests[requests.length - 1];