@osqd/bothandlerjs 0.5.0 → 0.7.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 (90) hide show
  1. package/CHANGELOG.md +227 -1
  2. package/README.md +18 -10
  3. package/dist/adapters/fastify.d.ts +10 -0
  4. package/dist/adapters/index.cjs +38 -10
  5. package/dist/adapters/index.cjs.map +1 -1
  6. package/dist/adapters/index.js +38 -10
  7. package/dist/adapters/index.js.map +1 -1
  8. package/dist/challenge/index.d.ts +40 -0
  9. package/dist/cli.cjs +2256 -103
  10. package/dist/cli.cjs.map +1 -1
  11. package/dist/cli.js +2256 -103
  12. package/dist/cli.js.map +1 -1
  13. package/dist/config.d.ts +55 -0
  14. package/dist/core.d.ts +48 -21
  15. package/dist/corpus/index.cjs +365 -7
  16. package/dist/corpus/index.cjs.map +1 -1
  17. package/dist/corpus/index.js +365 -7
  18. package/dist/corpus/index.js.map +1 -1
  19. package/dist/corpus/schema.d.ts +33 -0
  20. package/dist/crawler-ranges.d.ts +31 -0
  21. package/dist/dashboard/client/actions.d.ts +1 -1
  22. package/dist/dashboard/client/app.d.ts +9 -2
  23. package/dist/dashboard/client/boot.d.ts +32 -3
  24. package/dist/dashboard/client/query.d.ts +72 -12
  25. package/dist/dashboard/client/registry.d.ts +25 -0
  26. package/dist/dashboard/client/saved.d.ts +29 -0
  27. package/dist/dashboard/client/store.d.ts +16 -2
  28. package/dist/dashboard/client/types.d.ts +2 -0
  29. package/dist/dashboard/client.generated.d.ts +1 -1
  30. package/dist/dashboard/types.d.ts +15 -0
  31. package/dist/detectors/blended-identity.d.ts +34 -0
  32. package/dist/detectors/challenge-integrity.d.ts +26 -0
  33. package/dist/detectors/challenge-reaction.d.ts +39 -0
  34. package/dist/detectors/clearance.d.ts +1 -23
  35. package/dist/detectors/id-enumeration.d.ts +31 -0
  36. package/dist/detectors/index.d.ts +24 -1
  37. package/dist/detectors/known-bots.d.ts +11 -0
  38. package/dist/detectors/marker.d.ts +106 -0
  39. package/dist/detectors/parameter-sweep.d.ts +39 -0
  40. package/dist/detectors/probe-signature.d.ts +27 -0
  41. package/dist/detectors/probe-volume.d.ts +26 -0
  42. package/dist/detectors/site-baseline.d.ts +135 -0
  43. package/dist/detectors/target-integrity.d.ts +16 -0
  44. package/dist/detectors/transport-coherence.d.ts +31 -0
  45. package/dist/detectors/trap.d.ts +10 -3
  46. package/dist/detectors/types.d.ts +17 -0
  47. package/dist/element/index.cjs +730 -80
  48. package/dist/element/index.cjs.map +1 -1
  49. package/dist/element/index.js +730 -80
  50. package/dist/element/index.js.map +1 -1
  51. package/dist/index.cjs +2044 -123
  52. package/dist/index.cjs.map +1 -1
  53. package/dist/index.d.ts +6 -2
  54. package/dist/index.js +2025 -123
  55. package/dist/index.js.map +1 -1
  56. package/dist/internal/async.d.ts +0 -3
  57. package/dist/internal/ip.d.ts +18 -0
  58. package/dist/internal/text.d.ts +28 -0
  59. package/dist/metrics.d.ts +18 -0
  60. package/dist/probe/index.d.ts +153 -0
  61. package/dist/probe/marker.d.ts +119 -0
  62. package/dist/site/index.d.ts +122 -0
  63. package/dist/state.d.ts +205 -0
  64. package/dist/stores/redis.d.ts +24 -1
  65. package/dist/types.d.ts +106 -0
  66. package/docs/course/05-detectors.md +9 -4
  67. package/docs/course/06-identity.md +1 -1
  68. package/docs/course/16-proving-it.md +15 -9
  69. package/docs/course/index.md +1 -1
  70. package/docs/design/decisions.md +1 -1
  71. package/docs/detection/correlation.md +284 -0
  72. package/docs/detection/detectors.md +259 -1
  73. package/docs/detection/index.md +2 -1
  74. package/docs/detection/shadow-mode.md +147 -0
  75. package/docs/detection/signatures.md +10 -2
  76. package/docs/index.md +3 -2
  77. package/docs/integration/client-ip.md +16 -0
  78. package/docs/operations/dashboard.md +40 -1
  79. package/docs/operations/filters.md +143 -0
  80. package/docs/operations/index.md +1 -0
  81. package/docs/operations/metrics.md +18 -0
  82. package/docs/policy/presets.md +1 -1
  83. package/docs/start/choosing-a-policy.md +1 -1
  84. package/docs/start/first-integration.md +1 -1
  85. package/docs/start/installation.md +2 -2
  86. package/docs/testing/cli.md +7 -1
  87. package/docs/testing/corpus.md +12 -8
  88. package/docs/testing/index.md +1 -1
  89. package/docs/testing/try-it.md +1 -1
  90. package/package.json +4 -1
package/dist/cli.cjs CHANGED
@@ -136,6 +136,45 @@ var init_lru = __esm({
136
136
  }
137
137
  });
138
138
 
139
+ // src/internal/text.ts
140
+ function safeSummary(text) {
141
+ let flawed = text.length > SUMMARY_CHARS;
142
+ if (!flawed) {
143
+ for (let i = 0; i < text.length; i++) {
144
+ const code = text.charCodeAt(i);
145
+ if (code < 32 || code >= 127 && code <= 159 || code >= 55296 && code <= 57343) {
146
+ flawed = true;
147
+ break;
148
+ }
149
+ }
150
+ }
151
+ if (!flawed) return text;
152
+ let out = "";
153
+ const limit = Math.min(text.length, SUMMARY_CHARS);
154
+ for (let i = 0; i < limit; i++) {
155
+ const code = text.charCodeAt(i);
156
+ if (code < 32 || code >= 127 && code <= 159) {
157
+ out += "\uFFFD";
158
+ } else if (code >= 55296 && code <= 56319) {
159
+ const next = text.charCodeAt(i + 1);
160
+ if (next >= 56320 && next <= 57343) {
161
+ out += text[i] + text[i + 1];
162
+ i++;
163
+ } else out += "\uFFFD";
164
+ } else if (code >= 56320 && code <= 57343) {
165
+ out += "\uFFFD";
166
+ } else out += text[i];
167
+ }
168
+ return text.length > SUMMARY_CHARS ? `${out}\u2026` : out;
169
+ }
170
+ var SUMMARY_CHARS;
171
+ var init_text = __esm({
172
+ "src/internal/text.ts"() {
173
+ "use strict";
174
+ SUMMARY_CHARS = 512;
175
+ }
176
+ });
177
+
139
178
  // src/state.ts
140
179
  function hashString(value) {
141
180
  let hash = 2166136261;
@@ -145,16 +184,50 @@ function hashString(value) {
145
184
  }
146
185
  return hash >>> 0;
147
186
  }
148
- var TIMESTAMP_RING, PATH_CAP, UA_CAP, MAX_TRACKED_ARRIVALS, MAX_TRACKED_PATHS, ActorState, ActorRegistry;
187
+ function walkStepOf(path) {
188
+ if (path.length > MAX_WALK_PATH_CHARS) return void 0;
189
+ let depth = 0;
190
+ for (let i = 0; i < path.length; i++) {
191
+ if (path.charCodeAt(i) === 47 && ++depth > MAX_WALK_SEGMENTS) return void 0;
192
+ }
193
+ let value;
194
+ const parts = [];
195
+ for (const segment of path.split("/")) {
196
+ if (segment === "") continue;
197
+ if (DIGITS.test(segment)) {
198
+ const parsed = Number(segment);
199
+ if (Number.isSafeInteger(parsed) && parsed <= 1e7) value = parsed;
200
+ parts.push("#");
201
+ } else {
202
+ parts.push(segment);
203
+ }
204
+ }
205
+ if (value === void 0) return void 0;
206
+ let template = `/${parts.join("/")}`;
207
+ if (template.length > TEMPLATE_CHARS) template = `${template.slice(0, TEMPLATE_CHARS)}\u2026`;
208
+ return { template, id: value };
209
+ }
210
+ var TIMESTAMP_RING, PATH_CAP, QUERY_CAP, METHOD_CAP, WALK_CAP, IDENTITY_CAP, TEMPLATE_CHARS, MAX_WALK_SEGMENTS, MAX_WALK_PATH_CHARS, MAX_QUERY_KEYS, DIGITS, UA_CAP, MAX_TRACKED_ARRIVALS, MAX_TRACKED_PATHS, MAX_TRACKED_QUERIES, ActorState, ActorRegistry;
149
211
  var init_state = __esm({
150
212
  "src/state.ts"() {
151
213
  "use strict";
152
214
  init_lru();
215
+ init_text();
153
216
  TIMESTAMP_RING = 32;
154
217
  PATH_CAP = 64;
218
+ QUERY_CAP = 64;
219
+ METHOD_CAP = 12;
220
+ WALK_CAP = 4;
221
+ IDENTITY_CAP = 12;
222
+ TEMPLATE_CHARS = 120;
223
+ MAX_WALK_SEGMENTS = 24;
224
+ MAX_WALK_PATH_CHARS = 512;
225
+ MAX_QUERY_KEYS = 24;
226
+ DIGITS = /^\d+$/;
155
227
  UA_CAP = 4;
156
228
  MAX_TRACKED_ARRIVALS = TIMESTAMP_RING;
157
229
  MAX_TRACKED_PATHS = PATH_CAP;
230
+ MAX_TRACKED_QUERIES = QUERY_CAP;
158
231
  ActorState = class {
159
232
  key;
160
233
  firstSeen;
@@ -195,6 +268,96 @@ var init_state = __esm({
195
268
  paths = /* @__PURE__ */ new Set();
196
269
  pathsOverflowed = false;
197
270
  pathsSaturatedAtTotal = 0;
271
+ /**
272
+ * Distinct *parameterised* requests: the path together with its query.
273
+ *
274
+ * Counted apart from `paths` because the two answer different questions and a scraper
275
+ * lives in the gap between them. `/products?page=1` through `?page=200` is one path and
276
+ * two hundred requests, so breadth reads it as somebody rereading a single page — which
277
+ * is exactly what enumerating a catalogue looks like from the path alone.
278
+ */
279
+ queries = /* @__PURE__ */ new Set();
280
+ queriesOverflowed = false;
281
+ /**
282
+ * Which HTTP methods this actor has used.
283
+ *
284
+ * A browser navigating issues GET. Something that has issued nothing but HEAD across a
285
+ * long visit is checking what exists rather than reading it, and that is a fact about
286
+ * the actor rather than about any one of its requests — which is why it is kept here.
287
+ */
288
+ /**
289
+ * What has happened with this actor's marker cookie.
290
+ *
291
+ * Counted rather than listed: the useful questions are all "how often", and a list of
292
+ * marker ids would grow with a client's cookie jar for no benefit. The three drift
293
+ * flags are sticky — once a client has been seen claiming two different browsers under
294
+ * one marker it has done so, and a later request that looks tidy again does not undo
295
+ * it. That is the point of correlating a series rather than judging a request.
296
+ */
297
+ /**
298
+ * When this actor was last challenged, and how it described itself at that moment.
299
+ *
300
+ * Kept so that what a client does *in response* to being challenged can be read. That
301
+ * reaction is better evidence than anything observed passively, because the stimulus
302
+ * was ours: we chose the moment, so a change of identity that follows it within
303
+ * seconds is a reaction to it rather than a coincidence we went looking for.
304
+ */
305
+ /**
306
+ * Answers to challenges that were valid in form but wrong in a way only the series
307
+ * shows: a solution already spent, or one returned faster than the puzzle allows.
308
+ */
309
+ /** Requests for a path no other client had ever asked this site for. */
310
+ novelPaths = 0;
311
+ replayedSolutions = 0;
312
+ implausibleSolves = 0;
313
+ challengedAt = 0;
314
+ challengeShape;
315
+ markerIssues = 0;
316
+ markerReturns = 0;
317
+ markerForgeries = 0;
318
+ driftSeen = { browser: false, platform: false, language: false };
319
+ driftEvents = 0;
320
+ methods = /* @__PURE__ */ new Set();
321
+ /**
322
+ * Numeric walks in progress, by path shape: `/user/#` against the ids requested under it.
323
+ *
324
+ * Three numbers per shape, deliberately — a count, a lowest and a highest — rather than
325
+ * the ids themselves. What separates enumeration from reading is not which ids were
326
+ * asked for but whether they *cover a range*: thirty requests spanning thirty
327
+ * consecutive ids is a walk, and thirty scattered across a hundred thousand is somebody
328
+ * following links. Both are answerable from a count and a span, and only the count and
329
+ * the span survive an actor asking for ten thousand of them.
330
+ */
331
+ walks = /* @__PURE__ */ new Map();
332
+ /**
333
+ * Every named identity this actor has claimed, and what kind each was.
334
+ *
335
+ * Kept because the interesting question is not what one request said but what the *set*
336
+ * of them says. One address claiming sqlmap and nikto is a scan; one claiming Googlebot
337
+ * and Bingbot is a forgery, since at most one of those can be true of an address. Neither
338
+ * observation exists inside a single request.
339
+ */
340
+ identities = /* @__PURE__ */ new Map();
341
+ /**
342
+ * A name somebody gave this actor.
343
+ *
344
+ * Nothing in detection reads it. It exists because an address is not a memory: the
345
+ * person who worked out that `198.51.100.4` is the partner's price feed should be able
346
+ * to write that down where the next person will see it, rather than in a ticket.
347
+ */
348
+ actorLabel;
349
+ /** Requests from this actor that carried a scanner payload or target. */
350
+ probePayloads = 0;
351
+ /**
352
+ * What the application answered, for the requests anybody bothered to tell us about.
353
+ *
354
+ * The engine decides *before* the response exists, so this arrives afterwards and only
355
+ * when the adapter reports it. Kept as two counters rather than a list because the one
356
+ * question worth asking is a ratio: an actor whose requests are almost all misses is
357
+ * looking for something rather than reading anything.
358
+ */
359
+ responsesSeen = 0;
360
+ missesSeen = 0;
198
361
  userAgents = /* @__PURE__ */ new Set();
199
362
  constructor(key, now) {
200
363
  this.key = key;
@@ -214,6 +377,16 @@ var init_state = __esm({
214
377
  this.pathsOverflowed = true;
215
378
  this.pathsSaturatedAtTotal = this.total;
216
379
  }
380
+ const keys = Object.keys(facts.query);
381
+ if (keys.length > 0 && keys.length <= MAX_QUERY_KEYS) {
382
+ keys.sort();
383
+ const signature = `${facts.path}?${keys.map((key) => `${key}=${facts.query[key] ?? ""}`).join("&")}`;
384
+ const queryHash = hashString(signature);
385
+ if (this.queries.size < QUERY_CAP) this.queries.add(queryHash);
386
+ else if (!this.queries.has(queryHash)) this.queriesOverflowed = true;
387
+ }
388
+ if (this.methods.size < METHOD_CAP) this.methods.add(facts.method);
389
+ this.noteWalk(facts.path);
217
390
  const ua = facts.headers["user-agent"];
218
391
  if (ua !== void 0 && this.userAgents.size < UA_CAP) this.userAgents.add(ua);
219
392
  }
@@ -224,6 +397,145 @@ var init_state = __esm({
224
397
  get pathsSaturated() {
225
398
  return this.pathsOverflowed;
226
399
  }
400
+ /** Distinct path-and-query combinations seen. Saturates at {@link QUERY_CAP}. */
401
+ get distinctQueries() {
402
+ return this.queries.size;
403
+ }
404
+ get queriesSaturated() {
405
+ return this.queriesOverflowed;
406
+ }
407
+ /**
408
+ * Records what the application answered. Called after the response, if at all.
409
+ *
410
+ * 404 and 410 only. A 403 is usually this library's own doing and counting it would
411
+ * make the detector that reads this argue with itself; a 500 is the site's problem and
412
+ * says nothing about the client.
413
+ */
414
+ recordOutcome(status) {
415
+ this.responsesSeen++;
416
+ if (status === 404 || status === 410) this.missesSeen++;
417
+ }
418
+ /** Responses reported for this actor. Zero unless something is reporting them. */
419
+ get responses() {
420
+ return this.responsesSeen;
421
+ }
422
+ /** Of those, how many were 404 or 410. */
423
+ get misses() {
424
+ return this.missesSeen;
425
+ }
426
+ /** Names this actor, or clears the name when given nothing. Trimmed and bounded. */
427
+ setLabel(label) {
428
+ const trimmed = label === void 0 ? void 0 : safeSummary(label).trim().slice(0, 120);
429
+ this.actorLabel = trimmed === void 0 || trimmed === "" ? void 0 : trimmed;
430
+ }
431
+ get label() {
432
+ return this.actorLabel;
433
+ }
434
+ /** Records a named identity this actor claimed. Called once per matching signature. */
435
+ noteIdentity(id, category, verifiable) {
436
+ if (this.identities.has(id) || this.identities.size >= IDENTITY_CAP) return;
437
+ this.identities.set(id, { category, verifiable });
438
+ }
439
+ /** Records that this request was for a path the site had never served to anybody. */
440
+ noteNovelPath() {
441
+ if (this.novelPaths < 1e6) this.novelPaths++;
442
+ }
443
+ /** How many of this actor's requests were for a path nobody else had ever asked for. */
444
+ get novelPathCount() {
445
+ return this.novelPaths;
446
+ }
447
+ /** Records something wrong with a submitted solution that only its history reveals. */
448
+ noteChallengeAnomaly(kind) {
449
+ if (kind === "replay") {
450
+ if (this.replayedSolutions < 1e6) this.replayedSolutions++;
451
+ } else if (this.implausibleSolves < 1e6) this.implausibleSolves++;
452
+ }
453
+ /** Solutions this actor submitted that had already been spent, and ones returned too fast. */
454
+ get challengeAnomalies() {
455
+ return { replays: this.replayedSolutions, implausible: this.implausibleSolves };
456
+ }
457
+ /** Records that a challenge went out, and the identity claimed as it did. */
458
+ noteChallengeIssued(at, shape) {
459
+ this.challengedAt = at;
460
+ this.challengeShape = shape;
461
+ }
462
+ /** The moment of the last challenge, and the identity claimed then. `at` is 0 for none. */
463
+ get lastChallenge() {
464
+ return { at: this.challengedAt, shape: this.challengeShape };
465
+ }
466
+ /** Records that a marker was handed to this actor on the way out. */
467
+ noteMarkerIssued() {
468
+ if (this.markerIssues < 1e6) this.markerIssues++;
469
+ }
470
+ /** Records what this request's marker cookie turned out to be. */
471
+ noteMarker(returned, forged, drift) {
472
+ if (returned && this.markerReturns < 1e6) this.markerReturns++;
473
+ if (forged && this.markerForgeries < 1e6) this.markerForgeries++;
474
+ if (drift === void 0) return;
475
+ if (drift.browser || drift.platform || drift.language) {
476
+ if (this.driftEvents < 1e6) this.driftEvents++;
477
+ }
478
+ this.driftSeen.browser ||= drift.browser;
479
+ this.driftSeen.platform ||= drift.platform;
480
+ this.driftSeen.language ||= drift.language;
481
+ }
482
+ /** Markers handed to this actor, and how many came back. */
483
+ get markers() {
484
+ return { issued: this.markerIssues, returned: this.markerReturns, forged: this.markerForgeries };
485
+ }
486
+ /** Which parts of a claimed identity have ever changed under one marker. */
487
+ get identityDrift() {
488
+ return { ...this.driftSeen, events: this.driftEvents };
489
+ }
490
+ /** Records that this request carried a scanner payload, so later requests can know. */
491
+ notePayloadProbe() {
492
+ this.probePayloads++;
493
+ }
494
+ /** Every identity claimed so far, by id. */
495
+ get claimedIdentities() {
496
+ return this.identities;
497
+ }
498
+ /** How many of this actor's requests carried a scanner payload or target. */
499
+ get payloadProbes() {
500
+ return this.probePayloads;
501
+ }
502
+ /**
503
+ * Files a request under the shape of its path, if that path carries a number.
504
+ *
505
+ * The last numeric segment is the one taken to be the identifier: in `/api/v2/orders/42`
506
+ * the version is part of the shape and the order id is what is being walked.
507
+ */
508
+ noteWalk(path) {
509
+ const step = walkStepOf(path);
510
+ if (step === void 0) return;
511
+ const { template, id: value } = step;
512
+ const existing = this.walks.get(template);
513
+ if (existing !== void 0) {
514
+ existing.count++;
515
+ if (value < existing.min) existing.min = value;
516
+ if (value > existing.max) existing.max = value;
517
+ return;
518
+ }
519
+ if (this.walks.size < WALK_CAP) this.walks.set(template, { count: 1, min: value, max: value });
520
+ }
521
+ /**
522
+ * The path shape this actor has walked hardest, with how far it reached.
523
+ *
524
+ * `span` is inclusive of both ends, so a walk of 1 to 30 spans 30. Comparing the count
525
+ * against it is what separates covering a range from visiting a few points in one.
526
+ */
527
+ densestWalk() {
528
+ let best;
529
+ for (const [template, walk] of this.walks) {
530
+ const span = walk.max - walk.min + 1;
531
+ if (best === void 0 || walk.count > best.count) best = { template, count: walk.count, span };
532
+ }
533
+ return best;
534
+ }
535
+ /** Every HTTP method this actor has used, in first-seen order. */
536
+ get methodsSeen() {
537
+ return [...this.methods];
538
+ }
227
539
  /**
228
540
  * Requests seen when {@link distinctPaths} stopped being able to grow, or 0 if it
229
541
  * still can. Over that many requests the distinct count is exact, so it is the only
@@ -312,6 +624,13 @@ var init_state = __esm({
312
624
  key: this.key,
313
625
  requests: this.total,
314
626
  distinctPaths: this.distinctPaths,
627
+ distinctQueries: this.distinctQueries,
628
+ methodsSeen: this.methodsSeen,
629
+ ...this.actorLabel === void 0 ? {} : { label: this.actorLabel },
630
+ walk: this.densestWalk(),
631
+ responses: this.responses,
632
+ misses: this.misses,
633
+ queriesSaturated: this.queriesSaturated,
315
634
  firstSeen: this.firstSeen,
316
635
  lastSeen: this.lastSeen,
317
636
  sinceLastMs: this.sinceLast(now),
@@ -658,6 +977,12 @@ function renderChallengePage(options) {
658
977
  <meta charset="utf-8">
659
978
  <meta name="viewport" content="width=device-width, initial-scale=1">
660
979
  <meta name="robots" content="noindex, nofollow">
980
+ <!-- An empty icon, so the browser does not go looking for /favicon.ico on its own. It
981
+ is the browser that makes that request rather than this page, and under
982
+ default-src 'none' it is refused \u2014 which Firefox reports to the console as a
983
+ security error on a page whose entire purpose is to reassure somebody that nothing
984
+ is wrong. Declaring one stops the request being made at all. -->
985
+ <link rel="icon" href="data:,">
661
986
  <title>${title}</title>
662
987
  <style>
663
988
  :root { color-scheme: light dark; --fg: #16181d; --muted: #5b6270; --bg: #fbfbfc; --line: #e2e5ea; --accent: #2f6feb; }
@@ -848,8 +1173,8 @@ function parseAcceptLanguage(header) {
848
1173
  if (header === void 0 || header.trim() === "") return [];
849
1174
  const entries = [];
850
1175
  const parts = header.split(",").slice(0, MAX_TAGS);
851
- parts.forEach((part, order) => {
852
- const [rawTag, ...parameters] = part.trim().split(";");
1176
+ parts.forEach((part2, order) => {
1177
+ const [rawTag, ...parameters] = part2.trim().split(";");
853
1178
  const tag = (rawTag ?? "").trim().toLowerCase();
854
1179
  if (tag === "" || tag === "*" || !/^[a-z]{1,8}(-[a-z\d]{1,8})*$/.test(tag)) return;
855
1180
  let q = 1;
@@ -1030,7 +1355,14 @@ function analyseMovement(path) {
1030
1355
  timingVariation: coefficientOfVariation(gaps),
1031
1356
  accelerationChanges,
1032
1357
  straightness: pathLength === 0 ? 1 : Math.min(1, Math.hypot(netX, netY) / pathLength),
1033
- fractionalShare: fractional / samples.length,
1358
+ // Over the samples this actually looked at, not over everything that arrived. The
1359
+ // two differ by however many discontinuities were dropped above, and using the raw
1360
+ // count meant a path with pauses in it reported a *lower* fractional share than the
1361
+ // samples it was computed from — which reads as "these coordinates are integers"
1362
+ // when what happened is that most of them were never examined. It costs the people
1363
+ // most likely to have pauses: somebody who moved the pointer, stopped to read, and
1364
+ // moved again.
1365
+ fractionalShare: distances.length === 0 ? 0 : fractional / distances.length,
1034
1366
  totalTurning
1035
1367
  };
1036
1368
  }
@@ -1125,9 +1457,10 @@ function verifyInteraction(report2, elapsedMs, settings = DEFAULT_INTERACTION_SE
1125
1457
  const failed = Object.keys(CAPABILITY_WEIGHTS).filter((name) => report2.capabilities[name] !== true);
1126
1458
  notes.push(failed.length === 0 ? "capabilities 100%" : `capabilities ${(capabilityScore * 100).toFixed(0)}% (missing: ${failed.join(", ")})`);
1127
1459
  let score = capabilityScore * 0.6;
1128
- const measurable = report2.via === "pointer" && report2.path.length >= 4;
1460
+ const analysis = analyseMovement(report2.path);
1461
+ const measurable = report2.via === "pointer" && analysis.samples >= 4;
1129
1462
  if (measurable) {
1130
- const movement = scoreMovement(analyseMovement(report2.path));
1463
+ const movement = scoreMovement(analysis);
1131
1464
  notes.push(`movement ${(movement * 100).toFixed(0)}%`);
1132
1465
  score += movement * 0.4;
1133
1466
  } else {
@@ -1226,7 +1559,7 @@ var init_http = __esm({
1226
1559
  });
1227
1560
 
1228
1561
  // src/challenge/index.ts
1229
- var ChallengeService;
1562
+ var IMPLAUSIBLE_HASHES_PER_MS, MAX_TRACKED_TOKENS, MAX_BEARERS, ChallengeService;
1230
1563
  var init_challenge = __esm({
1231
1564
  "src/challenge/index.ts"() {
1232
1565
  "use strict";
@@ -1237,7 +1570,11 @@ var init_challenge = __esm({
1237
1570
  init_interaction();
1238
1571
  init_http();
1239
1572
  init_crypto();
1573
+ init_lru();
1240
1574
  init_clock();
1575
+ IMPLAUSIBLE_HASHES_PER_MS = 2e4;
1576
+ MAX_TRACKED_TOKENS = 2e4;
1577
+ MAX_BEARERS = 64;
1241
1578
  ChallengeService = class {
1242
1579
  constructor(options) {
1243
1580
  this.options = options;
@@ -1253,11 +1590,14 @@ var init_challenge = __esm({
1253
1590
  this.verifyPath = options.verifyPath ?? "/__bothandler/verify";
1254
1591
  this.cookieName = options.cookieName ?? "__bh_clearance";
1255
1592
  this.clock = options.clock ?? systemClock;
1593
+ this.bearers = new TtlLru(MAX_TRACKED_TOKENS, this.clearanceTtlMs, this.clock);
1256
1594
  this.store = options.store;
1257
1595
  this.interaction = !wantsGesture ? void 0 : { ...DEFAULT_INTERACTION_SETTINGS, ...options.interaction === true ? {} : options.interaction };
1258
1596
  }
1259
1597
  options;
1260
1598
  secrets;
1599
+ /** Clearance token id to the actors that have presented it. Bounded both ways. */
1600
+ bearers;
1261
1601
  difficulty;
1262
1602
  challengeTtlMs;
1263
1603
  clock;
@@ -1340,7 +1680,11 @@ var init_challenge = __esm({
1340
1680
  "cache-control": "no-store, private",
1341
1681
  // The page carries one inline script and nothing else. Locking the policy
1342
1682
  // this far down means the interstitial cannot be turned into a fetch primitive.
1343
- "content-security-policy": `default-src 'none'; script-src 'nonce-${rendered.scriptNonce}'; style-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`,
1683
+ // `img-src data:` permits nothing off this machine a data: URI is inline by
1684
+ // definition — and exists only so the empty icon the page declares is honoured.
1685
+ // Without it the browser asks for /favicon.ico by itself and is refused, which
1686
+ // Firefox prints as a security error in the console of every person challenged.
1687
+ "content-security-policy": `default-src 'none'; script-src 'nonce-${rendered.scriptNonce}'; style-src 'unsafe-inline'; img-src data:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`,
1344
1688
  "referrer-policy": "no-referrer",
1345
1689
  "x-content-type-options": "nosniff",
1346
1690
  "x-robots-tag": "noindex, nofollow"
@@ -1385,6 +1729,11 @@ var init_challenge = __esm({
1385
1729
  interactionScore = outcome.score;
1386
1730
  notes = outcome.notes;
1387
1731
  }
1732
+ const elapsedSinceIssue = this.clock.now() - verified.payload.iat;
1733
+ const floorMs = 2 ** verified.payload.diff / IMPLAUSIBLE_HASHES_PER_MS | 0;
1734
+ if (elapsedSinceIssue >= 0 && elapsedSinceIssue < floorMs) {
1735
+ return { ok: false, status: 400, reason: "solution returned faster than the puzzle allows", signal: "implausible-speed" };
1736
+ }
1388
1737
  if (this.store) {
1389
1738
  let claimed;
1390
1739
  try {
@@ -1392,7 +1741,7 @@ var init_challenge = __esm({
1392
1741
  } catch {
1393
1742
  claimed = true;
1394
1743
  }
1395
- if (!claimed) return { ok: false, status: 409, reason: "challenge already solved" };
1744
+ if (!claimed) return { ok: false, status: 409, reason: "challenge already solved", signal: "replay" };
1396
1745
  }
1397
1746
  return {
1398
1747
  ok: true,
@@ -1422,10 +1771,55 @@ var init_challenge = __esm({
1422
1771
  }
1423
1772
  /** Reads and validates the clearance cookie for an actor. Returns `undefined` if there is none valid. */
1424
1773
  read(actorKey, cookies) {
1774
+ const inspected = this.inspect(actorKey, cookies);
1775
+ return inspected.claims;
1776
+ }
1777
+ /**
1778
+ * Reads a clearance token and says what became of it.
1779
+ *
1780
+ * `read` answers the only question the clearance detector used to ask — is this client
1781
+ * cleared — and throws away the reason when the answer is no. One of those reasons is
1782
+ * worth keeping: a token whose *signature* is ours but whose subject is somebody
1783
+ * else's has been moved between clients. Usually that is innocent and extremely
1784
+ * common, because the subject is derived from the address and a phone changing
1785
+ * networks changes its address. It stops being innocent when one token turns up under
1786
+ * a great many different actors, which is a token being handed around.
1787
+ */
1788
+ inspect(actorKey, cookies) {
1425
1789
  const token = cookies?.[this.cookieName];
1426
- if (token === void 0) return void 0;
1427
- const verified = verifyToken(token, this.secrets, this.clock.now(), this.subjectsFor(actorKey));
1428
- return verified.ok ? verified.payload : void 0;
1790
+ if (token === void 0) return { presentedBy: 0 };
1791
+ const now = this.clock.now();
1792
+ const verified = verifyToken(token, this.secrets, now, this.subjectsFor(actorKey));
1793
+ if (verified.ok) return { claims: verified.payload, presentedBy: this.noteBearer(verified.payload.jti, actorKey) };
1794
+ if (verified.reason !== "wrong-actor") return { presentedBy: 0 };
1795
+ const claims = this.claimsOf(token);
1796
+ return claims === void 0 ? { presentedBy: 0 } : { boundElsewhere: true, presentedBy: this.noteBearer(claims.jti, actorKey) };
1797
+ }
1798
+ /** The claims inside a token whose signature has already been checked. */
1799
+ claimsOf(token) {
1800
+ const separator = token.lastIndexOf(".");
1801
+ if (separator <= 0) return void 0;
1802
+ try {
1803
+ const claims = JSON.parse(base64UrlDecode(token.slice(0, separator)).toString("utf8"));
1804
+ return typeof claims?.jti === "string" ? claims : void 0;
1805
+ } catch {
1806
+ return void 0;
1807
+ }
1808
+ }
1809
+ /**
1810
+ * Files this presentation under the token's own id, returning how many distinct actors
1811
+ * have now presented it. Bounded in both directions, and in process for the reason
1812
+ * given in `state.ts`: a store round trip per request buys precision nobody asked for.
1813
+ */
1814
+ noteBearer(jti, actorKey) {
1815
+ if (this.bearers === void 0) return 0;
1816
+ let seen = this.bearers.get(jti);
1817
+ if (seen === void 0) {
1818
+ seen = /* @__PURE__ */ new Set();
1819
+ this.bearers.set(jti, seen);
1820
+ }
1821
+ if (seen.size < MAX_BEARERS) seen.add(actorKey);
1822
+ return seen.size;
1429
1823
  }
1430
1824
  /** A `Set-Cookie` that removes any clearance. Call it on logout. */
1431
1825
  revoke() {
@@ -1598,6 +1992,11 @@ function toPrometheus(snapshot, options = {}) {
1598
1992
  counter("downgrades_total", "Terminal actions the safety guard replaced for lack of proof.", [["", snapshot.downgrades]]);
1599
1993
  counter("proven_total", "Assessments resting on proven evidence.", [["", snapshot.proven]]);
1600
1994
  counter("detector_firings_total", "Evidence produced, by detector.", Object.entries(snapshot.detectorFirings).map(([detector, value]) => [`{detector="${escapeLabel(detector)}"}`, value]));
1995
+ const shadowFirings = Object.entries(snapshot.shadowFirings);
1996
+ if (shadowFirings.length > 0) {
1997
+ counter("shadow_firings_total", "Evidence produced by shadowed detectors, which decided nothing.", shadowFirings.map(([detector, value]) => [`{detector="${escapeLabel(detector)}"}`, value]));
1998
+ counter("shadow_verdict_changes_total", "Assessments the shadowed detectors would have moved, by the verdict they would have produced.", Object.entries(snapshot.shadowChanges).map(([verdict, value]) => [`{verdict="${verdict}"}`, value]));
1999
+ }
1601
2000
  counter("detector_failures_total", "Detector errors and timeouts.", Object.entries(snapshot.detectorFailures).map(([detector, value]) => [`{detector="${escapeLabel(detector)}"}`, value]));
1602
2001
  const timings = Object.entries(snapshot.detectorTimings);
1603
2002
  if (timings.length > 0) {
@@ -1665,6 +2064,8 @@ var init_metrics = __esm({
1665
2064
  detectorFirings = /* @__PURE__ */ new Map();
1666
2065
  detectorFailures = /* @__PURE__ */ new Map();
1667
2066
  detectorTimings = /* @__PURE__ */ new Map();
2067
+ shadowFirings = /* @__PURE__ */ new Map();
2068
+ shadowChanges = zeroed(VERDICTS);
1668
2069
  challengesIssued = 0;
1669
2070
  challengesSolved = 0;
1670
2071
  challengesRejected = 0;
@@ -1698,6 +2099,10 @@ var init_metrics = __esm({
1698
2099
  }
1699
2100
  for (const item of assessment.evidence) bump(this.detectorFirings, item.detector);
1700
2101
  for (const item of assessment.humanEvidence) bump(this.detectorFirings, item.detector);
2102
+ for (const item of assessment.shadowEvidence) bump(this.shadowFirings, item.detector);
2103
+ if (assessment.shadowVerdict !== void 0 && assessment.shadowVerdict.verdict !== assessment.verdict) {
2104
+ this.shadowChanges[assessment.shadowVerdict.verdict]++;
2105
+ }
1701
2106
  for (const failure of assessment.failures) bump(this.detectorFailures, failure.detector);
1702
2107
  const ms = assessment.durationMs;
1703
2108
  this.durationCount++;
@@ -1764,6 +2169,8 @@ var init_metrics = __esm({
1764
2169
  detectorFirings: Object.fromEntries(this.detectorFirings),
1765
2170
  detectorFailures: Object.fromEntries(this.detectorFailures),
1766
2171
  detectorTimings: Object.fromEntries([...this.detectorTimings].map(([id, timing]) => [id, { ...timing }])),
2172
+ shadowFirings: Object.fromEntries(this.shadowFirings),
2173
+ shadowChanges: { ...this.shadowChanges },
1767
2174
  challenges: { issued: this.challengesIssued, solved: this.challengesSolved, rejected: this.challengesRejected },
1768
2175
  clearances: Object.fromEntries(this.clearances),
1769
2176
  challengeRejections: Object.fromEntries(this.challengeRejections),
@@ -2105,8 +2512,21 @@ __export(ip_exports, {
2105
2512
  networkKey: () => networkKey,
2106
2513
  normalizeIp: () => normalizeIp,
2107
2514
  parseCidr: () => parseCidr,
2108
- parseIp: () => parseIp
2515
+ parseIp: () => parseIp,
2516
+ stripPort: () => stripPort
2109
2517
  });
2518
+ function stripPort(value) {
2519
+ const input = value.trim();
2520
+ if (input.startsWith("[")) {
2521
+ const close = input.indexOf("]");
2522
+ if (close > 0) return input.slice(1, close);
2523
+ return input;
2524
+ }
2525
+ const colon = input.indexOf(":");
2526
+ if (colon === -1 || input.indexOf(":", colon + 1) !== -1) return input;
2527
+ const host = input.slice(0, colon);
2528
+ return parseIpv4(host) !== null ? host : input;
2529
+ }
2110
2530
  function parseIp(value) {
2111
2531
  const input = value.trim();
2112
2532
  if (input.length === 0 || input.length > 45) return null;
@@ -2119,11 +2539,11 @@ function parseIpv4(value) {
2119
2539
  if (parts.length !== 4) return null;
2120
2540
  const bytes = new Uint8Array(4);
2121
2541
  for (let i = 0; i < 4; i++) {
2122
- const part = parts[i];
2123
- if (part.length === 0 || part.length > 3) return null;
2124
- if (!/^\d+$/.test(part)) return null;
2125
- if (part.length > 1 && part[0] === "0") return null;
2126
- const n = Number(part);
2542
+ const part2 = parts[i];
2543
+ if (part2.length === 0 || part2.length > 3) return null;
2544
+ if (!/^\d+$/.test(part2)) return null;
2545
+ if (part2.length > 1 && part2[0] === "0") return null;
2546
+ const n = Number(part2);
2127
2547
  if (n > 255) return null;
2128
2548
  bytes[i] = n;
2129
2549
  }
@@ -2390,9 +2810,13 @@ function redactAssessment(assessment, options) {
2390
2810
  removed,
2391
2811
  assessment: {
2392
2812
  ...assessment,
2813
+ ...assessment.marker === void 0 ? {} : { marker: reduceMarker(assessment.marker, options) },
2393
2814
  actor: options.maskIp ? { ...assessment.actor, key: maskActorKey(assessment.actor.key) } : assessment.actor,
2394
2815
  evidence: scrubEvidence(assessment.evidence, removed),
2395
2816
  humanEvidence: scrubEvidence(assessment.humanEvidence, removed),
2817
+ // Scrubbed on the same terms: a shadowed detector reads the same request as every
2818
+ // other one, so its summary can quote the same secret out of it.
2819
+ shadowEvidence: scrubEvidence(assessment.shadowEvidence, removed),
2396
2820
  facts: {
2397
2821
  ...assessment.facts,
2398
2822
  ip: options.maskIp ? maskIpValue(assessment.facts.ip) : assessment.facts.ip,
@@ -2444,6 +2868,15 @@ function maskActorKey(key) {
2444
2868
  if (separator === -1) return networkKey(key);
2445
2869
  return `${networkKey(key.slice(0, separator))}|${key.slice(separator + 1)}`;
2446
2870
  }
2871
+ function reduceMarker(marker, options) {
2872
+ return {
2873
+ ...marker,
2874
+ reading: { kind: marker.reading.kind },
2875
+ // The shape is three coarse parts of the User-Agent. If the User-Agent itself is
2876
+ // being withheld, the parts of it must go too, or the setting only half applies.
2877
+ ...options.dropUserAgent === true ? { shape: { b: "", o: "", l: "" } } : {}
2878
+ };
2879
+ }
2447
2880
  var REDACTED, CREDENTIAL_HEADERS, ALWAYS_STRIP, MIN_SCRUB_LENGTH;
2448
2881
  var init_redact = __esm({
2449
2882
  "src/notify/redact.ts"() {
@@ -3177,37 +3610,50 @@ var init_dns = __esm({
3177
3610
  });
3178
3611
 
3179
3612
  // src/detectors/clearance.ts
3180
- function clearanceDetector(service) {
3613
+ function withShared(shared, primary) {
3614
+ return shared === void 0 ? primary : [primary, shared];
3615
+ }
3616
+ function clearanceDetector(service, sharingThreshold = DEFAULT_SHARING_THRESHOLD) {
3181
3617
  return {
3182
3618
  id: "clearance",
3183
3619
  description: "Reads a signed clearance token proving the client previously passed a check",
3184
3620
  cost: "cheap",
3185
3621
  stage: "always",
3186
3622
  inspect(ctx) {
3187
- const claims = service.read(ctx.actor.key, ctx.facts.cookies);
3188
- if (!claims) return void 0;
3623
+ const { claims, boundElsewhere, presentedBy } = service.inspect(ctx.actor.key, ctx.facts.cookies);
3624
+ const shared = presentedBy >= sharingThreshold ? {
3625
+ detector: "clearance",
3626
+ summary: `The clearance token presented here has now been presented by ${presentedBy} different clients`,
3627
+ direction: "bot",
3628
+ certainty: "moderate",
3629
+ botClass: "scraper"
3630
+ } : void 0;
3631
+ if (!claims) {
3632
+ void boundElsewhere;
3633
+ return shared;
3634
+ }
3189
3635
  const ageMs = ctx.facts.timestamp - claims.iat;
3190
3636
  if (claims.lvl === "operator") {
3191
- return {
3637
+ return withShared(shared, {
3192
3638
  detector: "clearance",
3193
3639
  summary: "Client holds an operator-granted clearance token",
3194
3640
  direction: "human",
3195
3641
  certainty: "certain",
3196
3642
  deterministicBasis: "The application issued this clearance itself, on evidence it holds and this library cannot see. It is an assertion by the operator, not an inference from the request, and the token's signature binds it to this actor.",
3197
3643
  metadata: { level: claims.lvl, ageMs }
3198
- };
3644
+ });
3199
3645
  }
3200
3646
  if (claims.lvl === "interaction") {
3201
- return {
3647
+ return withShared(shared, {
3202
3648
  detector: "clearance",
3203
3649
  summary: "Client holds a clearance token granted after a trusted input event",
3204
3650
  direction: "human",
3205
3651
  certainty: "strong",
3206
3652
  weight: 0.7,
3207
3653
  metadata: { level: claims.lvl, ageMs }
3208
- };
3654
+ });
3209
3655
  }
3210
- return {
3656
+ return withShared(shared, {
3211
3657
  detector: "clearance",
3212
3658
  summary: "Client holds a clearance token granted for a completed proof of work",
3213
3659
  direction: "human",
@@ -3218,13 +3664,389 @@ function clearanceDetector(service) {
3218
3664
  ageMs,
3219
3665
  note: "Proof of work demonstrates a JavaScript engine and spent CPU. It does not demonstrate a person."
3220
3666
  }
3221
- };
3667
+ });
3222
3668
  }
3223
3669
  };
3224
3670
  }
3671
+ var DEFAULT_SHARING_THRESHOLD;
3225
3672
  var init_clearance = __esm({
3226
3673
  "src/detectors/clearance.ts"() {
3227
3674
  "use strict";
3675
+ DEFAULT_SHARING_THRESHOLD = 12;
3676
+ }
3677
+ });
3678
+
3679
+ // src/probe/marker.ts
3680
+ function identityShape(facts, ua) {
3681
+ const platform = facts.headers["sec-ch-ua-platform"]?.replace(/"/g, "").trim().toLowerCase();
3682
+ const language = facts.headers["accept-language"]?.split(",")[0]?.split("-")[0]?.trim().toLowerCase();
3683
+ return {
3684
+ // A client that names no browser is its own category, and an empty User-Agent must
3685
+ // not read as equal to every other empty one by accident — it reads as "none", which
3686
+ // is exactly what it is, and changing away from it is a real change.
3687
+ b: part(ua.browser ?? (ua.raw.length === 0 ? "none" : `t:${withoutVersions(ua.raw)}`)),
3688
+ o: part(ua.os ?? platform ?? "none"),
3689
+ l: part(language ?? "none")
3690
+ };
3691
+ }
3692
+ function part(value) {
3693
+ const trimmed = value.length > 40 ? value.slice(0, 40) : value;
3694
+ return trimmed.toLowerCase();
3695
+ }
3696
+ function withoutVersions(raw) {
3697
+ return raw.replace(VERSION_NUMBERS, "#");
3698
+ }
3699
+ function driftBetween(issued, now) {
3700
+ return { browser: issued.b !== now.b, platform: issued.o !== now.o, language: issued.l !== now.l };
3701
+ }
3702
+ function newMarker(shape, ttlMs, now) {
3703
+ return { v: 1, sub: randomId(9), iat: now, exp: now + ttlMs, ...shape };
3704
+ }
3705
+ function markerCookie(name, claims, secrets, options) {
3706
+ return serializeCookie(name, issueToken(claims, secrets), {
3707
+ maxAgeMs: claims.exp - claims.iat,
3708
+ sameSite: options.sameSite ?? "Lax",
3709
+ secure: options.secure ?? true,
3710
+ // Nothing in a page needs to read this, and a marker readable by script is one a
3711
+ // cross-site script can lift.
3712
+ httpOnly: true,
3713
+ ...options.domain === void 0 ? {} : { domain: options.domain }
3714
+ });
3715
+ }
3716
+ function readMarker(value, secrets, now) {
3717
+ if (value === void 0 || value.length === 0) return { kind: "absent" };
3718
+ if (!TOKEN_SHAPE.test(value)) return { kind: "absent" };
3719
+ const verified = verifyToken(value, secrets, now);
3720
+ if (verified.ok) return { kind: "valid", claims: verified.payload };
3721
+ return verified.reason === "expired" ? { kind: "expired" } : { kind: "forged" };
3722
+ }
3723
+ var VERSION_NUMBERS, TOKEN_SHAPE;
3724
+ var init_marker = __esm({
3725
+ "src/probe/marker.ts"() {
3726
+ "use strict";
3727
+ init_token();
3728
+ init_crypto();
3729
+ init_http();
3730
+ VERSION_NUMBERS = /\d+(?:[._]\d+)*/g;
3731
+ TOKEN_SHAPE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
3732
+ }
3733
+ });
3734
+
3735
+ // src/detectors/challenge-reaction.ts
3736
+ function challengeReactionDetector(options = {}) {
3737
+ const windowMs = options.windowMs ?? 6e4;
3738
+ const minUnsolved = options.minUnsolved ?? 4;
3739
+ return {
3740
+ id: "challenge-reaction",
3741
+ description: "Reads how a client responded to being challenged: a changed identity, or never answering at all",
3742
+ cost: "cheap",
3743
+ stage: "always",
3744
+ inspect(ctx) {
3745
+ const found = [];
3746
+ const { at, shape } = ctx.state.lastChallenge;
3747
+ const since = at === 0 ? Number.POSITIVE_INFINITY : ctx.facts.timestamp - at;
3748
+ if (shape !== void 0 && since >= 0 && since <= windowMs) {
3749
+ const now = ctx.marker?.shape ?? identityShape(ctx.facts, ctx.ua);
3750
+ if (now.b !== shape.b) {
3751
+ const proven = ctx.marker?.reading.kind === "valid";
3752
+ found.push({
3753
+ detector: "challenge-reaction",
3754
+ summary: proven ? `Client changed the browser it claims to be within ${Math.round(since / 1e3)}s of being challenged, holding the same marker throughout` : `A client at this address changed the browser it claims to be within ${Math.round(since / 1e3)}s of being challenged`,
3755
+ direction: "bot",
3756
+ certainty: proven ? "strong" : "moderate",
3757
+ botClass: "impersonator",
3758
+ // One cause with `identity-drift`: this client changed what it claims to be.
3759
+ // Both fire together whenever a challenge is what prompted the change.
3760
+ family: "identity-change"
3761
+ });
3762
+ }
3763
+ }
3764
+ if (ctx.state.unsolvedChallenges >= minUnsolved) {
3765
+ found.push({
3766
+ detector: "challenge-reaction",
3767
+ summary: `Challenged ${ctx.state.unsolvedChallenges} times and has never returned a solution`,
3768
+ direction: "bot",
3769
+ certainty: "moderate",
3770
+ botClass: "unknown"
3771
+ });
3772
+ }
3773
+ return found.length > 0 ? found : void 0;
3774
+ }
3775
+ };
3776
+ }
3777
+ var init_challenge_reaction = __esm({
3778
+ "src/detectors/challenge-reaction.ts"() {
3779
+ "use strict";
3780
+ init_marker();
3781
+ }
3782
+ });
3783
+
3784
+ // src/detectors/challenge-integrity.ts
3785
+ function challengeIntegrityDetector(options = {}) {
3786
+ const minReplays = options.minReplays ?? 3;
3787
+ const minImplausible = options.minImplausible ?? 1;
3788
+ return {
3789
+ id: "challenge-integrity",
3790
+ description: "Reports solutions that were replayed, or returned faster than the proof of work allows",
3791
+ cost: "cheap",
3792
+ stage: "always",
3793
+ inspect(ctx) {
3794
+ const { replays, implausible } = ctx.state.challengeAnomalies;
3795
+ const found = [];
3796
+ if (replays >= minReplays) {
3797
+ found.push({
3798
+ detector: "challenge-integrity",
3799
+ summary: `Submitted ${replays} solutions for challenges that had already been solved`,
3800
+ direction: "bot",
3801
+ certainty: "moderate",
3802
+ botClass: "unknown"
3803
+ });
3804
+ }
3805
+ if (implausible >= minImplausible) {
3806
+ found.push({
3807
+ detector: "challenge-integrity",
3808
+ summary: implausible > 1 ? `Returned ${implausible} solutions faster than the proof of work can be computed in a browser` : "Returned a solution faster than the proof of work can be computed in a browser",
3809
+ direction: "bot",
3810
+ certainty: "moderate",
3811
+ botClass: "automation"
3812
+ });
3813
+ }
3814
+ return found.length > 0 ? found : void 0;
3815
+ }
3816
+ };
3817
+ }
3818
+ var init_challenge_integrity = __esm({
3819
+ "src/detectors/challenge-integrity.ts"() {
3820
+ "use strict";
3821
+ }
3822
+ });
3823
+
3824
+ // src/detectors/site-baseline.ts
3825
+ function distributedWalkDetector(options = {}) {
3826
+ const minActors = options.minActors ?? 8;
3827
+ const minIds = options.minIds ?? 150;
3828
+ const minCoverage = options.minCoverage ?? 0.6;
3829
+ const maxRevisitRatio = options.maxRevisitRatio ?? 1.3;
3830
+ const minRevisitRatio = options.minRevisitRatio ?? 0.7;
3831
+ return {
3832
+ id: "distributed-walk",
3833
+ description: "Reports a numeric range being walked across many clients, none of which walks enough of it alone",
3834
+ cost: "cheap",
3835
+ stage: "always",
3836
+ inspect(ctx) {
3837
+ if (ctx.site === void 0 || !ctx.site.warm) return void 0;
3838
+ const step = walkStepOf(ctx.facts.path);
3839
+ if (step === void 0) return void 0;
3840
+ const spread = ctx.site.spreadOf(step.template);
3841
+ if (spread === void 0) return void 0;
3842
+ if (spread.actors < minActors || spread.ids < minIds) return void 0;
3843
+ if (spread.coverage < minCoverage) return void 0;
3844
+ const revisits = spread.visits / spread.ids;
3845
+ if (revisits > maxRevisitRatio || revisits < minRevisitRatio) return void 0;
3846
+ return {
3847
+ detector: "distributed-walk",
3848
+ summary: `${spread.actors} clients between them have requested ${spread.ids} ids under ${step.template}, covering ${(spread.coverage * 100).toFixed(0)}% of the range and almost none of them twice`,
3849
+ direction: "bot",
3850
+ certainty: "moderate",
3851
+ botClass: "scraper"
3852
+ };
3853
+ }
3854
+ };
3855
+ }
3856
+ function pathNoveltyDetector(options = {}) {
3857
+ const minRequests = options.minRequests ?? 30;
3858
+ const minNovelShare = options.minNovelShare ?? 0.95;
3859
+ return {
3860
+ id: "path-novelty",
3861
+ description: "Reports a client whose requests are almost all for paths this site has never been asked for",
3862
+ cost: "cheap",
3863
+ stage: "always",
3864
+ inspect(ctx) {
3865
+ if (ctx.site === void 0 || !ctx.site.warm) return void 0;
3866
+ const total = ctx.state.total;
3867
+ if (total < minRequests) return void 0;
3868
+ const share = ctx.state.novelPathCount / total;
3869
+ if (share < minNovelShare) return void 0;
3870
+ return {
3871
+ detector: "path-novelty",
3872
+ summary: `${(share * 100).toFixed(0)}% of this client's ${total} requests were for paths no other client has ever asked this site for`,
3873
+ direction: "bot",
3874
+ certainty: "moderate",
3875
+ botClass: "scanner",
3876
+ // The same cause `probe-signature` names when a path is on a list it ships: this
3877
+ // client is walking a list rather than reading a site.
3878
+ family: "wordlist-probe"
3879
+ };
3880
+ }
3881
+ };
3882
+ }
3883
+ function missBaselineDetector(options = {}) {
3884
+ const minResponses = options.minResponses ?? 20;
3885
+ const minRatio = options.minRatio ?? 5;
3886
+ const floor = options.floor ?? 0.5;
3887
+ return {
3888
+ id: "miss-baseline",
3889
+ description: 'Compares how often a client is answered "not found" with how often this site answers that at all',
3890
+ cost: "cheap",
3891
+ stage: "always",
3892
+ inspect(ctx) {
3893
+ const siteRate = ctx.site?.missRate;
3894
+ if (siteRate === void 0) return void 0;
3895
+ const { responses, misses } = ctx.state;
3896
+ if (responses < minResponses) return void 0;
3897
+ const rate = misses / responses;
3898
+ if (rate < floor) return void 0;
3899
+ if (siteRate > 0 && rate / siteRate < minRatio) return void 0;
3900
+ return {
3901
+ detector: "miss-baseline",
3902
+ summary: siteRate > 0 ? `${(rate * 100).toFixed(0)}% of this client's requests were answered "not found", against ${(siteRate * 100).toFixed(1)}% across the site` : `${(rate * 100).toFixed(0)}% of this client's requests were answered "not found", on a site that otherwise never answers that`,
3903
+ direction: "bot",
3904
+ certainty: "moderate",
3905
+ botClass: "scanner",
3906
+ // `probe-volume` reads the same misses against a fixed threshold. Two readings
3907
+ // of one cause, so the stronger stands and they do not sum.
3908
+ family: "misses"
3909
+ };
3910
+ }
3911
+ };
3912
+ }
3913
+ function pathCampaignDetector(options = {}) {
3914
+ const minClients = options.minClients ?? 12;
3915
+ const minMissShare = options.minMissShare ?? 0.9;
3916
+ const minAnswered = options.minAnswered ?? 10;
3917
+ return {
3918
+ id: "path-campaign",
3919
+ description: "Reports a path this site never served that many unrelated clients have suddenly begun requesting",
3920
+ cost: "cheap",
3921
+ stage: "always",
3922
+ inspect(ctx) {
3923
+ const surge = ctx.site?.surgeOf(ctx.facts.path);
3924
+ if (surge === void 0) return void 0;
3925
+ if (surge.clients < minClients || surge.answered < minAnswered) return void 0;
3926
+ if (surge.misses / surge.answered < minMissShare) return void 0;
3927
+ return {
3928
+ detector: "path-campaign",
3929
+ summary: `${surge.clients} unrelated clients have requested ${ctx.facts.path} since it first appeared ${Math.round(surge.ageMs / 6e4)} minutes ago, and the site has answered "not found" to almost all of them`,
3930
+ direction: "bot",
3931
+ certainty: "moderate",
3932
+ botClass: "scanner"
3933
+ };
3934
+ }
3935
+ };
3936
+ }
3937
+ var init_site_baseline = __esm({
3938
+ "src/detectors/site-baseline.ts"() {
3939
+ "use strict";
3940
+ init_state();
3941
+ }
3942
+ });
3943
+
3944
+ // src/detectors/marker.ts
3945
+ function identityDriftDetector(options = {}) {
3946
+ const reportSoft = options.reportSoftDrift ?? true;
3947
+ return {
3948
+ id: "identity-drift",
3949
+ description: "Compares the identity a client claims now with the one it claimed when it was given its marker",
3950
+ cost: "cheap",
3951
+ stage: "always",
3952
+ inspect(ctx) {
3953
+ const drift = ctx.marker?.drift;
3954
+ if (drift === void 0) return void 0;
3955
+ if (drift.browser) {
3956
+ return {
3957
+ detector: "identity-drift",
3958
+ summary: "Client is holding a marker this server issued to a different browser, so one of the two identities it has claimed is false",
3959
+ direction: "bot",
3960
+ certainty: "strong",
3961
+ botClass: "impersonator"
3962
+ };
3963
+ }
3964
+ if (!reportSoft || !(drift.platform || drift.language)) return void 0;
3965
+ const what = drift.platform && drift.language ? "platform and language" : drift.platform ? "platform" : "language";
3966
+ return {
3967
+ detector: "identity-drift",
3968
+ // Named precisely, because the operator reading this needs to know it is the
3969
+ // soft case: a person switching to the desktop site produces exactly this.
3970
+ summary: `Client's claimed ${what} changed while holding one marker, which a person can also do deliberately`,
3971
+ direction: "bot",
3972
+ certainty: "moderate",
3973
+ botClass: "unknown"
3974
+ };
3975
+ }
3976
+ };
3977
+ }
3978
+ function markerIntegrityDetector(options = {}) {
3979
+ const minForgeries = options.minForgeries ?? 1;
3980
+ return {
3981
+ id: "marker-integrity",
3982
+ description: "Reports a marker cookie presented with a signature this server could not have produced",
3983
+ cost: "cheap",
3984
+ stage: "always",
3985
+ inspect(ctx) {
3986
+ if (ctx.marker?.reading.kind !== "forged") return void 0;
3987
+ const { forged } = ctx.state.markers;
3988
+ if (forged < minForgeries) return void 0;
3989
+ return {
3990
+ detector: "marker-integrity",
3991
+ summary: forged > 1 ? `Presented a marker cookie this server never signed, ${forged} times` : "Presented a marker cookie this server never signed",
3992
+ direction: "bot",
3993
+ certainty: "strong",
3994
+ botClass: "scanner"
3995
+ };
3996
+ }
3997
+ };
3998
+ }
3999
+ function markerPersistenceDetector(options = {}) {
4000
+ const minIssued = options.minIssued ?? 5;
4001
+ return {
4002
+ id: "marker-persistence",
4003
+ description: "Reports a client that has been handed a marker repeatedly and has never returned one",
4004
+ cost: "cheap",
4005
+ stage: "always",
4006
+ inspect(ctx) {
4007
+ if (ctx.marker === void 0) return void 0;
4008
+ if (ctx.facts.headers["cookie"] === void 0) return void 0;
4009
+ const { issued, returned } = ctx.state.markers;
4010
+ if (returned > 0 || issued < minIssued) return void 0;
4011
+ return {
4012
+ detector: "marker-persistence",
4013
+ summary: `Sends cookies but has never returned the one this server set, across ${issued} responses that offered it`,
4014
+ direction: "bot",
4015
+ certainty: "moderate",
4016
+ botClass: "http-client",
4017
+ // The same cause `session-integrity` reports when it sees no cookie at all: one
4018
+ // client that does not keep state. Without this they are two moderate signals
4019
+ // for one observation, and the population that produces it is people who block
4020
+ // cookies — so the double count landed squarely on them. Measured on the corpus:
4021
+ // it took `cookies-blocked` from 21 to 38 before the family was named.
4022
+ family: "no-session"
4023
+ };
4024
+ }
4025
+ };
4026
+ }
4027
+ function markerFanoutDetector(options = {}) {
4028
+ const minNetworks = options.minNetworks ?? 16;
4029
+ return {
4030
+ id: "marker-fanout",
4031
+ description: "Counts the distinct networks one marker cookie has been presented from",
4032
+ cost: "cheap",
4033
+ stage: "always",
4034
+ inspect(ctx) {
4035
+ const networks = ctx.marker?.networks ?? 0;
4036
+ if (networks < minNetworks) return void 0;
4037
+ return {
4038
+ detector: "marker-fanout",
4039
+ summary: `One client has presented the same marker from ${networks} different networks`,
4040
+ direction: "bot",
4041
+ certainty: "moderate",
4042
+ botClass: "scraper"
4043
+ };
4044
+ }
4045
+ };
4046
+ }
4047
+ var init_marker2 = __esm({
4048
+ "src/detectors/marker.ts"() {
4049
+ "use strict";
3228
4050
  }
3229
4051
  });
3230
4052
 
@@ -3374,7 +4196,7 @@ function compileSignatures(signatures = BOT_SIGNATURES) {
3374
4196
  }
3375
4197
  return new MultiPatternMatcher(entries);
3376
4198
  }
3377
- var BOT_CATEGORIES, SEARCH, AI, SEO, SOCIAL, MONITORING, ARCHIVE, FEED, SECURITY, LIBRARY, HEADLESS, EMBEDDED, ADVERTISING, COMMERCE, ACADEMIC, ACCESSIBILITY, BOT_SIGNATURES;
4199
+ var BOT_CATEGORIES, SEARCH, AI, SEO, SOCIAL, MONITORING, ARCHIVE, FEED, SECURITY, LIBRARY, HEADLESS, EMBEDDED, ADVERTISING, COMMERCE, ACADEMIC, EMAIL_SECURITY, ACCESSIBILITY, BOT_SIGNATURES;
3378
4200
  var init_known_bots = __esm({
3379
4201
  "src/detectors/known-bots.ts"() {
3380
4202
  "use strict";
@@ -3395,6 +4217,7 @@ var init_known_bots = __esm({
3395
4217
  "commerce",
3396
4218
  "accessibility",
3397
4219
  "academic",
4220
+ "email-security",
3398
4221
  "other"
3399
4222
  ];
3400
4223
  SEARCH = [
@@ -3419,6 +4242,13 @@ var init_known_bots = __esm({
3419
4242
  { id: "yisouspider", name: "Shenma (Yisou) Spider", tokens: ["yisouspider"], category: "search", benign: true, robotsAgent: "YisouSpider", verification: { kind: "none" } },
3420
4243
  { id: "yahoo-slurp", name: "Yahoo! Slurp", tokens: ["yahoo! slurp"], category: "search", benign: true, robotsAgent: "Slurp", verification: { kind: "fcrdns", domains: ["crawl.yahoo.net", "yahoo.com"] } },
3421
4244
  { id: "mail-ru", name: "Mail.Ru bot", tokens: ["mail.ru_bot"], category: "search", benign: true, verification: { kind: "none" } },
4245
+ { id: "brave-search", name: "Brave Search", tokens: ["bravesearchbot"], category: "search", benign: true, robotsAgent: "BraveSearchBot", verification: { kind: "none" } },
4246
+ { id: "ecosia", name: "Ecosia", tokens: ["ecosiabot"], category: "search", benign: true, verification: { kind: "none" } },
4247
+ { id: "startpage", name: "Startpage", tokens: ["startpagebot"], category: "search", benign: true, verification: { kind: "none" } },
4248
+ { id: "daum", name: "Daumoa", tokens: ["daumoa"], category: "search", benign: true, verification: { kind: "none" } },
4249
+ { id: "stract", name: "Stract", tokens: ["stractbot"], category: "search", benign: true, verification: { kind: "none" } },
4250
+ { id: "rightdao", name: "RightDao", tokens: ["rightdaobot"], category: "search", benign: true, verification: { kind: "none" } },
4251
+ { id: "gigablast", name: "Gigablast", tokens: ["gigablastopensource"], category: "search", benign: true, verification: { kind: "none" } },
3422
4252
  { id: "exabot", name: "Exabot (Exalead)", tokens: ["exabot"], category: "search", benign: true, verification: { kind: "none" } },
3423
4253
  { id: "kagibot", name: "Kagi", tokens: ["kagibot"], category: "search", benign: true, robotsAgent: "KagiBot", verification: { kind: "none" } }
3424
4254
  ];
@@ -3489,11 +4319,15 @@ var init_known_bots = __esm({
3489
4319
  { id: "pinterestbot", name: "Pinterestbot", tokens: ["pinterest/", "pinterestbot"], category: "social", benign: true, verification: { kind: "fcrdns", domains: ["pinterest.com"] } },
3490
4320
  { id: "mastodon", name: "Mastodon / Fediverse", tokens: ["mastodon/", "pleroma", "misskey/", "akkoma"], category: "social", benign: true, verification: { kind: "none" } },
3491
4321
  { id: "embedly", name: "Embedly", tokens: ["embedly"], category: "social", benign: true, verification: { kind: "none" } },
3492
- { id: "bluesky", name: "Bluesky card fetcher", tokens: ["bluesky cardyb", "cardyb/"], category: "social", benign: true, verification: { kind: "none" } },
4322
+ { id: "bluesky", name: "Bluesky card fetcher", tokens: ["bluesky cardyb", "cardyb/", "blueskybot"], category: "social", benign: true, verification: { kind: "none" } },
3493
4323
  { id: "iframely", name: "Iframely", tokens: ["iframely"], category: "social", benign: true, verification: { kind: "none" } },
3494
4324
  { id: "skype-preview", name: "Skype URI preview", tokens: ["skypeuripreview"], category: "social", benign: true, verification: { kind: "none" } },
3495
4325
  { id: "vk-share", name: "VK / Odnoklassniki preview", tokens: ["vkshare", "odklbot"], category: "social", benign: true, verification: { kind: "none" } },
3496
4326
  { id: "discourse-onebox", name: "Discourse Onebox", tokens: ["discourse forum onebox"], category: "social", benign: true, verification: { kind: "none" } },
4327
+ { id: "microsoft-preview", name: "Microsoft Teams preview", tokens: ["microsoftpreview"], category: "social", benign: true, verification: { kind: "none" } },
4328
+ { id: "zoom-preview", name: "Zoom link preview", tokens: ["zoombot"], category: "social", benign: true, verification: { kind: "none" } },
4329
+ { id: "signal-preview", name: "Signal link preview", tokens: ["signalbot"], category: "social", benign: true, verification: { kind: "none" } },
4330
+ { id: "matrix-synapse", name: "Matrix (Synapse) preview", tokens: ["synapse/"], category: "social", benign: true, verification: { kind: "none" } },
3497
4331
  { id: "yahoo-preview", name: "Yahoo Link Preview", tokens: ["yahoo link preview"], category: "social", benign: true, verification: { kind: "none" } }
3498
4332
  ];
3499
4333
  MONITORING = [
@@ -3533,6 +4367,10 @@ var init_known_bots = __esm({
3533
4367
  { id: "smartnews", name: "SmartNews", tokens: ["smartnewsbot"], category: "feed", benign: true, verification: { kind: "none" } },
3534
4368
  { id: "flipboard", name: "Flipboard", tokens: ["flipboardproxy"], category: "feed", benign: true, verification: { kind: "none" } },
3535
4369
  { id: "podcast-index", name: "Podcast Index", tokens: ["podcastindexbot"], category: "feed", benign: true, verification: { kind: "none" } },
4370
+ // Spotify's podcast fetcher sends `Spotify/1.0` — and so does the Spotify desktop app,
4371
+ // with a person driving it. There is no token that separates them, so this one is left
4372
+ // unnamed rather than named wrongly: the corpus proved the point immediately by blocking
4373
+ // a human under `protect-auth`, `indexers-only` and `under-attack` at once.
3536
4374
  { id: "freshrss", name: "FreshRSS", tokens: ["freshrss"], category: "feed", benign: true, verification: { kind: "none" } },
3537
4375
  { id: "netnewswire", name: "NetNewsWire", tokens: ["netnewswire"], category: "feed", benign: true, verification: { kind: "none" } },
3538
4376
  { id: "overcast", name: "Overcast", tokens: ["overcast/"], category: "feed", benign: true, verification: { kind: "none" } },
@@ -3659,7 +4497,23 @@ var init_known_bots = __esm({
3659
4497
  ];
3660
4498
  ADVERTISING = [
3661
4499
  { id: "adsbot-google", name: "AdsBot-Google", tokens: ["adsbot-google", "mediapartners-google", "adsbot"], category: "advertising", benign: true, verification: { kind: "fcrdns", domains: ["googlebot.com", "google.com"] } },
3662
- { id: "criteo", name: "Criteo", tokens: ["criteobot"], category: "advertising", benign: true, verification: { kind: "none" } }
4500
+ { id: "criteo", name: "Criteo", tokens: ["criteobot"], category: "advertising", benign: true, verification: { kind: "none" } },
4501
+ // Verification and contextual classification: they read a page to decide whether an ad
4502
+ // may appear beside it, or what the page is about. A publisher usually wants these and a
4503
+ // site with no advertising has no reason to.
4504
+ { id: "doubleverify", name: "DoubleVerify", tokens: ["doubleverifybot"], category: "advertising", benign: true, verification: { kind: "none" } },
4505
+ { id: "ias", name: "Integral Ad Science", tokens: ["ias crawler"], category: "advertising", benign: true, verification: { kind: "none" } },
4506
+ { id: "moat", name: "Moat", tokens: ["moatbot"], category: "advertising", benign: true, verification: { kind: "none" } },
4507
+ { id: "comscore", name: "comScore (Proximic)", tokens: ["proximic"], category: "advertising", benign: true, verification: { kind: "none" } },
4508
+ { id: "grapeshot", name: "Grapeshot", tokens: ["grapeshotcrawler"], category: "advertising", benign: true, verification: { kind: "none" } },
4509
+ { id: "peer39", name: "Peer39", tokens: ["peer39bot"], category: "advertising", benign: true, verification: { kind: "none" } },
4510
+ { id: "taboola", name: "Taboola", tokens: ["taboolabot"], category: "advertising", benign: true, verification: { kind: "none" } },
4511
+ { id: "outbrain", name: "Outbrain", tokens: ["outbrainbot"], category: "advertising", benign: true, verification: { kind: "none" } },
4512
+ { id: "pubmatic", name: "PubMatic", tokens: ["pubmaticbot"], category: "advertising", benign: true, verification: { kind: "none" } },
4513
+ { id: "thetradedesk", name: "The Trade Desk", tokens: ["ttd-content"], category: "advertising", benign: true, verification: { kind: "none" } },
4514
+ // Competitive ad intelligence rather than verification: it collects what everyone else
4515
+ // is running. Named, and left for the operator to decide about.
4516
+ { id: "adbeat", name: "Adbeat", tokens: ["adbeat_bot"], category: "advertising", benign: false, verification: { kind: "none" } }
3663
4517
  ];
3664
4518
  COMMERCE = [
3665
4519
  { id: "idealo", name: "idealo", tokens: ["idealo-bot"], category: "commerce", benign: false, verification: { kind: "none" } },
@@ -3675,6 +4529,12 @@ var init_known_bots = __esm({
3675
4529
  { id: "openalex", name: "OpenAlex", tokens: ["openalexbot"], category: "academic", benign: true, verification: { kind: "none" } },
3676
4530
  { id: "webis", name: "Webis research crawler", tokens: ["webisbot"], category: "academic", benign: true, verification: { kind: "none" } }
3677
4531
  ];
4532
+ EMAIL_SECURITY = [
4533
+ { id: "proofpoint", name: "Proofpoint URL Defense", tokens: ["proofpointurldefensebot"], category: "email-security", benign: true, verification: { kind: "none" } },
4534
+ { id: "mimecast", name: "Mimecast URL Protect", tokens: ["mimecasturlprotectbot"], category: "email-security", benign: true, verification: { kind: "none" } },
4535
+ { id: "barracuda", name: "Barracuda Link Protect", tokens: ["barracudalinkprotectbot"], category: "email-security", benign: true, verification: { kind: "none" } },
4536
+ { id: "cisco-esa", name: "Cisco Secure Email", tokens: ["ciscosecureemailbot"], category: "email-security", benign: true, verification: { kind: "none" } }
4537
+ ];
3678
4538
  ACCESSIBILITY = [
3679
4539
  { id: "siteimprove", name: "Siteimprove", tokens: ["siteimprovebot"], category: "accessibility", benign: true, robotsAgent: "SiteimproveBot", verification: { kind: "none" } }
3680
4540
  ];
@@ -3693,7 +4553,8 @@ var init_known_bots = __esm({
3693
4553
  ...EMBEDDED,
3694
4554
  ...COMMERCE,
3695
4555
  ...ACADEMIC,
3696
- ...ACCESSIBILITY
4556
+ ...ACCESSIBILITY,
4557
+ ...EMAIL_SECURITY
3697
4558
  ]);
3698
4559
  }
3699
4560
  });
@@ -4014,6 +4875,309 @@ var init_ua = __esm({
4014
4875
  }
4015
4876
  });
4016
4877
 
4878
+ // src/probe/index.ts
4879
+ function hashToBit(value) {
4880
+ let hash = 2166136261;
4881
+ for (let i = 0; i < value.length; i++) {
4882
+ hash ^= value.charCodeAt(i);
4883
+ hash = Math.imul(hash, 16777619);
4884
+ }
4885
+ return (hash >>> 0) % FANOUT_BITS;
4886
+ }
4887
+ function estimateDistinct(sketch, cap) {
4888
+ let set = 0;
4889
+ for (let word = 0; word < FANOUT_WORDS; word++) {
4890
+ let bits = sketch[word];
4891
+ while (bits !== 0) {
4892
+ bits &= bits - 1;
4893
+ set++;
4894
+ }
4895
+ }
4896
+ if (set >= FANOUT_BITS) return cap;
4897
+ const estimate = Math.round(-FANOUT_BITS * Math.log(1 - set / FANOUT_BITS));
4898
+ return Math.min(estimate, cap);
4899
+ }
4900
+ var DEFAULT_TTL_MS, FANOUT_WORDS, FANOUT_BITS, MarkerProbe;
4901
+ var init_probe = __esm({
4902
+ "src/probe/index.ts"() {
4903
+ "use strict";
4904
+ init_marker();
4905
+ init_lru();
4906
+ init_ip();
4907
+ init_marker();
4908
+ DEFAULT_TTL_MS = 12 * 60 * 6e4;
4909
+ FANOUT_WORDS = 4;
4910
+ FANOUT_BITS = FANOUT_WORDS * 32;
4911
+ MarkerProbe = class {
4912
+ cookieName;
4913
+ secrets;
4914
+ ttlMs;
4915
+ cookieOptions;
4916
+ clock;
4917
+ /**
4918
+ * Marker id to a 128-bit sketch of the networks it has been presented from.
4919
+ *
4920
+ * A `Set` of network strings is the obvious structure and measured at **55.6 MB** with
4921
+ * both caps full — twenty thousand markers each seen from a few dozen networks — which
4922
+ * is far too much to hand somebody for switching on a detector. The question being
4923
+ * asked is only ever "has this marker come from more than about sixteen networks", and
4924
+ * a bitmap answers that in sixteen bytes by linear counting: hash each network to a
4925
+ * bit, then estimate the distinct count from how many bits are set.
4926
+ *
4927
+ * The estimate carries a few percent of error in **either** direction — measured, 16
4928
+ * real networks read as 17 and 32 read as 33 — so the threshold it feeds is a soft
4929
+ * boundary rather than a hard one. That is honest for this signal in particular, which
4930
+ * cannot separate a proxy pool from a heavily mobile person at any resolution, and is
4931
+ * why it is capped at `moderate` and never denies anybody by itself.
4932
+ */
4933
+ fanout;
4934
+ maxNetworks;
4935
+ /**
4936
+ * Markers already verified, by the exact cookie value that verified.
4937
+ *
4938
+ * A browsing session sends one identical cookie on every request, and verifying it is
4939
+ * an HMAC — which measured at roughly twenty microseconds, nearly doubling the cost of
4940
+ * an assessment to re-establish a fact that had not changed. The cache is only ever
4941
+ * populated with *successes*: caching failures would let anyone flood it with unique
4942
+ * junk, and a failure is cheap to reach anyway.
4943
+ *
4944
+ * Expiry is still checked on every hit, so a cached marker stops being accepted at the
4945
+ * moment it should. The key is the whole signed value, so a cache hit is only possible
4946
+ * for a string that already carried a valid signature.
4947
+ */
4948
+ verified;
4949
+ constructor(options) {
4950
+ if (options.secrets.length === 0) throw new Error("A marker probe requires at least one secret");
4951
+ for (const secret of options.secrets) {
4952
+ if (secret.length < 32) {
4953
+ throw new Error("Each marker secret must be at least 32 characters; generate one with `crypto.randomBytes(32).toString('base64url')`");
4954
+ }
4955
+ }
4956
+ this.secrets = options.secrets;
4957
+ this.cookieName = options.cookieName ?? "__bh_m";
4958
+ this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
4959
+ this.clock = options.clock;
4960
+ const tracked = options.maxTrackedMarkers ?? 2e4;
4961
+ this.maxNetworks = options.maxNetworksPerMarker ?? 96;
4962
+ this.fanout = tracked > 0 ? new TtlLru(tracked, this.ttlMs, options.clock) : void 0;
4963
+ const cached = options.maxVerifiedMarkers ?? 5e3;
4964
+ this.verified = cached > 0 ? new TtlLru(cached, this.ttlMs, options.clock) : void 0;
4965
+ this.cookieOptions = {
4966
+ ...options.sameSite === void 0 ? {} : { sameSite: options.sameSite },
4967
+ ...options.secure === void 0 ? {} : { secure: options.secure },
4968
+ ...options.domain === void 0 ? {} : { domain: options.domain }
4969
+ };
4970
+ try {
4971
+ markerCookie(this.cookieName, newMarker({ b: "x", o: "x", l: "x" }, this.ttlMs, 0), this.secrets, this.cookieOptions);
4972
+ } catch (error) {
4973
+ throw new Error(`The marker probe cannot issue a cookie with this configuration: ${error instanceof Error ? error.message : String(error)}`);
4974
+ }
4975
+ }
4976
+ /** Reads the marker this request carried, and measures it against the request. */
4977
+ observe(facts, ua) {
4978
+ const shape = identityShape(facts, ua);
4979
+ const reading = this.read(facts.cookies?.[this.cookieName]);
4980
+ const drift = reading.kind === "valid" ? driftBetween({ b: reading.claims.b, o: reading.claims.o, l: reading.claims.l }, shape) : void 0;
4981
+ const networks = reading.kind === "valid" ? this.noteNetwork(reading.claims.sub, facts.ip) : 0;
4982
+ return { reading, drift, shape, networks };
4983
+ }
4984
+ /**
4985
+ * Whether this response should carry a marker.
4986
+ *
4987
+ * Only when the client is not already holding a good one. An ordinary visitor is
4988
+ * therefore issued a cookie once and then browses with uncached-by-`Set-Cookie`
4989
+ * responses never again; a client that discards cookies is issued one every time,
4990
+ * which is itself the observation `marker-persistence` is built on.
4991
+ */
4992
+ shouldIssue(observation) {
4993
+ return observation.reading.kind !== "valid";
4994
+ }
4995
+ /** Verifies a presented marker, reusing an earlier verification of the same value. */
4996
+ read(value) {
4997
+ if (value === void 0 || value.length === 0) return { kind: "absent" };
4998
+ const now = this.clock.now();
4999
+ const remembered = this.verified?.get(value);
5000
+ if (remembered !== void 0) return remembered.exp > now ? { kind: "valid", claims: remembered } : { kind: "expired" };
5001
+ const reading = readMarker(value, this.secrets, now);
5002
+ if (reading.kind === "valid") this.verified?.set(value, reading.claims);
5003
+ return reading;
5004
+ }
5005
+ /**
5006
+ * Files this presentation under the marker's own id and returns how many distinct
5007
+ * networks it has now come from.
5008
+ *
5009
+ * A `/24` rather than an address, because a single visitor's address changes for
5010
+ * ordinary reasons all day — a phone moving between cells, a router relearning a
5011
+ * lease — while the network it sits behind usually does not. Counting addresses would
5012
+ * report every commuter.
5013
+ */
5014
+ noteNetwork(markerId, ip) {
5015
+ if (this.fanout === void 0) return 0;
5016
+ let sketch = this.fanout.get(markerId);
5017
+ if (sketch === void 0) {
5018
+ sketch = new Uint32Array(FANOUT_WORDS);
5019
+ this.fanout.set(markerId, sketch);
5020
+ }
5021
+ const bit = hashToBit(networkKey(ip));
5022
+ sketch[bit >>> 5] = sketch[bit >>> 5] | 1 << (bit & 31);
5023
+ return estimateDistinct(sketch, this.maxNetworks);
5024
+ }
5025
+ /** The `Set-Cookie` handing this client a marker bound to the identity it just claimed. */
5026
+ issue(observation) {
5027
+ return markerCookie(this.cookieName, newMarker(observation.shape, this.ttlMs, this.clock.now()), this.secrets, this.cookieOptions);
5028
+ }
5029
+ };
5030
+ }
5031
+ });
5032
+
5033
+ // src/site/index.ts
5034
+ function bucketSet(record, bucket) {
5035
+ record.bits[bucket >>> 5] = record.bits[bucket >>> 5] | 1 << (bucket & 31);
5036
+ }
5037
+ function bucketGet(record, bucket) {
5038
+ return (record.bits[bucket >>> 5] & 1 << (bucket & 31)) !== 0;
5039
+ }
5040
+ function coarsen(record) {
5041
+ const merged = new Uint32Array(WALK_BUCKETS / 32);
5042
+ for (let bucket = 0; bucket < WALK_BUCKETS / 2; bucket++) {
5043
+ const low = bucket * 2;
5044
+ if (bucketGet(record, low) || bucketGet(record, low + 1)) {
5045
+ merged[bucket >>> 5] = merged[bucket >>> 5] | 1 << (bucket & 31);
5046
+ }
5047
+ }
5048
+ record.bits = merged;
5049
+ record.scale *= 2;
5050
+ }
5051
+ function stripUndefined2(value) {
5052
+ const out = {};
5053
+ for (const [key, entry] of Object.entries(value)) if (entry !== void 0) out[key] = entry;
5054
+ return out;
5055
+ }
5056
+ var WALK_BUCKETS, DEFAULTS, SiteProfile;
5057
+ var init_site = __esm({
5058
+ "src/site/index.ts"() {
5059
+ "use strict";
5060
+ init_lru();
5061
+ WALK_BUCKETS = 1024;
5062
+ DEFAULTS = {
5063
+ warmupRequests: 5e3,
5064
+ maxPaths: 5e4,
5065
+ maxTemplates: 256,
5066
+ maxActorsPerTemplate: 64,
5067
+ windowMs: 60 * 6e4,
5068
+ maxWatchedPaths: 2048,
5069
+ maxActorsPerPath: 64
5070
+ };
5071
+ SiteProfile = class {
5072
+ options;
5073
+ paths;
5074
+ walks;
5075
+ watched;
5076
+ clock;
5077
+ observed = 0;
5078
+ misses = 0;
5079
+ answered = 0;
5080
+ constructor(options) {
5081
+ this.options = { ...DEFAULTS, ...stripUndefined2(options) };
5082
+ this.paths = new TtlLru(this.options.maxPaths, this.options.windowMs, options.clock);
5083
+ this.walks = new TtlLru(this.options.maxTemplates, this.options.windowMs, options.clock);
5084
+ this.clock = options.clock;
5085
+ this.watched = this.options.maxWatchedPaths > 0 ? new TtlLru(this.options.maxWatchedPaths, this.options.windowMs, options.clock) : void 0;
5086
+ }
5087
+ /**
5088
+ * Whether enough traffic has been seen for any of this to mean anything.
5089
+ *
5090
+ * Every reader checks this. A profile that answers during warmup is worse than one
5091
+ * that does not exist, because it answers confidently and wrongly.
5092
+ */
5093
+ get warm() {
5094
+ return this.observed >= this.options.warmupRequests;
5095
+ }
5096
+ get requestsObserved() {
5097
+ return this.observed;
5098
+ }
5099
+ /** The share of answered requests that were misses, or `undefined` before warmup. */
5100
+ get missRate() {
5101
+ return this.warm && this.answered > 0 ? this.misses / this.answered : void 0;
5102
+ }
5103
+ /** Files a request. Called once per assessed request, before the detectors run. */
5104
+ record(path, actorKey) {
5105
+ if (this.observed < Number.MAX_SAFE_INTEGER) this.observed++;
5106
+ const seen = this.paths.get(path);
5107
+ this.paths.set(path, (seen ?? 0) + 1);
5108
+ if (this.watched === void 0 || !this.warm) return;
5109
+ let surge = this.watched.get(path);
5110
+ if (surge === void 0) {
5111
+ if (seen !== void 0) return;
5112
+ surge = { actors: /* @__PURE__ */ new Set(), firstSeen: this.clock.now(), answered: 0, misses: 0 };
5113
+ this.watched.set(path, surge);
5114
+ }
5115
+ if (surge.actors.size < this.options.maxActorsPerPath) surge.actors.add(actorKey);
5116
+ }
5117
+ /** Files what the application answered, for the site's miss rate and each watched path. */
5118
+ recordOutcome(path, status) {
5119
+ if (this.answered < Number.MAX_SAFE_INTEGER) this.answered++;
5120
+ const missed = status === 404 || status === 410;
5121
+ if (missed) this.misses++;
5122
+ const surge = this.watched?.get(path);
5123
+ if (surge === void 0) return;
5124
+ surge.answered++;
5125
+ if (missed) surge.misses++;
5126
+ }
5127
+ /** What has happened to a path since it first appeared. `undefined` if not watched. */
5128
+ surgeOf(path) {
5129
+ if (!this.warm) return void 0;
5130
+ const surge = this.watched?.get(path);
5131
+ if (surge === void 0) return void 0;
5132
+ return { clients: surge.actors.size, ageMs: this.clock.now() - surge.firstSeen, answered: surge.answered, misses: surge.misses };
5133
+ }
5134
+ /**
5135
+ * How many times the site has served this path, to anybody.
5136
+ *
5137
+ * `undefined` before warmup, and `0` for a path this process has not seen — which is
5138
+ * not the same as one the site does not have, and is why the detector reading this
5139
+ * needs a great many of them before it says anything.
5140
+ */
5141
+ timesSeen(path) {
5142
+ return this.warm ? this.paths.get(path) ?? 0 : void 0;
5143
+ }
5144
+ /** Files one step of a numeric walk against the shape it belongs to. */
5145
+ recordWalk(template, id, actorKey) {
5146
+ let record = this.walks.get(template);
5147
+ if (record === void 0) {
5148
+ record = { actors: /* @__PURE__ */ new Set(), bits: new Uint32Array(WALK_BUCKETS / 32), scale: 1, min: id, max: id, visits: 0 };
5149
+ this.walks.set(template, record);
5150
+ }
5151
+ record.visits++;
5152
+ if (record.actors.size < this.options.maxActorsPerTemplate) record.actors.add(actorKey);
5153
+ if (id < record.min) record.min = id;
5154
+ if (id > record.max) record.max = id;
5155
+ while (Math.floor(record.max / record.scale) >= WALK_BUCKETS) coarsen(record);
5156
+ bucketSet(record, Math.floor(id / record.scale));
5157
+ }
5158
+ /** What the whole site has done with one numeric shape. `undefined` before warmup. */
5159
+ spreadOf(template) {
5160
+ if (!this.warm) return void 0;
5161
+ const record = this.walks.get(template);
5162
+ if (record === void 0) return void 0;
5163
+ const lowest = Math.floor(record.min / record.scale);
5164
+ const highest = Math.floor(record.max / record.scale);
5165
+ let touched = 0;
5166
+ for (let bucket = lowest; bucket <= highest; bucket++) if (bucketGet(record, bucket)) touched++;
5167
+ const window = highest - lowest + 1;
5168
+ return {
5169
+ actors: record.actors.size,
5170
+ ids: touched * record.scale,
5171
+ buckets: touched,
5172
+ scale: record.scale,
5173
+ visits: record.visits,
5174
+ coverage: touched / window
5175
+ };
5176
+ }
5177
+ };
5178
+ }
5179
+ });
5180
+
4017
5181
  // src/detectors/accept-signature.ts
4018
5182
  function acceptSignatureDetector() {
4019
5183
  return {
@@ -4399,49 +5563,280 @@ var init_client_hints = __esm({
4399
5563
  }
4400
5564
  });
4401
5565
 
4402
- // src/detectors/crawl-breadth.ts
4403
- function crawlBreadthDetector(options = {}) {
4404
- const threshold = options.threshold ?? 30;
4405
- const noveltyRatio = options.noveltyRatio ?? 0.85;
4406
- const minRequests = options.minRequests ?? 20;
4407
- if (threshold > MAX_TRACKED_PATHS) {
4408
- throw new RangeError(
4409
- `crawlBreadthDetector threshold ${threshold} can never be reached: an actor's distinct-path count saturates at ${MAX_TRACKED_PATHS}. Use ${MAX_TRACKED_PATHS} or fewer.`
4410
- );
4411
- }
5566
+ // src/detectors/crawl-breadth.ts
5567
+ function crawlBreadthDetector(options = {}) {
5568
+ const threshold = options.threshold ?? 30;
5569
+ const noveltyRatio = options.noveltyRatio ?? 0.85;
5570
+ const minRequests = options.minRequests ?? 20;
5571
+ if (threshold > MAX_TRACKED_PATHS) {
5572
+ throw new RangeError(
5573
+ `crawlBreadthDetector threshold ${threshold} can never be reached: an actor's distinct-path count saturates at ${MAX_TRACKED_PATHS}. Use ${MAX_TRACKED_PATHS} or fewer.`
5574
+ );
5575
+ }
5576
+ return {
5577
+ id: "crawl-breadth",
5578
+ description: "Compares distinct paths against total requests to distinguish reading a site from enumerating it",
5579
+ cost: "cheap",
5580
+ stage: "always",
5581
+ inspect(ctx) {
5582
+ const { distinctPaths, total, pathsSaturated } = ctx.state;
5583
+ if (total < minRequests || distinctPaths < threshold) return void 0;
5584
+ const measuredOver = pathsSaturated ? ctx.state.requestsWhenPathsSaturated : total;
5585
+ const ratio = measuredOver > 0 ? distinctPaths / measuredOver : 0;
5586
+ if (ratio < noveltyRatio) return void 0;
5587
+ return {
5588
+ detector: "crawl-breadth",
5589
+ summary: pathsSaturated ? `at least ${distinctPaths} distinct paths, ${(ratio * 100).toFixed(0)}% of the first ${measuredOver} requests never revisited` : `${distinctPaths} distinct paths across ${total} requests (${(ratio * 100).toFixed(0)}% never revisited)`,
5590
+ direction: "bot",
5591
+ certainty: "weak",
5592
+ weight: ctx.state.pathsSaturated ? 0.25 : 0.15,
5593
+ botClass: "scraper",
5594
+ metadata: {
5595
+ distinctPaths,
5596
+ totalRequests: total,
5597
+ noveltyRatio: Number(ratio.toFixed(3)),
5598
+ measuredOverRequests: measuredOver,
5599
+ saturated: pathsSaturated
5600
+ }
5601
+ };
5602
+ }
5603
+ };
5604
+ }
5605
+ var init_crawl_breadth = __esm({
5606
+ "src/detectors/crawl-breadth.ts"() {
5607
+ "use strict";
5608
+ init_state();
5609
+ }
5610
+ });
5611
+
5612
+ // src/detectors/parameter-sweep.ts
5613
+ function parameterSweepDetector(options = {}) {
5614
+ const threshold = options.threshold ?? 25;
5615
+ const variantsPerPath = options.variantsPerPath ?? 8;
5616
+ const minRequests = options.minRequests ?? 20;
5617
+ if (threshold > MAX_TRACKED_QUERIES) {
5618
+ throw new RangeError(
5619
+ `parameterSweepDetector threshold ${threshold} can never be reached: an actor's distinct-query count saturates at ${MAX_TRACKED_QUERIES}. Use ${MAX_TRACKED_QUERIES} or fewer.`
5620
+ );
5621
+ }
5622
+ return {
5623
+ id: "parameter-sweep",
5624
+ description: "Counts distinct query strings against the paths they sit on, to catch enumeration that leaves the path unchanged",
5625
+ cost: "cheap",
5626
+ stage: "always",
5627
+ inspect(ctx) {
5628
+ const { distinctPaths, distinctQueries, queriesSaturated, total } = ctx.state;
5629
+ if (total < minRequests || distinctQueries < threshold) return void 0;
5630
+ const spread = distinctQueries / Math.max(1, distinctPaths);
5631
+ if (spread < variantsPerPath) return void 0;
5632
+ return {
5633
+ detector: "parameter-sweep",
5634
+ 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`,
5635
+ direction: "bot",
5636
+ certainty: "weak",
5637
+ // Saturation means the count stopped being able to grow, so the real spread is
5638
+ // wider than the one reported — the same argument breadth makes for itself.
5639
+ weight: queriesSaturated ? 0.25 : 0.15,
5640
+ botClass: "scraper",
5641
+ metadata: {
5642
+ distinctQueries,
5643
+ distinctPaths,
5644
+ variantsPerPath: Number(spread.toFixed(1)),
5645
+ totalRequests: total,
5646
+ saturated: queriesSaturated
5647
+ }
5648
+ };
5649
+ }
5650
+ };
5651
+ }
5652
+ var init_parameter_sweep = __esm({
5653
+ "src/detectors/parameter-sweep.ts"() {
5654
+ "use strict";
5655
+ init_state();
5656
+ }
5657
+ });
5658
+
5659
+ // src/detectors/transport-coherence.ts
5660
+ function transportCoherenceDetector(options = {}) {
5661
+ const checkLegacyHttp = options.legacyHttp ?? true;
5662
+ const minHeadRequests = options.minHeadRequests ?? 8;
5663
+ return {
5664
+ id: "transport-coherence",
5665
+ description: "Reads the HTTP version and the methods across a visit against the client the request claims to be",
5666
+ cost: "cheap",
5667
+ stage: "always",
5668
+ inspect(ctx) {
5669
+ if (!claimsBrowser(ctx.ua)) return void 0;
5670
+ const results = [];
5671
+ const version = ctx.facts.httpVersion;
5672
+ if (checkLegacyHttp && version !== void 0 && LEGACY_VERSIONS.has(version)) {
5673
+ results.push({
5674
+ detector: "transport-coherence",
5675
+ summary: `Client claims to be a browser but negotiated HTTP/${version}, which no shipping browser has offered in over a decade`,
5676
+ direction: "bot",
5677
+ certainty: "moderate",
5678
+ botClass: "impersonator",
5679
+ // One downgrading proxy in front of the application does this to every request
5680
+ // that passes through it, so this must count once rather than once per reason.
5681
+ family: "legacy-transport",
5682
+ metadata: { httpVersion: version, browser: ctx.ua.browser }
5683
+ });
5684
+ }
5685
+ const methods = ctx.state.methodsSeen;
5686
+ if (ctx.state.total >= minHeadRequests && methods.length > 0 && methods.every((method) => method === "HEAD")) {
5687
+ results.push({
5688
+ detector: "transport-coherence",
5689
+ summary: `Client claims to be a browser but has issued nothing but HEAD across ${ctx.state.total} requests`,
5690
+ direction: "bot",
5691
+ certainty: "moderate",
5692
+ botClass: "scraper",
5693
+ metadata: { requests: ctx.state.total, browser: ctx.ua.browser }
5694
+ });
5695
+ }
5696
+ return results.length > 0 ? results : void 0;
5697
+ }
5698
+ };
5699
+ }
5700
+ var LEGACY_VERSIONS;
5701
+ var init_transport_coherence = __esm({
5702
+ "src/detectors/transport-coherence.ts"() {
5703
+ "use strict";
5704
+ init_ua();
5705
+ LEGACY_VERSIONS = /* @__PURE__ */ new Set(["0.9", "1.0"]);
5706
+ }
5707
+ });
5708
+
5709
+ // src/detectors/probe-volume.ts
5710
+ function probeVolumeDetector(options = {}) {
5711
+ const minResponses = options.minResponses ?? 20;
5712
+ const missRatio = options.missRatio ?? 0.8;
5713
+ return {
5714
+ id: "probe-volume",
5715
+ description: "Reads the share of an actor's requests that were answered 404 or 410, where the application reports them",
5716
+ cost: "cheap",
5717
+ stage: "always",
5718
+ inspect(ctx) {
5719
+ const { responses, misses } = ctx.state;
5720
+ if (responses < minResponses) return void 0;
5721
+ const ratio = misses / responses;
5722
+ if (ratio < missRatio) return void 0;
5723
+ return {
5724
+ detector: "probe-volume",
5725
+ summary: `${misses} of this client's last ${responses} requests were answered "not found" (${(ratio * 100).toFixed(0)}%)`,
5726
+ direction: "bot",
5727
+ certainty: "moderate",
5728
+ botClass: "scanner",
5729
+ // `miss-baseline` reads the same misses relative to the site's own rate. One
5730
+ // cause, so the stronger reading stands rather than the two summing.
5731
+ family: "misses",
5732
+ metadata: { responses, misses, missRatio: Number(ratio.toFixed(3)) }
5733
+ };
5734
+ }
5735
+ };
5736
+ }
5737
+ var init_probe_volume = __esm({
5738
+ "src/detectors/probe-volume.ts"() {
5739
+ "use strict";
5740
+ }
5741
+ });
5742
+
5743
+ // src/detectors/id-enumeration.ts
5744
+ function idEnumerationDetector(options = {}) {
5745
+ const minRequests = options.minRequests ?? 30;
5746
+ const density = options.density ?? 0.9;
4412
5747
  return {
4413
- id: "crawl-breadth",
4414
- description: "Compares distinct paths against total requests to distinguish reading a site from enumerating it",
5748
+ id: "id-enumeration",
5749
+ description: "Reports an actor covering a contiguous range of numeric identifiers under one path shape",
4415
5750
  cost: "cheap",
4416
5751
  stage: "always",
4417
5752
  inspect(ctx) {
4418
- const { distinctPaths, total, pathsSaturated } = ctx.state;
4419
- if (total < minRequests || distinctPaths < threshold) return void 0;
4420
- const measuredOver = pathsSaturated ? ctx.state.requestsWhenPathsSaturated : total;
4421
- const ratio = measuredOver > 0 ? distinctPaths / measuredOver : 0;
4422
- if (ratio < noveltyRatio) return void 0;
5753
+ const walk = ctx.state.densestWalk();
5754
+ if (walk === void 0 || walk.count < minRequests) return void 0;
5755
+ if (walk.span < minRequests) return void 0;
5756
+ const covered = Math.min(1, walk.count / walk.span);
5757
+ if (covered < density) return void 0;
4423
5758
  return {
4424
- detector: "crawl-breadth",
4425
- summary: pathsSaturated ? `at least ${distinctPaths} distinct paths, ${(ratio * 100).toFixed(0)}% of the first ${measuredOver} requests never revisited` : `${distinctPaths} distinct paths across ${total} requests (${(ratio * 100).toFixed(0)}% never revisited)`,
5759
+ detector: "id-enumeration",
5760
+ summary: `${walk.count} requests to ${walk.template} covering ${(covered * 100).toFixed(0)}% of a ${walk.span}-wide range of ids`,
4426
5761
  direction: "bot",
4427
- certainty: "weak",
4428
- weight: ctx.state.pathsSaturated ? 0.25 : 0.15,
5762
+ certainty: "moderate",
4429
5763
  botClass: "scraper",
4430
- metadata: {
4431
- distinctPaths,
4432
- totalRequests: total,
4433
- noveltyRatio: Number(ratio.toFixed(3)),
4434
- measuredOverRequests: measuredOver,
4435
- saturated: pathsSaturated
4436
- }
5764
+ metadata: { template: walk.template, requests: walk.count, span: walk.span, coverage: Number(covered.toFixed(3)) }
4437
5765
  };
4438
5766
  }
4439
5767
  };
4440
5768
  }
4441
- var init_crawl_breadth = __esm({
4442
- "src/detectors/crawl-breadth.ts"() {
5769
+ var init_id_enumeration = __esm({
5770
+ "src/detectors/id-enumeration.ts"() {
5771
+ "use strict";
5772
+ }
5773
+ });
5774
+
5775
+ // src/detectors/blended-identity.ts
5776
+ function blendedIdentityDetector(options = {}) {
5777
+ const scannerFloor = options.scannerIdentities ?? 2;
5778
+ const crawlerFloor = options.crawlerIdentities ?? 2;
5779
+ return {
5780
+ id: "blended-identity",
5781
+ description: "Reads the set of identities one actor has claimed across requests for combinations that cannot all be true",
5782
+ cost: "cheap",
5783
+ stage: "always",
5784
+ inspect(ctx) {
5785
+ const claimed = ctx.state.claimedIdentities;
5786
+ if (claimed.size === 0) return void 0;
5787
+ const scanners = [];
5788
+ const crawlers = [];
5789
+ const benignCrawlers = [];
5790
+ for (const [id, what] of claimed) {
5791
+ if (what.category === "security") scanners.push(id);
5792
+ if (what.verifiable) crawlers.push(id);
5793
+ if (what.category === "search" || what.category === "ai" || what.category === "social") benignCrawlers.push(id);
5794
+ }
5795
+ const results = [];
5796
+ if (scanners.length >= scannerFloor) {
5797
+ results.push({
5798
+ detector: "blended-identity",
5799
+ summary: `One client has arrived as ${scanners.length} different security tools: ${scanners.join(", ")}`,
5800
+ direction: "bot",
5801
+ certainty: "strong",
5802
+ weight: 0.7,
5803
+ botClass: "scanner",
5804
+ metadata: { identities: scanners }
5805
+ });
5806
+ }
5807
+ if (crawlers.length >= crawlerFloor) {
5808
+ results.push({
5809
+ detector: "blended-identity",
5810
+ summary: `One client has claimed ${crawlers.length} crawler identities that publish address proofs: ${crawlers.join(", ")}`,
5811
+ direction: "bot",
5812
+ certainty: "strong",
5813
+ weight: 0.7,
5814
+ botClass: "impersonator",
5815
+ // Not `certain`, and the line is worth holding. Each operator publishes a proof
5816
+ // tied to addresses it controls, so at most one claim can be true — but a shared
5817
+ // egress in front of two genuinely different clients would produce the same set,
5818
+ // and this library refuses to deny anybody on an inference.
5819
+ metadata: { identities: crawlers }
5820
+ });
5821
+ }
5822
+ if (ctx.state.payloadProbes > 0 && benignCrawlers.length > 0) {
5823
+ results.push({
5824
+ detector: "blended-identity",
5825
+ summary: `Client claims to be ${benignCrawlers.join(", ")} and has sent ${ctx.state.payloadProbes} scanner payload(s)`,
5826
+ direction: "bot",
5827
+ certainty: "strong",
5828
+ weight: 0.75,
5829
+ botClass: "impersonator",
5830
+ metadata: { identities: benignCrawlers, payloadProbes: ctx.state.payloadProbes }
5831
+ });
5832
+ }
5833
+ return results.length > 0 ? results : void 0;
5834
+ }
5835
+ };
5836
+ }
5837
+ var init_blended_identity = __esm({
5838
+ "src/detectors/blended-identity.ts"() {
4443
5839
  "use strict";
4444
- init_state();
4445
5840
  }
4446
5841
  });
4447
5842
 
@@ -5025,14 +6420,16 @@ function findPayload(path, query) {
5025
6420
  for (const { pattern, what, tier } of PAYLOADS) {
5026
6421
  if (pattern.test(path)) return { what, where: "path", sample: path, tier };
5027
6422
  }
5028
- for (const [key, value] of Object.entries(query)) {
6423
+ for (const key in query) {
6424
+ const value = query[key];
6425
+ if (!PAYLOAD_GATE.test(value)) continue;
5029
6426
  for (const { pattern, what, tier } of PAYLOADS) {
5030
6427
  if (pattern.test(value)) return { what, where: `query parameter "${key.slice(0, 40)}"`, sample: value, tier };
5031
6428
  }
5032
6429
  }
5033
6430
  return void 0;
5034
6431
  }
5035
- var EXPLOIT_PATHS, EXPLOIT_SUFFIXES, PLATFORM_PATHS, PAYLOADS, INJECTION_PUNCTUATION, PROBE_METHODS;
6432
+ var EXPLOIT_PATHS, EXPLOIT_SUFFIXES, PLATFORM_PATHS, PAYLOADS, PAYLOAD_GATE, INJECTION_PUNCTUATION, PROBE_METHODS;
5036
6433
  var init_probe_signature = __esm({
5037
6434
  "src/detectors/probe-signature.ts"() {
5038
6435
  "use strict";
@@ -5106,11 +6503,74 @@ var init_probe_signature = __esm({
5106
6503
  { pattern: /<script[\s>]/i, what: "an inline script tag", tier: "markup" },
5107
6504
  { pattern: /\bon(?:error|load|mouseover)\s*=/i, what: "an inline event handler", tier: "markup" }
5108
6505
  ];
6506
+ PAYLOAD_GATE = /[$`:(<=\s]/;
5109
6507
  INJECTION_PUNCTUATION = /['"]|--\s|\/\*|;|%27|%22/;
5110
6508
  PROBE_METHODS = /* @__PURE__ */ new Set(["TRACE", "TRACK", "DEBUG", "CONNECT"]);
5111
6509
  }
5112
6510
  });
5113
6511
 
6512
+ // src/detectors/target-integrity.ts
6513
+ function targetIntegrityDetector(options = {}) {
6514
+ const reportPlain = options.reportPlainTraversal ?? true;
6515
+ return {
6516
+ id: "target-integrity",
6517
+ description: "Reports a request target spelled to get past something rather than to fetch something",
6518
+ cost: "cheap",
6519
+ stage: "always",
6520
+ inspect(ctx) {
6521
+ const raw = ctx.facts.rawPath;
6522
+ if (raw === void 0) return void 0;
6523
+ const findings = [];
6524
+ if (ABSOLUTE_FORM.test(raw)) {
6525
+ findings.push({ what: "asked this server to fetch a URL elsewhere, which is a request addressed to a proxy", certainty: "strong" });
6526
+ }
6527
+ if (DOUBLE_ENCODED.test(raw)) {
6528
+ findings.push({ what: "encoded its own encoding, so one round of decoding leaves it still encoded", certainty: "strong" });
6529
+ }
6530
+ if (ENCODED_CONTROL.test(raw)) {
6531
+ findings.push({ what: "carried a control character in the target", certainty: "strong" });
6532
+ }
6533
+ if (TRAVERSAL.test(raw)) {
6534
+ if (ENCODED_SEPARATOR.test(raw)) {
6535
+ findings.push({ what: "spelled the dots and slashes of a directory traversal in percent-encoding", certainty: "strong" });
6536
+ } else if (reportPlain) {
6537
+ findings.push({ what: "walked up out of the site root", certainty: "moderate" });
6538
+ }
6539
+ } else if (ENCODED_SLASH.test(raw)) {
6540
+ findings.push({ what: "hid a path separator inside a segment by encoding it", certainty: "moderate" });
6541
+ }
6542
+ if (findings.length === 0) return void 0;
6543
+ const certainty = findings.some((finding) => finding.certainty === "strong") ? "strong" : "moderate";
6544
+ const what = findings.map((finding) => finding.what);
6545
+ const listed = what.length === 1 ? what[0] : `${what.slice(0, -1).join(", ")}, and ${what[what.length - 1]}`;
6546
+ return {
6547
+ detector: "target-integrity",
6548
+ summary: `The request target ${listed}`,
6549
+ direction: "bot",
6550
+ certainty,
6551
+ botClass: "scanner",
6552
+ // One act, however many ways it shows. A traversal is usually encoded and an
6553
+ // encoded traversal is often double-encoded; compounding them would turn one
6554
+ // request into three independent reasons to be suspicious.
6555
+ family: "evasive-target",
6556
+ metadata: { target: raw.length > 200 ? `${raw.slice(0, 200)}\u2026` : raw }
6557
+ };
6558
+ }
6559
+ };
6560
+ }
6561
+ var ENCODED_SEPARATOR, ENCODED_SLASH, DOUBLE_ENCODED, ENCODED_CONTROL, TRAVERSAL, ABSOLUTE_FORM;
6562
+ var init_target_integrity = __esm({
6563
+ "src/detectors/target-integrity.ts"() {
6564
+ "use strict";
6565
+ ENCODED_SEPARATOR = /%2e|%2f|%5c/i;
6566
+ ENCODED_SLASH = /%2f|%5c/i;
6567
+ DOUBLE_ENCODED = /%25[0-9a-f]{2}/i;
6568
+ ENCODED_CONTROL = /%0[0-9a-f]|%1[0-9a-f]|%7f/i;
6569
+ TRAVERSAL = /\.\.|%2e%2e|%2e\.|\.%2e/i;
6570
+ ABSOLUTE_FORM = /^[a-z][a-z0-9+.-]*:\/\//i;
6571
+ }
6572
+ });
6573
+
5114
6574
  // src/detectors/rate-anomaly.ts
5115
6575
  function rateAnomalyDetector(options = {}) {
5116
6576
  const windowMs = options.windowMs ?? 1e4;
@@ -5613,20 +7073,26 @@ function defaultDetectors(options = {}) {
5613
7073
  // Identity first: a self-declaration or a verified crawler settles the question
5614
7074
  // outright, and the engine can then skip everything that would only add nuance.
5615
7075
  selfIdentifiedDetector(),
7076
+ blendedIdentityDetector(),
5616
7077
  trapDetector(),
5617
7078
  ipIntelligenceDetector(),
5618
7079
  probeSignatureDetector(),
7080
+ targetIntegrityDetector(),
5619
7081
  // Single-request consistency.
5620
7082
  headerIntegrityDetector(),
5621
7083
  uaCoherenceDetector(),
5622
7084
  clientHintsDetector(),
5623
7085
  fetchMetadataDetector(),
5624
7086
  acceptSignatureDetector(),
7087
+ transportCoherenceDetector(),
5625
7088
  headerOrderDetector(),
5626
7089
  // Behaviour across requests.
5627
7090
  rateAnomalyDetector(),
5628
7091
  cadenceDetector(),
5629
7092
  crawlBreadthDetector(),
7093
+ parameterSweepDetector(),
7094
+ probeVolumeDetector(),
7095
+ idEnumerationDetector(),
5630
7096
  sessionIntegrityDetector(),
5631
7097
  // The other side of the argument: what a real browsing session looks like.
5632
7098
  browsingCoherenceDetector(),
@@ -5642,12 +7108,18 @@ var init_detectors = __esm({
5642
7108
  init_cadence();
5643
7109
  init_client_hints();
5644
7110
  init_crawl_breadth();
7111
+ init_parameter_sweep();
7112
+ init_transport_coherence();
7113
+ init_probe_volume();
7114
+ init_id_enumeration();
7115
+ init_blended_identity();
5645
7116
  init_crawler_verification();
5646
7117
  init_fetch_metadata();
5647
7118
  init_header_integrity();
5648
7119
  init_header_order();
5649
7120
  init_ip_intelligence();
5650
7121
  init_probe_signature();
7122
+ init_target_integrity();
5651
7123
  init_rate_anomaly();
5652
7124
  init_self_identified();
5653
7125
  init_session_integrity();
@@ -5903,6 +7375,7 @@ function resolveConfig(config = {}) {
5903
7375
  }
5904
7376
  seen.add(detector.id);
5905
7377
  }
7378
+ const shadowDetectors = new Set(config.shadowDetectors ?? []);
5906
7379
  const rules = [...config.rules ?? []];
5907
7380
  if (config.preset !== void 0) {
5908
7381
  const preset = PRESETS[config.preset];
@@ -5931,6 +7404,36 @@ function resolveConfig(config = {}) {
5931
7404
  if (proxyConfig.trustProxy !== true && (trustedProxies !== void 0 || proxyConfig.hops !== void 0)) {
5932
7405
  warnings.push("proxy.trustedProxies / proxy.hops are configured but proxy.trustProxy is not enabled, so the forwarded header is ignored and the socket address is used.");
5933
7406
  }
7407
+ if (config.probe !== void 0) {
7408
+ if (config.probe.cookieName !== void 0 && config.probe.cookieName === config.challenge?.cookieName) {
7409
+ throw new ConfigError(
7410
+ `probe.cookieName and challenge.cookieName are both "${config.probe.cookieName}". One would overwrite the other on every response, so clearance and the marker would each destroy the other.`
7411
+ );
7412
+ }
7413
+ if (config.probe.secure === false) {
7414
+ warnings.push(
7415
+ "probe.secure is false, so the marker cookie will travel over plain HTTP and can be read by anything on the path. It is meant for local development; leave it unset in production."
7416
+ );
7417
+ }
7418
+ if (config.probe.domain !== void 0 && config.probe.domain.startsWith(".") === false && config.probe.domain.includes(".") === false) {
7419
+ warnings.push(`probe.domain is "${config.probe.domain}", which is not a domain a browser will accept, so the marker will never be stored or returned.`);
7420
+ }
7421
+ if (config.probe.ttlMs !== void 0 && config.probe.ttlMs < 6e4) {
7422
+ warnings.push(
7423
+ `probe.ttlMs is ${config.probe.ttlMs}ms. A marker that expires within a minute is re-issued on almost every request, which makes responses uncacheable and leaves nothing long enough to correlate.`
7424
+ );
7425
+ }
7426
+ }
7427
+ if (config.site !== void 0 && config.site.warmupRequests !== void 0 && config.site.warmupRequests < 500) {
7428
+ warnings.push(
7429
+ `site.warmupRequests is ${config.site.warmupRequests}. A baseline drawn from so little traffic is not one \u2014 every path is rare when nothing has been seen \u2014 so the site detectors will report ordinary visitors until real traffic arrives.`
7430
+ );
7431
+ }
7432
+ if (config.actorKey === void 0 && detectors2.some((detector) => detector.id === "identity-rotation")) {
7433
+ warnings.push(
7434
+ "identity-rotation is enabled while `actorKey` is left as the client address, so one actor means one address. A NAT gateway \u2014 an office, a campus, a mobile carrier \u2014 presents many people's browsers under a single address, which is this detector's exact signature and is entirely innocent. Give `actorKey` something narrower than an address (a session cookie, an authenticated user id, or an address combined with a TLS fingerprint), or take the detector out."
7435
+ );
7436
+ }
5934
7437
  const strictEvidence = config.strictEvidence ?? process.env["NODE_ENV"] !== "production";
5935
7438
  const falsePositivePolicy = config.falsePositivePolicy ?? "strict";
5936
7439
  if (falsePositivePolicy === "aggressive") {
@@ -5945,6 +7448,7 @@ function resolveConfig(config = {}) {
5945
7448
  }
5946
7449
  return {
5947
7450
  detectors: detectors2,
7451
+ shadowDetectors,
5948
7452
  rules,
5949
7453
  ranges,
5950
7454
  signatures,
@@ -5982,11 +7486,11 @@ function clamp(value, min, max) {
5982
7486
  return Math.min(max, Math.max(min, value));
5983
7487
  }
5984
7488
  function resolveClientIp(socketAddress, headers, proxy) {
5985
- const direct = socketAddress !== void 0 ? normalizeIp(socketAddress) ?? socketAddress : "";
7489
+ const direct = socketAddress !== void 0 ? normalizeIp(stripPort(socketAddress)) ?? socketAddress : "";
5986
7490
  if (!proxy.trustProxy) return direct;
5987
7491
  const header = headers[proxy.header];
5988
7492
  if (header === void 0) return direct;
5989
- const chain = header.slice(0, 2048).split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0 && parseIp(entry) !== null).map((entry) => normalizeIp(entry));
7493
+ const chain = header.slice(0, 2048).split(",").map((entry) => stripPort(entry)).filter((entry) => entry.length > 0 && parseIp(entry) !== null).map((entry) => normalizeIp(entry));
5990
7494
  if (chain.length === 0) return direct;
5991
7495
  if (proxy.trustedProxies) {
5992
7496
  if (direct !== "" && !proxy.trustedProxies.contains(direct)) return direct;
@@ -6243,11 +7747,15 @@ var init_feed = __esm({
6243
7747
  certain: assessment.certain,
6244
7748
  durationMs: Number(assessment.durationMs.toFixed(3)),
6245
7749
  bypass: assessment.bypass,
6246
- evidence: [...assessment.evidence, ...assessment.humanEvidence].map((item) => ({
7750
+ // Shadowed findings ride in the same list, flagged. They belong on the same screen
7751
+ // as the evidence that did decide — the comparison is the point — and the flag is
7752
+ // what stops the page, and `previewAssessment`, from treating them as such.
7753
+ evidence: [...assessment.evidence, ...assessment.humanEvidence, ...assessment.shadowEvidence].map((item) => ({
6247
7754
  detector: item.detector,
6248
7755
  summary: item.summary,
6249
7756
  certainty: item.certainty,
6250
7757
  direction: item.direction,
7758
+ ...item.shadow === true ? { shadow: true } : {},
6251
7759
  family: item.family,
6252
7760
  deterministicBasis: item.deterministicBasis,
6253
7761
  identity: item.identity,
@@ -6256,6 +7764,7 @@ var init_feed = __esm({
6256
7764
  category: typeof item.metadata?.["category"] === "string" ? item.metadata["category"] : void 0,
6257
7765
  weight: item.weight
6258
7766
  })),
7767
+ ...assessment.shadowVerdict === void 0 ? {} : { shadowVerdict: assessment.shadowVerdict },
6259
7768
  failures: assessment.failures.map((failure) => ({ detector: failure.detector, reason: failure.reason, message: failure.message })),
6260
7769
  actorStats: {
6261
7770
  requests: assessment.actor.requests,
@@ -6465,6 +7974,7 @@ function isDenial(action) {
6465
7974
  function assessmentFromEntry(entry) {
6466
7975
  const evidence = [];
6467
7976
  const humanEvidence = [];
7977
+ const shadowEvidence = [];
6468
7978
  for (const item of entry.evidence) {
6469
7979
  const rebuilt = {
6470
7980
  detector: item.detector,
@@ -6475,9 +7985,11 @@ function assessmentFromEntry(entry) {
6475
7985
  ...item.identity !== void 0 ? { identity: item.identity } : {},
6476
7986
  ...item.family !== void 0 ? { family: item.family } : {},
6477
7987
  // `category` is read off metadata by the matcher, so it has to go back there.
6478
- ...item.category !== void 0 ? { metadata: { category: item.category } } : {}
7988
+ ...item.category !== void 0 ? { metadata: { category: item.category } } : {},
7989
+ ...item.shadow === true ? { shadow: true } : {}
6479
7990
  };
6480
- (item.direction === "human" ? humanEvidence : evidence).push(rebuilt);
7991
+ if (item.shadow === true) shadowEvidence.push(rebuilt);
7992
+ else (item.direction === "human" ? humanEvidence : evidence).push(rebuilt);
6481
7993
  }
6482
7994
  return {
6483
7995
  requestId: entry.requestId,
@@ -6489,10 +8001,17 @@ function assessmentFromEntry(entry) {
6489
8001
  certain: entry.certain,
6490
8002
  evidence,
6491
8003
  humanEvidence,
8004
+ shadowEvidence,
8005
+ ...entry.shadowVerdict === void 0 ? {} : { shadowVerdict: entry.shadowVerdict },
6492
8006
  actor: {
6493
8007
  key: entry.actor,
6494
8008
  requests: entry.actorStats.requests,
6495
8009
  distinctPaths: entry.actorStats.distinctPaths,
8010
+ distinctQueries: 0,
8011
+ queriesSaturated: false,
8012
+ methodsSeen: ["GET"],
8013
+ responses: 0,
8014
+ misses: 0,
6496
8015
  firstSeen: entry.actorStats.firstSeen,
6497
8016
  lastSeen: entry.at,
6498
8017
  ...entry.actorStats.sinceLastMs !== void 0 ? { sinceLastMs: entry.actorStats.sinceLastMs } : {},
@@ -6541,7 +8060,7 @@ var CLIENT_SCRIPT;
6541
8060
  var init_client_generated = __esm({
6542
8061
  "src/dashboard/client.generated.ts"() {
6543
8062
  "use strict";
6544
- 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';
8063
+ 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 FALLBACK = {\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 BOOT = globalThis.__BOOTSTRAP__ ?? FALLBACK;\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 var FIELD_NAMES = Object.keys(FIELDS).sort();\n var FIELD_VALUES = {\n verdict: ["confirmed-bot", "verified-bot", "suspected-bot", "human", "unknown"],\n class: ["scanner", "scraper", "impersonator", "automation", "http-client", "declared-bot", "verified-bot", "human", "unknown"],\n botclass: ["scanner", "scraper", "impersonator", "automation", "http-client", "declared-bot", "verified-bot", "human", "unknown"],\n action: ["allow", "tag", "log", "challenge", "rate-limit", "delay", "block", "drop", "redirect"],\n outcome: ["allow", "mitigate", "deny", "pending"],\n certain: ["true", "false"],\n method: ["GET", "POST", "HEAD", "PUT", "PATCH", "DELETE", "OPTIONS"]\n };\n function suggestFor(input, caret) {\n const before = input.slice(0, caret);\n const start2 = Math.max(before.lastIndexOf(" ") + 1, 0);\n const token = before.slice(start2);\n const to = caret;\n const negated = token.startsWith("-") || token.startsWith("!");\n const body = negated ? token.slice(1) : token;\n const colon = body.indexOf(":");\n if (colon === -1) {\n const partial2 = body.toLowerCase();\n if (partial2.startsWith("$")) {\n return { options: OPERATORS.filter((name) => name.startsWith(partial2)), from: start2, to };\n }\n const options2 = FIELD_NAMES.filter((name) => name.startsWith(partial2)).map((name) => `${negated ? token[0] : ""}${name}:`);\n return { options: options2, from: start2, to };\n }\n const field2 = body.slice(0, colon).toLowerCase();\n const values = FIELD_VALUES[field2];\n if (values === void 0) return { options: [], from: start2, to };\n const partial = body.slice(colon + 1).toLowerCase();\n const prefix = `${negated ? token[0] : ""}${field2}:`;\n const forms = partial.startsWith("$") ? ["$in(", "$notin("].filter((form) => form.startsWith(partial)).map((form) => `${prefix}${form}`) : [];\n const options = [...forms, ...values.filter((value) => value.toLowerCase().startsWith(partial)).map((value) => `${prefix}${value}`)];\n return { options, from: start2, to };\n }\n var OPERATORS = ["$and", "$or", "$not", "$in", "$notin"];\n var MAX_GROUP_DEPTH = 32;\n function tokenize(input) {\n const tokens = [];\n let current = "";\n let quoted = false;\n let depth = 0;\n const flush = () => {\n if (current === "") return;\n const lower = current.toLowerCase();\n if (lower === "$and" || lower === "$or" || lower === "$not") tokens.push({ kind: "op", op: lower.slice(1) });\n else tokens.push({ kind: "word", text: current });\n current = "";\n };\n for (let i = 0; i < input.length; i++) {\n const character = input[i];\n if (character === \'"\') {\n quoted = !quoted;\n continue;\n }\n if (quoted) {\n current += character;\n continue;\n }\n if (depth > 0) {\n current += character;\n if (character === "(") depth++;\n else if (character === ")") {\n depth--;\n if (depth === 0) flush();\n }\n continue;\n }\n if (character === "(") {\n if (/\\$(in|notin)$/i.test(current)) {\n current += character;\n depth = 1;\n continue;\n }\n flush();\n tokens.push({ kind: "open" });\n continue;\n }\n if (character === ")") {\n flush();\n tokens.push({ kind: "close" });\n continue;\n }\n if (/\\s/.test(character)) {\n flush();\n continue;\n }\n current += character;\n }\n flush();\n return tokens;\n }\n function toTerm(word) {\n const negated = word.startsWith("-") || word.startsWith("!");\n const body = negated ? word.slice(1) : word;\n if (body === "") return void 0;\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) return { field: void 0, value: body.toLowerCase(), negated };\n const rest = body.slice(colon + 1);\n const set = /^\\$(in|notin)\\(([\\s\\S]*)$/i.exec(rest);\n if (set !== null) {\n const inside = set[2].endsWith(")") ? set[2].slice(0, -1) : set[2];\n const values = inside.split(",").map((entry) => entry.trim().toLowerCase()).filter((entry) => entry !== "");\n const inverted = set[1].toLowerCase() === "notin";\n return { field: field2, value: values[0] ?? "", negated: negated !== inverted, values };\n }\n let value = rest.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 === "") return void 0;\n return { field: field2, value, negated, compare };\n }\n function parseTokens(tokens) {\n let at = 0;\n let depth = 0;\n const parseUnary = () => {\n const token = tokens[at];\n if (token === void 0) return void 0;\n if (token.kind === "op" && token.op === "not") {\n at++;\n const of = parseUnary();\n return of === void 0 ? void 0 : { kind: "not", of };\n }\n if (token.kind === "open") {\n at++;\n if (depth >= MAX_GROUP_DEPTH) return void 0;\n depth++;\n const inner = parseOr();\n depth--;\n if (tokens[at]?.kind === "close") at++;\n return inner.kind === "all" ? void 0 : inner;\n }\n if (token.kind === "close") return void 0;\n if (token.kind === "op") {\n at++;\n return parseUnary();\n }\n at++;\n const term = toTerm(token.text);\n return term === void 0 ? void 0 : { kind: "term", term };\n };\n const parseAnd = () => {\n const parts = [];\n while (at < tokens.length) {\n const token = tokens[at];\n if (token === void 0 || token.kind === "close") break;\n if (token.kind === "op" && token.op === "or") break;\n if (token.kind === "op" && token.op === "and") {\n at++;\n continue;\n }\n const before = at;\n const part = parseUnary();\n if (part !== void 0) parts.push(part);\n if (at === before) at++;\n }\n return parts.length === 0 ? { kind: "all" } : parts.length === 1 ? parts[0] : { kind: "and", parts };\n };\n const parseOr = () => {\n const parts = [parseAnd()];\n while (tokens[at]?.kind === "op" && tokens[at].op === "or") {\n at++;\n parts.push(parseAnd());\n }\n const real = parts.filter((part) => part.kind !== "all");\n if (real.length === 0) return { kind: "all" };\n return real.length === 1 ? real[0] : { kind: "or", parts: real };\n };\n return parseOr();\n }\n var MAX_QUERY_CHARS = 8192;\n function parseFilter(input) {\n const trimmed = input.trim();\n return parseTokens(tokenize(trimmed.length > MAX_QUERY_CHARS ? trimmed.slice(0, MAX_QUERY_CHARS) : trimmed));\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.values !== void 0) {\n if (term.values.length === 0) return false;\n const actual = fieldValue(entry, term.field).toLowerCase();\n return term.values.some((value) => actual.includes(value));\n }\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 matches(filter, entry, haystack) {\n switch (filter.kind) {\n case "all":\n return true;\n case "term":\n return matchesTerm(filter.term, entry, haystack) !== filter.term.negated;\n case "not":\n return !matches(filter.of, entry, haystack);\n case "and":\n return filter.parts.every((part) => matches(part, entry, haystack));\n case "or":\n return filter.parts.some((part) => matches(part, entry, haystack));\n }\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 actorScope: "tracked",\n paused: false,\n filter: "all",\n search: "",\n query: { kind: "all" },\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 fromMs: void 0,\n toMs: void 0,\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.query = parseFilter(value);\n resetPaging();\n }\n function setTimeframe(from, to) {\n state.fromMs = from;\n state.toMs = to;\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 matches2(row) {\n if (state.fromMs !== void 0 && row.entry.at < state.fromMs) return false;\n if (state.toMs !== void 0 && row.entry.at > state.toMs) return false;\n return matchesFilter(state.filter, row.entry) && matches(state.query, 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 && matches2(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 (matches2(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, current) {\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 const label2 = labelControl(key, current, after);\n return [confirmingButton("Allowlist", `Allowlist ${key} \u2014 it stops being assessed at all`, () => allowlist(key, after)), forget, clear2, label2];\n }\n function labelControl(key, current, after) {\n const host = el("span", "label-edit");\n const button = el("button", null, current === void 0 ? "Label" : "Relabel");\n button.title = "Give this actor a name, for whoever reads this next. It never changes a verdict.";\n const commit = (value) => {\n const trimmed = value.trim().slice(0, 120);\n void act(\n { key, action: "label", ...trimmed === "" ? {} : { label: trimmed } },\n trimmed === "" ? `Cleared the label on ${key}` : `Labelled ${key}`,\n trimmed === "" ? "It shows as its address again." : `Shown as "${trimmed}" wherever it appears.`,\n after\n );\n };\n button.addEventListener("click", () => {\n const container = host.parentElement;\n clear(host);\n const input = el("input", "label-input");\n input.type = "text";\n input.value = current ?? "";\n input.placeholder = "A name for this client";\n input.setAttribute("aria-label", `Name for ${key}`);\n input.maxLength = 120;\n const save = el("button", "label-save", "Save");\n save.title = "Save this name";\n const cancel = el("button", null, "Cancel");\n cancel.title = "Leave the name as it was";\n input.title = "Enter to save, Escape to cancel";\n armed++;\n container?.classList.add("editing");\n let settled = false;\n const finish = (accept) => {\n if (settled) return;\n settled = true;\n armed--;\n container?.classList.remove("editing");\n if (accept) commit(input.value);\n else {\n clear(host);\n host.appendChild(button);\n }\n };\n for (const control of [input, save, cancel]) {\n control.addEventListener("keydown", (event) => {\n const pressed = event.key;\n if (pressed === "Escape") {\n event.preventDefault();\n finish(false);\n } else if (pressed === "Enter" && control === input) {\n event.preventDefault();\n finish(true);\n }\n });\n }\n for (const control of [save, cancel]) control.addEventListener("mousedown", (event) => event.preventDefault());\n save.addEventListener("click", () => finish(true));\n cancel.addEventListener("click", () => finish(false));\n host.addEventListener("focusout", (event) => {\n const next = event.relatedTarget;\n if (next !== null && host.contains(next)) return;\n finish(false);\n });\n host.append(input, save, cancel);\n input.focus();\n input.select();\n });\n host.appendChild(button);\n return host;\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/saved.ts\n var FILTERS_KEY = "bothandler.filters";\n var MAX_SAVED = 50;\n function read(key, fallback) {\n if (isEmbedded()) return fallback;\n try {\n const raw = localStorage.getItem(key);\n if (raw === null) return fallback;\n const parsed = JSON.parse(raw);\n return Array.isArray(parsed) ? parsed : fallback;\n } catch {\n return fallback;\n }\n }\n function write(key, value) {\n if (isEmbedded()) return;\n try {\n localStorage.setItem(key, JSON.stringify(value));\n } catch {\n }\n }\n function savedFilters() {\n return read(FILTERS_KEY, []).filter((entry) => typeof entry?.name === "string");\n }\n function saveFilter(entry) {\n const kept = savedFilters().filter((existing) => existing.name !== entry.name);\n const next = [entry, ...kept].slice(0, MAX_SAVED);\n write(FILTERS_KEY, next);\n return next;\n }\n function deleteFilter(name) {\n const next = savedFilters().filter((entry) => entry.name !== name);\n write(FILTERS_KEY, next);\n return next;\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 apply2 = el("button", "primary", "Apply");\n apply2.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(apply2);\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 showSuggestions(search);\n });\n initSuggestions(search);\n initTimeframe();\n initSavedFilters(search);\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 let served;\n try {\n const body = await getJson("/api/feed");\n for (const entry of body.entries) ingest(entry);\n served = body.skipped;\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 = (served ?? 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 highlighted = -1;\n function suggestionList() {\n return $("search-suggest");\n }\n function closeSuggestions(input) {\n const list = suggestionList();\n list.hidden = true;\n clear(list);\n highlighted = -1;\n input.setAttribute("aria-expanded", "false");\n }\n function showSuggestions(input) {\n const caret = input.selectionStart ?? input.value.length;\n const { options, from, to } = suggestFor(input.value, caret);\n const list = suggestionList();\n if (options.length === 0 || input.value.slice(from, to) === options[0]) {\n closeSuggestions(input);\n return;\n }\n clear(list);\n highlighted = -1;\n options.slice(0, 12).forEach((option, index) => {\n const item = el("li", null, option);\n item.setAttribute("role", "option");\n item.setAttribute("aria-selected", "false");\n item.addEventListener("mousedown", (event) => {\n event.preventDefault();\n apply(input, option, from, to);\n });\n item.dataset["index"] = String(index);\n list.appendChild(item);\n });\n list.hidden = false;\n input.setAttribute("aria-expanded", "true");\n }\n function apply(input, option, from, to) {\n const before = input.value.slice(0, from);\n const after = input.value.slice(to);\n input.value = `${before}${option}${after}`;\n const caret = before.length + option.length;\n input.setSelectionRange(caret, caret);\n setSearch(input.value.trim());\n app.syncUrl();\n app.drawNow();\n closeSuggestions(input);\n showSuggestions(input);\n input.focus();\n }\n function initSuggestions(input) {\n input.addEventListener("keydown", (event) => {\n const list = suggestionList();\n const items = Array.from(list.querySelectorAll("li"));\n if (list.hidden || items.length === 0) return;\n if (event.key === "Escape") {\n closeSuggestions(input);\n return;\n }\n if (event.key === "ArrowDown" || event.key === "ArrowUp") {\n event.preventDefault();\n highlighted = (highlighted + (event.key === "ArrowDown" ? 1 : items.length - 1) + (highlighted === -1 && event.key === "ArrowUp" ? 1 : 0)) % items.length;\n items.forEach((item, index) => item.setAttribute("aria-selected", String(index === highlighted)));\n return;\n }\n if ((event.key === "Enter" || event.key === "Tab") && highlighted >= 0) {\n const chosen = items[highlighted]?.textContent ?? "";\n const caret = input.selectionStart ?? input.value.length;\n const { from, to } = suggestFor(input.value, caret);\n event.preventDefault();\n apply(input, chosen, from, to);\n }\n });\n input.addEventListener("blur", () => {\n setTimeout(() => closeSuggestions(input), 120);\n });\n input.addEventListener("focus", () => showSuggestions(input));\n }\n function initSavedFilters(input) {\n const host = $("saved-filters");\n const redraw = () => {\n clear(host);\n const entries = savedFilters();\n const select2 = document.createElement("select");\n select2.setAttribute("aria-label", "Saved filters");\n const first = document.createElement("option");\n first.value = "";\n first.textContent = entries.length === 0 ? "No saved filters" : "Saved filters\u2026";\n select2.appendChild(first);\n for (const entry of entries) {\n const option = document.createElement("option");\n option.value = entry.name;\n option.textContent = entry.name;\n select2.appendChild(option);\n }\n select2.addEventListener("change", () => {\n const chosen = entries.find((entry) => entry.name === select2.value);\n if (chosen === void 0) return;\n input.value = chosen.query;\n setSearch(chosen.query);\n state.filter = chosen.filter;\n reflectFilterButtons();\n resetPaging();\n app.syncUrl();\n app.drawNow();\n });\n host.appendChild(select2);\n const save = el("button", null, "Save");\n save.type = "button";\n save.title = "Save this filter, in this browser, under a name";\n save.addEventListener("click", () => {\n const name = prompt("Save this filter as:")?.trim();\n if (name === void 0 || name === "") return;\n saveFilter({ name, query: input.value.trim(), filter: state.filter });\n redraw();\n toast("ok", "Filter saved", `"${name}" is in this browser. It is not shared with anybody else.`);\n });\n host.appendChild(save);\n if (select2.value !== "") {\n const remove = el("button", null, "Delete");\n remove.type = "button";\n remove.addEventListener("click", () => {\n deleteFilter(select2.value);\n redraw();\n });\n host.appendChild(remove);\n }\n };\n redraw();\n }\n function initTimeframe() {\n const from = byId("from-at");\n const to = byId("to-at");\n const clearButton = byId("timeframe-clear");\n const read2 = (input) => {\n if (input.value === "") return void 0;\n const parsed = new Date(input.value).getTime();\n return Number.isFinite(parsed) ? parsed : void 0;\n };\n const apply2 = () => {\n const start2 = read2(from);\n const end = read2(to);\n if (start2 !== void 0 && end !== void 0 && end < start2) {\n toast("warn", "That window runs backwards", "The end is before the start, so nothing can fall inside it.");\n }\n setTimeframe(start2, end);\n clearButton.hidden = start2 === void 0 && end === void 0;\n app.drawNow();\n };\n from.addEventListener("change", apply2);\n to.addEventListener("change", apply2);\n clearButton.addEventListener("click", () => {\n from.value = "";\n to.value = "";\n apply2();\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" : ""}${item.shadow === true ? " shadow" : ""}`);\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 if (item.shadow === true) meta += " \xB7 shadowed: counted, and part of no decision";\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 if (entry.shadowVerdict !== void 0) {\n const would = entry.shadowVerdict;\n const changed = would.verdict !== entry.verdict;\n cell.appendChild(\n el(\n "div",\n `ev-meta shadow-verdict${changed ? " changed" : ""}`,\n changed ? `With the shadowed detectors counted, this request would have been ${would.verdict} at ${would.score} instead of ${entry.verdict} at ${entry.score}.` : `With the shadowed detectors counted, this request would still have been ${would.verdict}${would.score === entry.score ? "" : `, at ${would.score} rather than ${entry.score}`}.`\n )\n );\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 shadowed = pairs(metrics.shadowFirings);\n if (shadowed.length > 0) {\n const moved = Object.entries(metrics.shadowChanges).filter(([, count]) => count > 0);\n rows.push(["Shadowed findings", n(shadowed.reduce((total, [, count]) => total + count, 0))]);\n for (const [verdict, count] of moved) rows.push([`Would have become ${verdict}`, n(count)]);\n if (moved.length === 0) rows.push(["Verdicts they would have changed", "none"]);\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${detector.shadow === true ? " shadow" : ""}`);\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 = (detector.shadow === true ? metrics?.shadowFirings[detector.id] : metrics?.detectorFirings[detector.id]) ?? 0;\n const timing = metrics?.detectorTimings[detector.id];\n let right = `${n(fires)}${detector.shadow === true ? " shadowed" : ""} \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 if (state.actorScope === "feed") 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 feedActors() {\n const byKey = /* @__PURE__ */ new Map();\n for (const row of matchingRows()) {\n const entry = row.entry;\n let seen = byKey.get(entry.actor);\n if (seen === void 0) {\n seen = { rows: 0, paths: /* @__PURE__ */ new Set(), agents: /* @__PURE__ */ new Set(), first: entry.at, last: entry.at };\n byKey.set(entry.actor, seen);\n }\n seen.rows++;\n seen.paths.add(entry.path);\n seen.agents.add(entry.userAgent);\n if (entry.at < seen.first) seen.first = entry.at;\n if (entry.at > seen.last) seen.last = entry.at;\n seen.stats = entry.actorStats ?? seen.stats;\n }\n const labels = new Map(state.actors.filter((actor) => actor.label !== void 0).map((actor) => [actor.key, actor.label]));\n const out = [];\n for (const [key, seen] of byKey) {\n const label2 = labels.get(key);\n out.push({\n key,\n ...label2 === void 0 ? {} : { label: label2 },\n requests: seen.rows,\n recentRate: Number.NaN,\n distinctPaths: seen.paths.size,\n distinctUserAgents: seen.agents.size,\n cadenceCv: void 0,\n // A dash where the feed cannot know, like the three columns below it. A confident\n // zero under a heading that means "how many times has this client been proven a bot"\n // is worse than an admission, and it was the only column here still guessing.\n priorConfirmations: seen.stats?.priorConfirmations ?? Number.NaN,\n unsolvedChallenges: Number.NaN,\n cleared: seen.stats?.cleared ?? false,\n firstSeen: seen.stats?.firstSeen ?? seen.first,\n lastSeen: seen.last\n });\n }\n return out.sort((a, b) => b.requests - a.requests);\n }\n function applyActorScope(scope) {\n if (!SECTIONS.registry) return;\n state.actorScope = scope;\n for (const [id, on] of [\n ["actors-scope-tracked", scope === "tracked"],\n ["actors-scope-feed", scope === "feed"]\n ]) {\n byId(id).className = on ? "on" : "";\n byId(id).setAttribute("aria-pressed", String(on));\n }\n drawActors();\n }\n function initActorScope() {\n if (!SECTIONS.registry) return;\n const choose = (scope) => {\n applyActorScope(scope);\n app.syncUrl({ replace: false });\n };\n $("actors-scope-tracked").addEventListener("click", () => choose("tracked"));\n $("actors-scope-feed").addEventListener("click", () => choose("feed"));\n }\n function drawActors() {\n if (!SECTIONS.registry) return;\n if (isConfirming()) return;\n const body = byId("actor-rows");\n clear(body);\n const fromFeed = state.actorScope === "feed";\n const actors = fromFeed ? feedActors() : state.actors;\n $("actors-count").textContent = fromFeed ? `${n(actors.length)} in the feed you are looking at \xB7 ${n(state.actorsTracked)} tracked` : `${n(actors.length)} shown \xB7 ${n(state.actorsTracked)} tracked`;\n drawActorsPager(!fromFeed && actors.length === state.actorsPageSize);\n if (fromFeed) for (const id of ["actors-pager-top", "actors-pager"]) byId(id).hidden = true;\n byId("actors-empty").hidden = actors.length > 0;\n for (const actor of actors) {\n const row = el("tr");\n const who = el("td", "who");\n if (actor.label === void 0) who.textContent = actor.key;\n else {\n who.appendChild(el("div", "label", actor.label));\n who.appendChild(el("div", "sub", actor.key));\n }\n row.appendChild(who);\n row.appendChild(el("td", "num tnum", n(actor.requests)));\n row.appendChild(el("td", "num tnum", Number.isNaN(actor.recentRate) ? "\u2014" : 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", Number.isNaN(actor.priorConfirmations) ? "\u2014" : n(actor.priorConfirmations)));\n const unsolved = el("td", "num tnum", Number.isNaN(actor.unsolvedChallenges) ? "\u2014" : 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(), actor.label)) 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 raw = byId("test-input").value;\n if (raw.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,\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 apply2 = () => {\n themeElement().style.setProperty("--header-h", `${header.getBoundingClientRect().height}px`);\n };\n apply2();\n if (typeof ResizeObserver === "function") new ResizeObserver(apply2).observe(header);\n else addEventListener("resize", apply2);\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 if (state.actorScope !== "tracked") params.set("a", state.actorScope);\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 raw = isEmbedded() ? "" : location.hash.slice(1);\n const split = raw.indexOf("?");\n const name = split === -1 ? raw : raw.slice(0, split);\n const params = new URLSearchParams(split === -1 ? "" : raw.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 // Anything but the one alternative reads as the default rather than as an error:\n // a hand-edited URL should land somewhere, and this is the somewhere it lands.\n actorScope: params.get("a") === "feed" ? "feed" : "tracked"\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 applyActorScope(url.actorScope);\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 = (options) => syncUrl(options?.replace !== false);\n applySections();\n initHeader();\n drawPeers();\n initTabs();\n initSkipLink();\n initKeyboard();\n initRanges();\n if (SECTIONS.feed) initFeed();\n initActor();\n initActorScope();\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 applyActorScope(url.actorScope);\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';
6545
8064
  }
6546
8065
  });
6547
8066
 
@@ -6761,7 +8280,10 @@ button[disabled] { opacity: .5; cursor: default; }
6761
8280
  .tab[aria-selected="true"] { color: var(--ink); border-bottom-color: var(--s1); }
6762
8281
 
6763
8282
  /* --- layout ------------------------------------------------------------- */
6764
- main { padding: 18px 20px 64px; max-width: 1680px; margin: 0 auto; }
8283
+ /* The top padding is the gap under the sticky header. At 18px the counter row sat almost
8284
+ against the header's border and read as part of it; the tiles carry their own border, so
8285
+ two lines were meeting with nothing between them. */
8286
+ main { padding: 28px 20px 64px; max-width: 1680px; margin: 0 auto; }
6765
8287
  .stack { display: grid; gap: 16px; }
6766
8288
  /* Everything above the feed is drawn by script once the first snapshot arrives, which
6767
8289
  inserts a block of content above what is already laid out. The browser's scroll
@@ -6792,6 +8314,33 @@ button.tile {
6792
8314
  cursor: pointer; appearance: none; transition: border-color .12s, box-shadow .12s;
6793
8315
  }
6794
8316
  button.tile:hover { border-color: var(--focus); }
8317
+ /* The suggestion list under the search box, positioned against the search wrapper. */
8318
+ .search { position: relative; }
8319
+ .suggest {
8320
+ position: absolute; top: calc(100% + 4px); left: 0; z-index: 30; margin: 0; padding: 4px;
8321
+ list-style: none; min-width: 220px; max-height: 260px; overflow-y: auto;
8322
+ background: var(--surface); border: 1px solid var(--line); border-radius: 9px; box-shadow: var(--shadow);
8323
+ }
8324
+ .suggest li { padding: 4px 9px; border-radius: 6px; cursor: pointer; font-size: 12px; }
8325
+ .suggest li[aria-selected="true"] { background: color-mix(in srgb, var(--focus) 18%, transparent); }
8326
+
8327
+ /* Saved filters: a list of small removable things. Quiet, because it is not what
8328
+ somebody came to the page to look at. */
8329
+ .saved { display: flex; align-items: center; gap: 6px; }
8330
+ /* Two open-ended bounds rather than a list of durations: "from the incident until now",
8331
+ "everything up to when it stopped" and "between these two moments" are the same control
8332
+ with one end left empty. */
8333
+ .timeframe { display: flex; align-items: center; gap: 8px; font-size: 11.5px; color: var(--muted); }
8334
+ .timeframe label { display: inline-flex; align-items: center; gap: 4px; }
8335
+ .timeframe input {
8336
+ font: inherit; font-size: 11.5px; padding: 2px 5px; border-radius: 6px;
8337
+ border: 1px solid var(--line); background: var(--surface); color: var(--ink);
8338
+ }
8339
+ .timeframe button { font: inherit; font-size: 11.5px; padding: 3px 9px; border-radius: 7px; border: 1px solid var(--line); background: var(--surface); color: var(--ink); cursor: pointer; }
8340
+ .saved select { font: inherit; font-size: 11.5px; padding: 3px 6px; border-radius: 7px; border: 1px solid var(--line); background: var(--surface); color: var(--ink); max-width: 170px; }
8341
+ .saved button { font: inherit; font-size: 11.5px; padding: 3px 9px; border-radius: 7px; border: 1px solid var(--line); background: var(--surface); color: var(--ink); cursor: pointer; }
8342
+ .saved button:hover { border-color: var(--focus); }
8343
+
6795
8344
  /* The badge's companion: fetches the entries the stream skipped. Sits inline with the
6796
8345
  heading, so it is styled to read as part of the sentence rather than as a form control. */
6797
8346
  .load-skipped {
@@ -6813,6 +8362,10 @@ button.tile:hover { border-color: var(--focus); }
6813
8362
  .pager button:hover:not(:disabled) { border-color: var(--focus); }
6814
8363
  .pager button:disabled { opacity: .45; cursor: default; }
6815
8364
  .pager .where { font-variant-numeric: tabular-nums; }
8365
+ /* A labelled actor leads with its name and keeps the key underneath: whoever named it did
8366
+ so because the key was not the useful part, and the key is still what you search for. */
8367
+ td.who .label { font-weight: 560; }
8368
+ td.who .sub { color: var(--muted); font-size: 11px; }
6816
8369
  .pager.pager-top { padding: 2px 2px 9px; border-bottom: 1px solid var(--line); margin-bottom: 9px; }
6817
8370
  /* The feed's upper pager rides in the toolbar rather than owning a row of its own, which
6818
8371
  was thirty-six pixels of mostly empty rule above every screenful of requests. */
@@ -6933,7 +8486,29 @@ input[type="search"] {
6933
8486
  }
6934
8487
  input[type="search"]::placeholder { color: var(--muted); }
6935
8488
 
6936
- table { width: 100%; border-collapse: collapse; }
8489
+ /* separate with zero spacing rather than collapse, and the difference is the whole
8490
+ reason the column headers work in Safari.
8491
+
8492
+ Collapsed borders and sticky table cells are a long-standing sore point in WebKit: the
8493
+ CSSWG has an open issue on collapsed borders not following a cell when it sticks
8494
+ (csswg-drafts#3136), and Safari is widely reported to drop the stickiness of a th
8495
+ altogether under a collapsed table. Separating the borders is the standard remedy.
8496
+
8497
+ What was actually measured: the header sticks correctly in Chromium and in Firefox,
8498
+ both before and after this change, and it was reported adrift in Safari — which is what
8499
+ a sticky element that has stopped sticking looks like. WebKit could not be run on the
8500
+ machine this was written on, so the Safari half of it rests on that report and on the
8501
+ documented behaviour rather than on a measurement taken here.
8502
+
8503
+ The rendering is all but unchanged. Every border in these tables is a bottom border on
8504
+ the cell itself, plus the per-cell left accent on td.edge; no border is shared between
8505
+ two cells, so there is nothing for collapsing to merge and nothing for separating to
8506
+ double, and zero spacing keeps the cells touching. The one measurable difference is the
8507
+ accent column, which moves two pixels: collapsing centres that 3px border on the cell
8508
+ edge and leaves half of it outside the box, while separating puts all of it inside.
8509
+ Measured rather than assumed, and the leftmost column starting two pixels earlier is
8510
+ both imperceptible and the more correct of the two. */
8511
+ table { width: 100%; border-collapse: separate; border-spacing: 0; }
6937
8512
  thead th {
6938
8513
  /* Measured at runtime — see trackHeaderHeight(). The literal is the fallback for
6939
8514
  the instant before the first measurement, and for the tab strip wrapping. */
@@ -7225,9 +8800,44 @@ input::placeholder { color: color-mix(in srgb, var(--muted) 80%, transparent); }
7225
8800
  #actor-rows td { font-size: 12.5px; }
7226
8801
  #actor-rows td.who { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
7227
8802
  #actor-rows td.acts { text-align: right; white-space: nowrap; }
8803
+ /* The tracked/shown toggle above the actors table. A segmented pair rather than a
8804
+ dropdown: there are two answers and both are worth reading at a glance. */
8805
+ .scope { display: flex; gap: 6px; padding: 0 14px 10px; }
8806
+ .scope button { font-size: 11.5px; padding: 4px 10px; }
8807
+ .scope button.on { background: var(--accent); color: var(--on-accent, #fff); border-color: var(--accent); }
8808
+
7228
8809
  #actor-rows td.acts button { font-size: 11px; padding: 3px 8px; margin-left: 4px; }
8810
+ /* The Label control, which becomes a text box with a Save and a Cancel in place.
8811
+
8812
+ The cell does not wrap, so an editor that sat beside the row's other four buttons put
8813
+ Save off the right edge of the panel, where it could be seen and not clicked. While
8814
+ the editor is open it stands in for those buttons instead — which is also the right
8815
+ thing on its own, since Allowlist and Forget are not what somebody naming a client is
8816
+ reaching for. */
8817
+ .acts.editing > :not(.label-edit), .bar-actions.editing > :not(.label-edit) { display: none; }
8818
+ /* inline-flex rather than inline-block: the row is three fixed-size controls and a flex
8819
+ line is the layout that cannot spill them past its own edge. */
8820
+ .label-edit { display: inline-flex; align-items: center; gap: 4px; }
8821
+ .label-edit button { flex: 0 0 auto; }
8822
+ #actor-rows td.acts .label-save, #actor-actions .label-save { border-color: var(--accent); color: var(--accent); }
8823
+ /* Qualified with the element name on purpose: input[type="text"] { width: 100% } above
8824
+ outranks a bare class, so the width here was quietly ignored and the box grew to fill
8825
+ whatever it was in — which is what put Save and Cancel outside the panel. */
8826
+ input.label-input {
8827
+ font: inherit; font-size: 11px; padding: 3px 8px; width: 15ch; flex: 0 0 auto; box-sizing: border-box;
8828
+ color: var(--ink); background: var(--surface); border: 1px solid var(--accent); border-radius: 6px;
8829
+ }
8830
+ input.label-input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
8831
+
7229
8832
  /* A tag, not a warning: a cleared actor is a decision somebody made, and a metronomic
7230
8833
  one is a measurement. Neither is a verdict, so neither gets a verdict's colour. */
8834
+ /* A shadowed finding: shown at full detail, and visibly not part of the decision. Dimmed
8835
+ and set behind a rule rather than coloured, because every colour on this page already
8836
+ means something about a verdict and this one took no part in a verdict. */
8837
+ .det.shadow { opacity: 0.72; }
8838
+ .ev-item.shadow { opacity: 0.72; border-left: 2px dashed var(--line); padding-left: 8px; }
8839
+ .shadow-verdict { margin-top: 8px; font-style: italic; }
8840
+ .shadow-verdict.changed { color: var(--ink-2); font-style: normal; }
7231
8841
  .tagline { font-size: 11px; color: var(--muted); }
7232
8842
  .tagline b { color: var(--ink-2); font-weight: 600; }
7233
8843
 
@@ -7325,8 +8935,16 @@ input::placeholder { color: color-mix(in srgb, var(--muted) 80%, transparent); }
7325
8935
  <div class="toolbar">
7326
8936
  <div class="filters" id="filters"></div>
7327
8937
  <div class="search">
7328
- <input type="search" id="search" placeholder="path:/api actor:203.0.113.4 -rule:allow-crawlers" spellcheck="false" autocomplete="off">
8938
+ <input type="search" id="search" placeholder="path:/api actor:203.0.113.4 -rule:allow-crawlers" spellcheck="false" autocomplete="off"
8939
+ role="combobox" aria-expanded="false" aria-controls="search-suggest" aria-autocomplete="list">
7329
8940
  <kbd aria-hidden="true">/</kbd>
8941
+ <ul class="suggest" id="search-suggest" role="listbox" aria-label="Filter suggestions" hidden></ul>
8942
+ </div>
8943
+ <div class="saved" id="saved-filters"></div>
8944
+ <div class="timeframe" id="timeframe">
8945
+ <label>From <input type="datetime-local" id="from-at" step="1"></label>
8946
+ <label>To <input type="datetime-local" id="to-at" step="1"></label>
8947
+ <button type="button" id="timeframe-clear" hidden>Clear</button>
7330
8948
  </div>
7331
8949
  <button id="feed-export" title="Download every request matching this filter as replay JSONL">Export</button>
7332
8950
  <div class="pager pager-inline" id="feed-pager-top" hidden></div>
@@ -7390,6 +9008,12 @@ input::placeholder { color: color-mix(in srgb, var(--muted) 80%, transparent); }
7390
9008
  <div class="note" id="actors-note">Everyone the engine is currently remembering, busiest first — a far larger
7391
9009
  population than the feed's ring, which holds requests rather than clients. This is
7392
9010
  what <code>cadence</code>, <code>crawl-breadth</code> and <code>rate-anomaly</code> are reading.</div>
9011
+ <div class="scope" role="group" aria-label="Which actors to list">
9012
+ <button id="actors-scope-tracked" class="on" aria-pressed="true"
9013
+ title="Every client the engine is remembering, busiest first">Tracked</button>
9014
+ <button id="actors-scope-feed" aria-pressed="false"
9015
+ title="Only the clients that appear in the feed you are looking at, after its filter">Shown in the feed</button>
9016
+ </div>
7393
9017
  <div class="pager pager-top" id="actors-pager-top" hidden></div>
7394
9018
  <div class="feed-scroll">
7395
9019
  <table>
@@ -7703,18 +9327,21 @@ function createFacts(input) {
7703
9327
  const rawPath = queryStart === -1 ? url : url.slice(0, queryStart);
7704
9328
  const headers = /* @__PURE__ */ Object.create(null);
7705
9329
  for (const [name, value] of Object.entries(input.headers)) {
7706
- const joined = joinHeaderValue(value);
7707
- if (joined !== void 0) headers[name.toLowerCase()] = joined;
9330
+ const lower = name.toLowerCase();
9331
+ const joined = lower === "cookie" && Array.isArray(value) ? value.join("; ") : joinHeaderValue(value);
9332
+ if (joined !== void 0) headers[lower] = joined;
7708
9333
  }
9334
+ const normalized = normalizePath(rawPath);
7709
9335
  const facts = {
7710
9336
  method: (input.method ?? "GET").toUpperCase(),
7711
- path: normalizePath(rawPath),
9337
+ path: normalized,
7712
9338
  query: parseQuery(queryStart === -1 ? "" : url.slice(queryStart + 1)),
7713
9339
  headers,
7714
9340
  headerOrder: extractOrder(input.rawHeaders, headers),
7715
9341
  ip: normalizeIp(input.ip) ?? input.ip,
7716
9342
  timestamp: input.timestamp ?? Date.now()
7717
9343
  };
9344
+ if (rawPath !== normalized) facts.rawPath = rawPath.length > MAX_RAW_PATH ? rawPath.slice(0, MAX_RAW_PATH) : rawPath;
7718
9345
  const cookieHeader = headers["cookie"];
7719
9346
  if (cookieHeader !== void 0) facts.cookies = parseCookies(cookieHeader);
7720
9347
  if (input.protocol !== void 0) facts.protocol = input.protocol;
@@ -7747,12 +9374,23 @@ function parseQuery(search) {
7747
9374
  const query = /* @__PURE__ */ Object.create(null);
7748
9375
  if (search.length === 0) return query;
7749
9376
  let count = 0;
7750
- for (const [key, value] of new URLSearchParams(search)) {
9377
+ for (const [key, value] of new URLSearchParams(boundedSearch(search))) {
7751
9378
  if (count++ >= MAX_QUERY_PARAMS) break;
7752
9379
  query[key] = value.length > 1024 ? value.slice(0, 1024) : value;
7753
9380
  }
7754
9381
  return query;
7755
9382
  }
9383
+ function boundedSearch(search) {
9384
+ let seen = 0;
9385
+ let at = search.charCodeAt(0) === 63 ? 1 : 0;
9386
+ while (at < search.length) {
9387
+ let end = search.indexOf("&", at);
9388
+ if (end === -1) end = search.length;
9389
+ if (end !== at && ++seen > MAX_QUERY_PARAMS) return search.slice(0, at - 1);
9390
+ at = end + 1;
9391
+ }
9392
+ return search;
9393
+ }
7756
9394
  function extractOrder(rawHeaders, headers) {
7757
9395
  if (!rawHeaders || rawHeaders.length === 0) return EMPTY_ORDER;
7758
9396
  let isNodeStyle = rawHeaders.length % 2 === 0;
@@ -7788,13 +9426,14 @@ function isHeaderName(value) {
7788
9426
  }
7789
9427
  return true;
7790
9428
  }
7791
- var MAX_URL_LENGTH, MAX_QUERY_PARAMS, MAX_ORDERED_HEADERS, EMPTY_ORDER;
9429
+ var MAX_URL_LENGTH, MAX_RAW_PATH, MAX_QUERY_PARAMS, MAX_ORDERED_HEADERS, EMPTY_ORDER;
7792
9430
  var init_facts = __esm({
7793
9431
  "src/facts.ts"() {
7794
9432
  "use strict";
7795
9433
  init_http();
7796
9434
  init_ip();
7797
9435
  MAX_URL_LENGTH = 8192;
9436
+ MAX_RAW_PATH = 512;
7798
9437
  MAX_QUERY_PARAMS = 64;
7799
9438
  MAX_ORDERED_HEADERS = 64;
7800
9439
  EMPTY_ORDER = Object.freeze([]);
@@ -8081,7 +9720,7 @@ function buildDashboard(handler, options, host) {
8081
9720
  return send(response, 200, "application/json; charset=utf-8", JSON.stringify(snapshot()));
8082
9721
  case "/api/feed":
8083
9722
  if (!sections.feed) return sectionOff(response, "feed");
8084
- return send(response, 200, "application/json; charset=utf-8", JSON.stringify({ entries: feed.backlog().map(project) }));
9723
+ return send(response, 200, "application/json; charset=utf-8", JSON.stringify({ entries: feed.backlog().map(project), skipped: feed.skipped }));
8085
9724
  case "/api/stream":
8086
9725
  if (!sections.feed) return sectionOff(response, "feed");
8087
9726
  return stream(request, url, response);
@@ -8159,8 +9798,11 @@ function buildDashboard(handler, options, host) {
8159
9798
  } else if (action === "clear") {
8160
9799
  const forMs = typeof payload?.forMs === "number" && Number.isFinite(payload.forMs) ? Math.min(24 * 60 * 6e4, Math.max(0, payload.forMs)) : DEFAULT_CLEARANCE_MS;
8161
9800
  handler.clearActor(key, forMs, { by });
9801
+ } else if (action === "label") {
9802
+ const label = typeof payload?.label === "string" ? payload.label : void 0;
9803
+ handler.labelActor(key, label, { by });
8162
9804
  } else {
8163
- sendError(response, 400, 'Expected `action` to be "forget" or "clear".');
9805
+ sendError(response, 400, 'Expected `action` to be "forget", "clear" or "label".');
8164
9806
  return;
8165
9807
  }
8166
9808
  send(response, 200, "application/json; charset=utf-8", JSON.stringify({ ok: true }));
@@ -8595,7 +10237,7 @@ function headerValue(request, name) {
8595
10237
  if (value === void 0) return void 0;
8596
10238
  return (Array.isArray(value) ? value[0] : value)?.trim().toLowerCase();
8597
10239
  }
8598
- function stripPort(host) {
10240
+ function stripPort2(host) {
8599
10241
  if (host.startsWith("[")) {
8600
10242
  const end = host.indexOf("]");
8601
10243
  return end === -1 ? host : host.slice(0, end + 1);
@@ -8607,15 +10249,15 @@ function stripPort(host) {
8607
10249
  function resolveAllowedHosts(host, extra) {
8608
10250
  if (extra?.includes("*")) return void 0;
8609
10251
  if (!LOOPBACK_HOSTS.has(host)) return void 0;
8610
- const allowed = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]", "::1", stripPort(host).toLowerCase()]);
8611
- for (const entry of extra ?? []) allowed.add(stripPort(entry).toLowerCase());
10252
+ const allowed = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]", "::1", stripPort2(host).toLowerCase()]);
10253
+ for (const entry of extra ?? []) allowed.add(stripPort2(entry).toLowerCase());
8612
10254
  return allowed;
8613
10255
  }
8614
10256
  function hostAllowed(request, allowed) {
8615
10257
  if (allowed === void 0) return true;
8616
10258
  const host = headerValue(request, "host");
8617
10259
  if (host === void 0) return false;
8618
- return allowed.has(stripPort(host));
10260
+ return allowed.has(stripPort2(host));
8619
10261
  }
8620
10262
  function isSameOrigin(request) {
8621
10263
  const site = headerValue(request, "sec-fetch-site");
@@ -8658,7 +10300,7 @@ function rangeUpdate(body2, handler) {
8658
10300
  }
8659
10301
  function resolveMountedHosts(extra) {
8660
10302
  if (extra === void 0 || extra.length === 0 || extra.includes("*")) return void 0;
8661
- return new Set(extra.map((name) => stripPort(name).toLowerCase()));
10303
+ return new Set(extra.map((name) => stripPort2(name).toLowerCase()));
8662
10304
  }
8663
10305
  function validateClients(entries) {
8664
10306
  const set = new IpRangeSet(entries);
@@ -8912,6 +10554,12 @@ var init_dashboard = __esm({
8912
10554
  });
8913
10555
 
8914
10556
  // src/core.ts
10557
+ function sanitize(item) {
10558
+ const summary = safeSummary(item.summary);
10559
+ const basis = item.deterministicBasis === void 0 ? void 0 : safeSummary(item.deterministicBasis);
10560
+ if (summary === item.summary && basis === item.deterministicBasis) return item;
10561
+ return { ...item, summary, ...basis === void 0 ? {} : { deterministicBasis: basis } };
10562
+ }
8915
10563
  function attribute(context) {
8916
10564
  return context.by === void 0 || context.by === "" ? "" : ` by ${context.by}`;
8917
10565
  }
@@ -8937,6 +10585,10 @@ var init_core = __esm({
8937
10585
  init_policy();
8938
10586
  init_dns();
8939
10587
  init_clearance();
10588
+ init_challenge_reaction();
10589
+ init_challenge_integrity();
10590
+ init_site_baseline();
10591
+ init_marker2();
8940
10592
  init_evidence();
8941
10593
  init_known_bots();
8942
10594
  init_types2();
@@ -8944,6 +10596,10 @@ var init_core = __esm({
8944
10596
  init_ua();
8945
10597
  init_pattern();
8946
10598
  init_crypto();
10599
+ init_text();
10600
+ init_probe();
10601
+ init_site();
10602
+ init_state();
8947
10603
  init_config();
8948
10604
  init_async();
8949
10605
  init_ip();
@@ -8954,6 +10610,10 @@ var init_core = __esm({
8954
10610
  store;
8955
10611
  policy;
8956
10612
  challenge;
10613
+ /** The marker-cookie probe, when the operator asked for one. See `probe` in the config. */
10614
+ probe;
10615
+ /** The site-wide baseline, when the operator asked for one. See `site` in the config. */
10616
+ site;
8957
10617
  notifications;
8958
10618
  /**
8959
10619
  * The traffic audit, or `undefined` when it was switched off with `audit: false`.
@@ -8969,6 +10629,8 @@ var init_core = __esm({
8969
10629
  cheapDetectors = [];
8970
10630
  ioDetectors = [];
8971
10631
  confirmingDetectors = [];
10632
+ /** Hoisted from the resolved config: read once per detector per request. */
10633
+ shadowIds;
8972
10634
  events;
8973
10635
  ignoreExact;
8974
10636
  ignorePatterns;
@@ -8983,12 +10645,15 @@ var init_core = __esm({
8983
10645
  this.store = options.store ?? new MemoryStore({ clock: this.config.clock });
8984
10646
  this.registry = new ActorRegistry(this.config.clock, { windowMs: this.config.actorWindowMs, maxActors: this.config.maxActors });
8985
10647
  this.signatures = compileSignatures(this.config.signatures);
10648
+ this.shadowIds = this.config.shadowDetectors;
8986
10649
  this.resolver = cachingResolver(options.resolver ?? nodeDnsResolver(this.config.detectorTimeoutMs));
8987
10650
  this.handlers = new Map((options.handlers ?? []).map((handler) => [handler.id, handler]));
8988
10651
  this.isHuman = options.isHuman;
8989
10652
  this.meter = options.metrics === false ? void 0 : new Metrics(typeof options.metrics === "object" ? options.metrics : {});
8990
10653
  this.timing = this.meter?.perDetectorTiming === true;
8991
10654
  this.challenge = options.challenge ? new ChallengeService({ ...options.challenge, store: this.store, clock: this.config.clock }) : void 0;
10655
+ this.probe = options.probe !== void 0 ? new MarkerProbe({ ...options.probe, clock: this.config.clock }) : void 0;
10656
+ this.site = options.site !== void 0 ? new SiteProfile({ ...options.site, clock: this.config.clock }) : void 0;
8992
10657
  this.notifications = new NotificationHub({
8993
10658
  ...options.notifications,
8994
10659
  clock: this.config.clock,
@@ -9009,12 +10674,35 @@ var init_core = __esm({
9009
10674
  const detectors2 = [...this.config.detectors];
9010
10675
  if (this.challenge && !detectors2.some((detector) => detector.id === "clearance")) {
9011
10676
  detectors2.unshift(clearanceDetector(this.challenge));
10677
+ if (!detectors2.some((detector) => detector.id === "challenge-reaction")) {
10678
+ detectors2.unshift(challengeReactionDetector());
10679
+ }
10680
+ if (!detectors2.some((detector) => detector.id === "challenge-integrity")) {
10681
+ detectors2.unshift(challengeIntegrityDetector());
10682
+ }
10683
+ }
10684
+ if (this.site !== void 0) {
10685
+ for (const detector of [distributedWalkDetector(), pathNoveltyDetector(), missBaselineDetector(), pathCampaignDetector()]) {
10686
+ if (!detectors2.some((installed) => installed.id === detector.id)) detectors2.unshift(detector);
10687
+ }
10688
+ }
10689
+ if (this.probe !== void 0) {
10690
+ for (const detector of [identityDriftDetector(), markerIntegrityDetector(), markerPersistenceDetector(), markerFanoutDetector()]) {
10691
+ if (!detectors2.some((installed) => installed.id === detector.id)) detectors2.unshift(detector);
10692
+ }
9012
10693
  }
9013
10694
  for (const detector of detectors2) {
9014
10695
  if (detector.stage === "confirming") this.confirmingDetectors.push(detector);
9015
10696
  else if (detector.cost === "io") this.ioDetectors.push(detector);
9016
10697
  else this.cheapDetectors.push(detector);
9017
10698
  }
10699
+ for (const id of this.shadowIds) {
10700
+ if (!detectors2.some((detector) => detector.id === id)) {
10701
+ this.warn(
10702
+ `shadowDetectors names "${id}", which is not an installed detector, so nothing is being shadowed by that entry. Installed: ${detectors2.map((detector) => detector.id).join(", ")}.`
10703
+ );
10704
+ }
10705
+ }
9018
10706
  if (options.shareConfirmations === true) {
9019
10707
  this.registry.onFirstSight = (state) => this.loadSharedConfirmations(state);
9020
10708
  }
@@ -9169,6 +10857,45 @@ var init_core = __esm({
9169
10857
  this.warn(`Actor "${key}" was cleared as human at runtime${attribute(context)}, until ${new Date(until).toISOString()}.`);
9170
10858
  this.events.emit("actor-change", { key, action: "clear", until, by: context.by });
9171
10859
  }
10860
+ /**
10861
+ * Tells the engine what the application answered.
10862
+ *
10863
+ * The one thing detection cannot see for itself. Every verdict here is reached *before*
10864
+ * the response exists — that is what makes it useful, since it can shape the response —
10865
+ * and so the status is knowledge only the application holds. Handed back, it closes the
10866
+ * oldest gap in reading a scanner: an actor whose requests are almost all misses is
10867
+ * looking for something rather than reading anything, and no amount of header analysis
10868
+ * shows that.
10869
+ *
10870
+ * Optional, and silent when the actor has already been forgotten. Nothing about
10871
+ * detection depends on it being called; supplying it sharpens `probe-volume` and
10872
+ * nothing else. The bundled Node adapter wires it up for you.
10873
+ */
10874
+ recordOutcome(facts, status) {
10875
+ if (!this.isIgnoredPath(facts.path) && !this.isAllowlisted(facts.ip)) {
10876
+ this.site?.recordOutcome(facts.path, status);
10877
+ }
10878
+ if (!Number.isFinite(status)) return;
10879
+ this.registry.peek(this.actorKeyFor(facts))?.recordOutcome(status);
10880
+ }
10881
+ /**
10882
+ * Gives an actor a name, or clears it with `undefined`.
10883
+ *
10884
+ * Detection never reads it — a label cannot make anybody more or less suspicious, and
10885
+ * that separation is deliberate: the moment a note changes a verdict, writing notes
10886
+ * becomes a way to be wrong about people at scale. It is for the humans reading the
10887
+ * dashboard, and it survives exactly as long as the actor does.
10888
+ *
10889
+ * Available from code so a deployment can label what it already knows — its own
10890
+ * monitoring, a partner's feed, the office egress — rather than waiting for somebody to
10891
+ * recognise the address twice.
10892
+ */
10893
+ labelActor(key, label, context = {}) {
10894
+ const state = this.registry.peek(key);
10895
+ if (state === void 0) return;
10896
+ state.setLabel(label);
10897
+ this.warn(`Actor "${key}" was ${label === void 0 ? "unlabelled" : `labelled "${state.label ?? ""}"`} at runtime${attribute(context)}.`);
10898
+ }
9172
10899
  /** Convenience for `updateRanges("crawler:<id>", …)`, matching a signature id. */
9173
10900
  updateCrawlerRanges(signatureId, entries, context = {}) {
9174
10901
  this.updateRanges(`crawler:${signatureId}`, entries, context);
@@ -9281,7 +11008,10 @@ var init_core = __esm({
9281
11008
  id: detector.id,
9282
11009
  description: detector.description,
9283
11010
  cost: detector.cost ?? "cheap",
9284
- stage: detector.stage ?? "always"
11011
+ stage: detector.stage ?? "always",
11012
+ // Present only when it is true, so a deployment shadowing nothing lists exactly
11013
+ // what it listed before.
11014
+ ...this.shadowIds.has(detector.id) ? { shadow: true } : {}
9285
11015
  }));
9286
11016
  }
9287
11017
  /** Recovers the client address from a socket address and headers, honouring the proxy config. */
@@ -9324,8 +11054,22 @@ var init_core = __esm({
9324
11054
  const state = record ? this.registry.observe(actorKey, facts) : detachedActor(actorKey, facts);
9325
11055
  const ua = parseUserAgent(facts.headers["user-agent"]);
9326
11056
  const signatureMatches = ua.lower.length > 0 ? this.signatures.matchAll(ua.lower) : [];
11057
+ for (const match of signatureMatches) state.noteIdentity(match.id, match.category, match.verification.kind !== "none");
11058
+ const marker = this.probe?.observe(facts, ua);
11059
+ if (marker !== void 0 && record) {
11060
+ state.noteMarker(marker.reading.kind === "valid", marker.reading.kind === "forged", marker.drift);
11061
+ }
11062
+ if (this.site !== void 0 && record) {
11063
+ const seenBefore = this.site.timesSeen(facts.path);
11064
+ this.site.record(facts.path, actorKey);
11065
+ if (seenBefore === 0) state.noteNovelPath();
11066
+ const step = walkStepOf(facts.path);
11067
+ if (step !== void 0) this.site.recordWalk(step.template, step.id, actorKey);
11068
+ }
9327
11069
  const context = {
9328
11070
  facts,
11071
+ marker,
11072
+ site: this.site,
9329
11073
  ua,
9330
11074
  actor: state.snapshot(facts.timestamp),
9331
11075
  state,
@@ -9337,21 +11081,22 @@ var init_core = __esm({
9337
11081
  shared: /* @__PURE__ */ new Map()
9338
11082
  };
9339
11083
  const evidence = [];
11084
+ const shadowEvidence = [];
9340
11085
  const failures = [];
9341
11086
  let pending;
9342
11087
  for (const detector of this.cheapDetectors) {
9343
- const inFlight = this.run(detector, context, evidence, failures, 0);
11088
+ const inFlight = this.run(detector, context, evidence, shadowEvidence, failures, 0);
9344
11089
  if (inFlight !== void 0) (pending ??= []).push(inFlight);
9345
11090
  }
9346
11091
  for (const detector of this.ioDetectors) {
9347
- const inFlight = this.run(detector, context, evidence, failures, this.config.detectorTimeoutMs);
11092
+ const inFlight = this.run(detector, context, evidence, shadowEvidence, failures, this.config.detectorTimeoutMs);
9348
11093
  if (inFlight !== void 0) (pending ??= []).push(inFlight);
9349
11094
  }
9350
11095
  if (pending !== void 0) await Promise.all(pending);
9351
11096
  if (signatureMatches.length > 0 && this.confirmingDetectors.length > 0) {
9352
11097
  let confirming;
9353
11098
  for (const detector of this.confirmingDetectors) {
9354
- const inFlight = this.run(detector, context, evidence, failures, this.config.detectorTimeoutMs);
11099
+ const inFlight = this.run(detector, context, evidence, shadowEvidence, failures, this.config.detectorTimeoutMs);
9355
11100
  if (inFlight !== void 0) (confirming ??= []).push(inFlight);
9356
11101
  }
9357
11102
  if (confirming !== void 0) await Promise.all(confirming);
@@ -9371,11 +11116,23 @@ var init_core = __esm({
9371
11116
  this.fail(error, "isHuman");
9372
11117
  }
9373
11118
  }
11119
+ if (evidence.some((item) => item.detector === "probe-signature")) state.notePayloadProbe();
9374
11120
  const combined = combineEvidence(evidence, {
9375
11121
  suspectThreshold: this.config.suspectThreshold,
9376
11122
  strictEvidence: this.config.strictEvidence,
9377
11123
  onEvidenceViolation: (message) => this.warn(message)
9378
11124
  });
11125
+ let shadowVerdict;
11126
+ if (shadowEvidence.length > 0) {
11127
+ const wouldBe = combineEvidence([...evidence, ...shadowEvidence], {
11128
+ suspectThreshold: this.config.suspectThreshold,
11129
+ strictEvidence: this.config.strictEvidence,
11130
+ onEvidenceViolation: (message, item) => {
11131
+ if (item.shadow === true) this.warn(message);
11132
+ }
11133
+ });
11134
+ shadowVerdict = { verdict: wouldBe.verdict, botClass: wouldBe.botClass, score: wouldBe.score, certain: wouldBe.certain };
11135
+ }
9379
11136
  const actor = state.snapshot(facts.timestamp);
9380
11137
  if (combined.verdict === "confirmed-bot" && record) {
9381
11138
  state.confirmations++;
@@ -9391,10 +11148,13 @@ var init_core = __esm({
9391
11148
  certain: combined.certain,
9392
11149
  evidence: combined.botEvidence,
9393
11150
  humanEvidence: combined.humanEvidence,
11151
+ shadowEvidence: sortEvidence(shadowEvidence),
11152
+ ...shadowVerdict === void 0 ? {} : { shadowVerdict },
9394
11153
  actor,
9395
11154
  durationMs: this.config.clock.now() - started,
9396
11155
  failures,
9397
- facts
11156
+ facts,
11157
+ ...marker === void 0 ? {} : { marker }
9398
11158
  };
9399
11159
  if (!record) return assessment;
9400
11160
  this.meter?.recordAssessment(assessment);
@@ -9431,15 +11191,46 @@ var init_core = __esm({
9431
11191
  onChallenge: (event) => {
9432
11192
  this.meter?.recordChallenge(event);
9433
11193
  const state = this.registry.peek(assessment.actor.key);
9434
- if (state !== void 0) state.unsolvedChallenges++;
11194
+ if (state !== void 0) {
11195
+ state.unsolvedChallenges++;
11196
+ state.noteChallengeIssued(
11197
+ this.config.clock.now(),
11198
+ assessment.marker?.shape ?? identityShape(assessment.facts, parseUserAgent(assessment.facts.headers["user-agent"]))
11199
+ );
11200
+ }
9435
11201
  this.events.emit("challenge", { phase: event, actorKey: assessment.actor.key });
9436
11202
  }
9437
11203
  });
11204
+ const issued = this.markerFor(assessment);
11205
+ if (issued !== void 0) {
11206
+ if (outcome.kind === "continue" && outcome.responseHeaders?.["set-cookie"] === void 0) {
11207
+ outcome.responseHeaders = { ...outcome.responseHeaders, "set-cookie": issued };
11208
+ } else if (outcome.kind === "respond" && outcome.headers["set-cookie"] === void 0) {
11209
+ outcome.headers = { ...outcome.headers, "set-cookie": issued };
11210
+ }
11211
+ }
9438
11212
  if (this.notifications.enabled && (outcome.kind !== "continue" || outcome.delayMs !== void 0)) {
9439
11213
  this.notifications.emit({ type: "action", at: new Date(facts.timestamp).toISOString(), assessment, decision });
9440
11214
  }
9441
11215
  return { assessment, decision, outcome };
9442
11216
  }
11217
+ /**
11218
+ * The `Set-Cookie` this response should carry, if any.
11219
+ *
11220
+ * Nothing is issued to a client that already holds a valid marker, because a
11221
+ * `Set-Cookie` on every response makes every response uncacheable by shared caches —
11222
+ * a detection feature is not worth a site's cache-hit ratio. Nothing is issued to a
11223
+ * verified crawler either: Googlebot does not keep cookies, so a marker sent to it is
11224
+ * a header that will never come back and an issuance count that means nothing.
11225
+ */
11226
+ markerFor(assessment) {
11227
+ if (this.probe === void 0 || assessment.marker === void 0) return void 0;
11228
+ if (assessment.botClass === "verified-bot") return void 0;
11229
+ if (!this.probe.shouldIssue(assessment.marker)) return void 0;
11230
+ const state = this.registry.peek(assessment.actor.key);
11231
+ state?.noteMarkerIssued();
11232
+ return this.probe.issue(assessment.marker);
11233
+ }
9443
11234
  /** True when this request is the challenge verification endpoint. */
9444
11235
  isChallengeEndpoint(facts) {
9445
11236
  return this.challenge !== void 0 && facts.method === "POST" && facts.path === this.challenge.verifyPath;
@@ -9458,6 +11249,9 @@ var init_core = __esm({
9458
11249
  this.meter?.recordChallenge(outcome.ok ? "solved" : "rejected");
9459
11250
  if (outcome.ok) this.meter?.recordClearance(outcome.level);
9460
11251
  else this.meter?.recordChallengeRejection(outcome.reason);
11252
+ if (!outcome.ok && outcome.signal !== void 0) {
11253
+ this.registry.peek(actorKey)?.noteChallengeAnomaly(outcome.signal);
11254
+ }
9461
11255
  if (outcome.interactionScore !== void 0) this.meter?.recordInteractionScore(outcome.interactionScore);
9462
11256
  if (outcome.ok) this.audit?.recordChallengeSolved(this.config.clock.now());
9463
11257
  this.events.emit("challenge", {
@@ -9494,7 +11288,9 @@ var init_core = __esm({
9494
11288
  * await. Failures are absorbed here in both paths: a detector can throw, reject or
9495
11289
  * hang, and none of those may reach the request.
9496
11290
  */
9497
- run(detector, context, sink, failures, timeoutMs) {
11291
+ run(detector, context, sink, shadowSink, failures, timeoutMs) {
11292
+ const shadowed = this.shadowIds.has(detector.id);
11293
+ const target = shadowed ? shadowSink : sink;
9498
11294
  const startedAt = this.timing ? this.config.clock.now() : 0;
9499
11295
  let raw;
9500
11296
  try {
@@ -9505,7 +11301,7 @@ var init_core = __esm({
9505
11301
  return void 0;
9506
11302
  }
9507
11303
  if (!(raw instanceof Promise)) {
9508
- this.collect(raw, sink);
11304
+ this.collect(raw, target, shadowed);
9509
11305
  if (startedAt !== 0) this.meter?.recordDetectorTiming(detector.id, this.config.clock.now() - startedAt);
9510
11306
  return void 0;
9511
11307
  }
@@ -9520,7 +11316,7 @@ var init_core = __esm({
9520
11316
  this.events.emit("detector-failure", { detector: detector.id, reason: "timeout", message, requestId: "" });
9521
11317
  return;
9522
11318
  }
9523
- this.collect(result, sink);
11319
+ this.collect(result, target, shadowed);
9524
11320
  },
9525
11321
  (error) => {
9526
11322
  if (startedAt !== 0) this.meter?.recordDetectorTiming(detector.id, this.config.clock.now() - startedAt);
@@ -9528,13 +11324,14 @@ var init_core = __esm({
9528
11324
  }
9529
11325
  );
9530
11326
  }
9531
- collect(result, sink) {
11327
+ collect(result, sink, shadowed = false) {
9532
11328
  if (result === void 0 || result === null) return;
11329
+ const mark = (item) => shadowed ? { ...sanitize(item), shadow: true } : sanitize(item);
9533
11330
  if (Array.isArray(result)) {
9534
- for (let i = 0; i < result.length; i++) sink.push(result[i]);
11331
+ for (let i = 0; i < result.length; i++) sink.push(mark(result[i]));
9535
11332
  return;
9536
11333
  }
9537
- sink.push(result);
11334
+ sink.push(mark(result));
9538
11335
  }
9539
11336
  recordFailure(detector, failures, error, requestId = "") {
9540
11337
  const message = error instanceof Error ? error.message : String(error);
@@ -9554,10 +11351,16 @@ var init_core = __esm({
9554
11351
  certain: false,
9555
11352
  evidence: [],
9556
11353
  humanEvidence: [],
11354
+ shadowEvidence: [],
9557
11355
  actor: existing?.snapshot(facts.timestamp) ?? {
9558
11356
  key: actorKey,
9559
11357
  requests: 0,
9560
11358
  distinctPaths: 0,
11359
+ distinctQueries: 0,
11360
+ queriesSaturated: false,
11361
+ methodsSeen: ["GET"],
11362
+ responses: 0,
11363
+ misses: 0,
9561
11364
  firstSeen: facts.timestamp,
9562
11365
  lastSeen: facts.timestamp,
9563
11366
  priorConfirmations: 0,
@@ -10359,7 +12162,7 @@ var init_ai_crawlers = __esm({
10359
12162
  });
10360
12163
 
10361
12164
  // src/corpus/adversarial.ts
10362
- var CHROME_UA, ADVERSARIAL_CASES;
12165
+ var CHROME_UA, CURL_UA, ADVERSARIAL_CASES;
10363
12166
  var init_adversarial = __esm({
10364
12167
  "src/corpus/adversarial.ts"() {
10365
12168
  "use strict";
@@ -10367,7 +12170,248 @@ var init_adversarial = __esm({
10367
12170
  init_schema();
10368
12171
  init_ranges();
10369
12172
  CHROME_UA = userAgentOf("chromeWindows");
12173
+ CURL_UA = "curl/8.4.0";
10370
12174
  ADVERSARIAL_CASES = [
12175
+ bot({
12176
+ id: "two-scanners-one-address",
12177
+ title: "One address arriving as two different security tools",
12178
+ audience: "hostile",
12179
+ category: "scanning",
12180
+ provenance: "The shape of an actual scan: an operator runs more than one tool against a target, and both announce themselves honestly. Each request on its own is a declared bot; the pair is a scan, and that reading does not exist inside either request.",
12181
+ requests: [
12182
+ { headers: [["Host", "shop.example"], ["User-Agent", "sqlmap/1.7.2#stable (http://sqlmap.org)"], ["Accept", "*/*"]], ip: "198.51.100.66", atMs: 0 },
12183
+ { headers: [["Host", "shop.example"], ["User-Agent", "Mozilla/5.00 (Nikto/2.5.0) (Evasions:None) (Test:Port Check)"], ["Accept", "*/*"]], ip: "198.51.100.66", atMs: 1e3 }
12184
+ ],
12185
+ expect: { verdict: "confirmed-bot", certain: true, detectors: ["blended-identity"] },
12186
+ notes: "Holds under the default address-based actor key, which is what separates it from `identity-rotation`. A NAT gateway presents a hundred browsers \u2014 that is exactly why counting User-Agents there is useless \u2014 and it does not present sqlmap and nikto."
12187
+ }),
12188
+ bot({
12189
+ id: "two-crawler-claims-one-address",
12190
+ title: "One address claiming to be both Googlebot and Bingbot",
12191
+ audience: "hostile",
12192
+ category: "impersonation",
12193
+ provenance: "At most one of these can be true of an address: each operator publishes a proof tied to addresses it controls. The contradiction is visible from the claims alone, with no lookup \u2014 which matters when DNS is unreachable and neither claim can be refuted on its own.",
12194
+ requests: [
12195
+ { headers: [["Host", "shop.example"], ["User-Agent", "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"], ["Accept", "*/*"]], ip: "198.51.100.67", atMs: 0 },
12196
+ { headers: [["Host", "shop.example"], ["User-Agent", "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)"], ["Accept", "*/*"]], ip: "198.51.100.67", atMs: 1e3 }
12197
+ ],
12198
+ expect: { detectors: ["blended-identity"] },
12199
+ notes: "Held at `strong` rather than `certain`. Trusting the wrong forwarded header collapses every client onto one address, and then two genuinely different crawlers produce this exact set \u2014 so it may contribute to a denial and may not be the whole of one."
12200
+ }),
12201
+ bot({
12202
+ id: "id-harvest-contiguous",
12203
+ title: "Every profile id in order, with a copied browser header set",
12204
+ audience: "hostile",
12205
+ category: "scraping",
12206
+ 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.",
12207
+ requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.65" }, 40, 800, (index) => `/user/${index + 1}`),
12208
+ expect: {
12209
+ // One `moderate` signal against a flawless header set, like the others here. What
12210
+ // changed is that the walk is now *visible* — before this detector it was scored
12211
+ // identically to a hundred and twenty scattered ids and to ordinary article paths.
12212
+ verdict: "unknown",
12213
+ detectors: ["id-enumeration"]
12214
+ },
12215
+ 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."
12216
+ }),
12217
+ bot({
12218
+ id: "wordlist-scan-mostly-misses",
12219
+ title: "A wordlist walked with a copied browser header set, almost all of it missing",
12220
+ audience: "hostile",
12221
+ category: "scanning",
12222
+ 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.",
12223
+ requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.64", status: 404 }, 30, 700, (index) => `/${["admin", "backup", "old", "test", "config", "db"][index % 6]}-${index}`),
12224
+ expect: {
12225
+ // One `moderate` signal against an otherwise flawless header set does not cross the
12226
+ // line, and it should not: a site that has just moved its URLs produces the same
12227
+ // shape from ordinary readers. Raising the ceiling so this case reads better would
12228
+ // be tuning the detector to the test rather than to the traffic.
12229
+ verdict: "unknown",
12230
+ detectors: ["probe-volume"]
12231
+ },
12232
+ 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."
12233
+ }),
12234
+ bot({
12235
+ id: "browser-claim-over-http-1-0",
12236
+ title: "A perfect Chrome header set, arriving over HTTP/1.0",
12237
+ audience: "hostile",
12238
+ category: "impersonation",
12239
+ 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.",
12240
+ requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.62", httpVersion: "1.0" }, 30, 900, (index) => `/${["news", "about", "blog", "help", "terms"][index % 5]}`),
12241
+ expect: {
12242
+ // Contributes rather than concludes. On its own, against an otherwise flawless
12243
+ // header set, one `moderate` signal does not reach the threshold — and it should
12244
+ // not, because an intermediary can cause this. Beside anything sharper it does.
12245
+ verdict: "unknown",
12246
+ detectors: ["transport-coherence"]
12247
+ },
12248
+ 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."
12249
+ }),
12250
+ // ---------------------------------------------------------------------------
12251
+ // The optional sources. None of these can be detected without the operator
12252
+ // switching something on — a marker cookie, or a site-wide baseline — so each
12253
+ // exists to hold that feature to the same standard as everything shipped by
12254
+ // default. See `docs/detection/correlation.md`.
12255
+ // ---------------------------------------------------------------------------
12256
+ bot({
12257
+ id: "marker-held-while-identity-changes",
12258
+ title: "One client presenting a marker it was issued as Chrome, then as curl",
12259
+ audience: "unwanted-bot",
12260
+ category: "evasion",
12261
+ provenance: "Identity rotation, which is invisible without a marker. Correlating by address cannot tell this apart from two people sharing an office connection, so the library declined to guess. A signed cookie removes the ambiguity: both requests carried an HMAC only this server can produce.",
12262
+ requires: ["marker-probe"],
12263
+ requests: [
12264
+ ...repeat({ ...browser("chromeWindows"), ip: "198.51.100.81", headers: [...browser("chromeWindows").headers, ["Cookie", "sid=held"]] }, 4, 1500, (i) => `/products/${i}`),
12265
+ ...repeat({ ...plain(CURL_UA), ip: "198.51.100.81", headers: [...plain(CURL_UA).headers, ["Cookie", "sid=held"]] }, 4, 1500, (i) => `/products/${i + 4}`).map((request) => ({ ...request, atMs: (request.atMs ?? 0) + 6e3 }))
12266
+ ],
12267
+ expect: { verdict: "confirmed-bot", detectors: ["identity-drift"] },
12268
+ notes: "The browser family carries the weight and the platform does not, because a phone with `Request desktop site` changes its platform and is a person. Software does not change what it is."
12269
+ }),
12270
+ bot({
12271
+ id: "marker-never-stored-though-cookies-sent",
12272
+ title: "A client replaying a captured session cookie and storing nothing new",
12273
+ audience: "unwanted-bot",
12274
+ category: "scraping",
12275
+ provenance: "A scraper handed a session header to copy. It sends the one cookie it was configured with on every request and never stores anything the server sets, which a browser with a jar does not do.",
12276
+ requires: ["marker-probe"],
12277
+ keepsCookies: false,
12278
+ requests: repeat(
12279
+ { ...browser("chromeWindows"), ip: "198.51.100.82", headers: [...browser("chromeWindows").headers, ["Cookie", "sid=captured-elsewhere"]] },
12280
+ 9,
12281
+ 1200,
12282
+ (i) => `/products/${i}`
12283
+ ),
12284
+ expect: { verdict: "unknown", detectors: ["marker-persistence"] },
12285
+ notes: "Deliberately narrower than `session-integrity`, which already reports a client sending no cookie at all. Overlapping them double-counted one observation and the population it landed on was people who block cookies."
12286
+ }),
12287
+ bot({
12288
+ id: "marker-edited-by-its-holder",
12289
+ title: "A client that edited the signed cookie it was given",
12290
+ audience: "unwanted-bot",
12291
+ category: "evasion",
12292
+ provenance: "Browsers do not edit their own cookies. A marker failing its HMAC was altered by whoever held it, and the only reason to alter an opaque signed value is to see what the server does with a different one.",
12293
+ requires: ["marker-probe"],
12294
+ keepsCookies: false,
12295
+ requests: repeat(
12296
+ { ...browser("chromeWindows"), ip: "198.51.100.83", headers: [...browser("chromeWindows").headers, ["Cookie", "__bh_m=eyJ2IjoxfQ.not-a-signature-this-server-made"]] },
12297
+ 4,
12298
+ 1500,
12299
+ (i) => `/account/${i}`
12300
+ ),
12301
+ expect: { verdict: "unknown", detectors: ["marker-integrity"] },
12302
+ notes: "Stops at `strong` rather than `certain` because a middlebox or a broken cookie jar can mangle a value in transit. That is rare, it is not the client's fault, and it should cost a challenge rather than a door."
12303
+ }),
12304
+ bot({
12305
+ id: "marker-carried-across-a-proxy-pool",
12306
+ title: "One marker presented from twenty different networks",
12307
+ audience: "unwanted-bot",
12308
+ category: "scraping",
12309
+ provenance: "A scraper on a rotating proxy pool that keeps its cookie jar, which most of them do because discarding it breaks the sites they are taking. The marker comes back only from the client that received it, so this is one client across twenty networks.",
12310
+ requires: ["marker-probe"],
12311
+ requests: Array.from({ length: 20 }, (_, index) => ({
12312
+ ...browser("chromeWindows"),
12313
+ headers: [...browser("chromeWindows").headers, ["Cookie", "sid=pooled"]],
12314
+ ip: `198.51.${140 + index}.9`,
12315
+ path: `/catalogue/${index}`,
12316
+ atMs: index * 2500
12317
+ })),
12318
+ expect: { verdict: "unknown", detectors: ["marker-fanout"] },
12319
+ notes: "Capped at `moderate` and offered no higher: a phone on a carrier using CGNAT can be renumbered across a great many /24s in the twelve hours a marker lives, and so can anyone whose employer egresses through a rotating pool."
12320
+ }),
12321
+ bot({
12322
+ id: "range-walked-across-many-clients",
12323
+ title: "An id range divided between ten clients so none of them walks enough to notice",
12324
+ audience: "unwanted-bot",
12325
+ category: "scraping",
12326
+ provenance: "The threat every per-actor threshold misses by construction. Split a range across enough addresses and each one is unremarkable, `id-enumeration` fires for nobody, and the range is still walked end to end. It is only visible in the union.",
12327
+ requires: ["site-baseline"],
12328
+ requests: Array.from({ length: 200 }, (_, index) => ({
12329
+ ...browser("chromeWindows"),
12330
+ ip: `198.51.${170 + index % 10}.5`,
12331
+ path: `/user/${index + 1}`,
12332
+ atMs: index * 900
12333
+ })),
12334
+ expect: { verdict: "unknown", detectors: ["distributed-walk"] },
12335
+ notes: "Coverage and the revisit ratio must both agree. Many clients on numbered pages is what a catalogue is; what a catalogue also has, and an enumeration does not, is people returning to the same popular items."
12336
+ }),
12337
+ bot({
12338
+ id: "fresh-path-wanted-by-everybody",
12339
+ title: "A path this site never served, requested at once by twenty unrelated clients",
12340
+ audience: "unwanted-bot",
12341
+ category: "recon",
12342
+ provenance: "What a freshly disclosed vulnerability looks like from inside a site: a URL nobody had ever requested is requested by hundreds of unrelated clients within the hour, each making a single request and moving on.",
12343
+ requires: ["site-baseline"],
12344
+ requests: Array.from({ length: 20 }, (_, index) => ({
12345
+ ...plain(CURL_UA),
12346
+ ip: `198.51.${190 + index}.11`,
12347
+ path: "/vendor/proprietary-thing/rce.php",
12348
+ status: 404,
12349
+ atMs: index * 3e3
12350
+ })),
12351
+ expect: { verdict: "unknown", detectors: ["path-campaign"] },
12352
+ notes: "The miss rate is required rather than optional. Many clients arriving at once on a brand-new URL is also exactly what a successful launch looks like; what separates them is whether the site had anything to serve."
12353
+ }),
12354
+ bot({
12355
+ id: "missing-far-more-than-this-site-does",
12356
+ title: 'A client answered "not found" far more often than the site answers it at all',
12357
+ audience: "unwanted-bot",
12358
+ category: "recon",
12359
+ provenance: "A fixed miss threshold is wrong on both kinds of site: on one mid-migration it reports everybody, and on a tidy one it stays silent while a client misses a third of the time. The site's own rate is the only honest comparison.",
12360
+ requires: ["site-baseline"],
12361
+ requests: repeat({ ...plain(CURL_UA), ip: "198.51.210.12", status: 404 }, 26, 1100, (i) => `/backup-${i}.sql`),
12362
+ expect: { verdict: "unknown", detectors: ["miss-baseline"] },
12363
+ notes: "Shares the `misses` family with `probe-volume`, which reads the same misses against a fixed threshold. One cause, so the stronger reading stands rather than the two summing."
12364
+ }),
12365
+ bot({
12366
+ id: "solution-farm-replaying-answers",
12367
+ title: "A client answering challenges with solutions that have already been spent",
12368
+ audience: "unwanted-bot",
12369
+ category: "evasion",
12370
+ provenance: "What a solved-challenge farm looks like from the server. A challenge nonce is random, single-use and signed, so a second valid solution for one is the same answer sent twice or one answer handed around \u2014 neither of which a browser does. One replay is a retried POST on a flaky connection, which is why the threshold is not one.",
12371
+ challengeHistory: { replayedSolutions: 4, implausibleSolves: 2 },
12372
+ requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.71" }, 6, 1500, () => "/account"),
12373
+ expect: {
12374
+ // A shape worth reporting and not worth concluding from: the client is otherwise
12375
+ // indistinguishable from the browser whose headers it copied.
12376
+ verdict: "unknown",
12377
+ detectors: ["challenge-integrity"]
12378
+ },
12379
+ notes: "The proof-of-work floor is measured on the server between issuing and receiving, so no client clock is involved, and it is set at a SHA-256 rate no browser has ever reached. Both signals stay `moderate`: they say the answers did not come from the page we served, which is a fact about the answering software rather than proof about the traffic it is attached to."
12380
+ }),
12381
+ bot({
12382
+ id: "head-only-visit",
12383
+ title: "A visit made entirely of HEAD, claiming a browser",
12384
+ audience: "unwanted-bot",
12385
+ category: "scraping",
12386
+ 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.",
12387
+ requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.63", method: "HEAD" }, 30, 900, (index) => `/${["news", "about", "blog", "help", "terms"][index % 5]}`),
12388
+ expect: {
12389
+ // As above: a shape worth reporting, not worth concluding from alone.
12390
+ verdict: "unknown",
12391
+ detectors: ["transport-coherence"]
12392
+ },
12393
+ 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`."
12394
+ }),
12395
+ bot({
12396
+ id: "catalogue-sweep-by-page",
12397
+ title: "A catalogue taken a page at a time, with the path never changing",
12398
+ audience: "unwanted-bot",
12399
+ category: "scraping",
12400
+ 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.",
12401
+ requests: repeat({ ...browser("chromeWindows"), ip: "198.51.100.61" }, 40, 900, (index) => `/products?page=${index}`),
12402
+ expect: {
12403
+ // Not proven, and not even suspected at this pace. Said plainly because it is true:
12404
+ // headers this clean leave only behaviour, behaviour is weak by construction, and a
12405
+ // collector polite enough to space its requests stays under the line. What changed
12406
+ // is that it no longer scores *lower* than the identical crawl expressed as distinct
12407
+ // paths — measured at a faster pace before this detector existed, the two differed by
12408
+ // seven points and only the path version crossed; they now score the same at every
12409
+ // volume tried.
12410
+ verdict: "unknown",
12411
+ detectors: ["parameter-sweep"]
12412
+ },
12413
+ 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."
12414
+ }),
10371
12415
  // ---------------------------------------------------------------------------
10372
12416
  // Forged identities. The narrow case where a lie is provable.
10373
12417
  // ---------------------------------------------------------------------------
@@ -10710,6 +12754,38 @@ var init_adversarial = __esm({
10710
12754
  expect: { certain: false, detectors: ["probe-signature"], neverAction: ["block", "drop"] },
10711
12755
  tags: ["scanning"]
10712
12756
  }),
12757
+ bot({
12758
+ id: "traversal-encoded-past-a-filter",
12759
+ title: "A traversal with its dots and slashes written in percent-encoding",
12760
+ audience: "hostile",
12761
+ category: "wordlist-probe",
12762
+ provenance: "The standard first move against a path filter, and the reason this library keeps the raw target: normalisation resolves the dots, so what reaches a wordlist check is `/app/config.yml` \u2014 an ordinary-looking path nobody has, on no list. The spelling is the whole signal, and it is destroyed by the thing that makes rules work.",
12763
+ requests: [{ ...browser("chromeWindows"), path: "/%2e%2e%2f%2e%2e%2fapp/config.yml", status: 404 }],
12764
+ expect: { certain: false, detectors: ["target-integrity"], neverAction: ["block", "drop"] },
12765
+ notes: "`strong`, not proven. A path segment carrying a URL as data is encoded to sit in a path and encoded again by whatever built the link, which produces the same characters honestly \u2014 so this may score, and may not close a door on its own.",
12766
+ tags: ["scanning"]
12767
+ }),
12768
+ bot({
12769
+ id: "traversal-double-encoded",
12770
+ title: "A traversal encoded twice, so one round of decoding leaves it encoded",
12771
+ audience: "hostile",
12772
+ category: "wordlist-probe",
12773
+ provenance: "Aimed at a filter that decodes once and then inspects: after its single pass the target still reads `%2e%2e%2f`, which the filter does not recognise, and the server behind it decodes again.",
12774
+ requests: [{ ...browser("chromeWindows"), path: "/static/%252e%252e%252f%252e%252e%252fetc/passwd", status: 404 }],
12775
+ expect: { certain: false, detectors: ["target-integrity"], neverAction: ["block", "drop"] },
12776
+ tags: ["scanning"]
12777
+ }),
12778
+ bot({
12779
+ id: "absolute-form-proxy-probe",
12780
+ title: "A request target addressed to somewhere else entirely",
12781
+ audience: "hostile",
12782
+ category: "protocol-abuse",
12783
+ provenance: "Absolute-form is the request line a client sends to a *proxy*. Arriving at an origin server it is a question \u2014 will you fetch this for me \u2014 and open-proxy scanning asks it of everything with a port 80 open.",
12784
+ requests: [{ ...plain(CHROME_UA), path: "http://scanner.example/check", status: 404 }],
12785
+ expect: { certain: false, detectors: ["target-integrity"], neverAction: ["block", "drop"] },
12786
+ notes: "RFC 9112 \xA73.2.2 requires servers to accept absolute-form, so this is not malformed and is not proven. No browser has ever sent one to an origin server.",
12787
+ tags: ["scanning"]
12788
+ }),
10713
12789
  bot({
10714
12790
  id: "probe-trace-method",
10715
12791
  title: "A TRACE request",
@@ -12363,6 +14439,46 @@ var init_humans = __esm({
12363
14439
  ],
12364
14440
  expect: { verdict: "unknown", maxScore: 0, notDetectors: ["header-order"], action: "allow" }
12365
14441
  }),
14442
+ human({
14443
+ id: "request-desktop-site-mid-visit",
14444
+ title: "Somebody switching their phone to the desktop version of a site",
14445
+ category: "mangled-by-infrastructure",
14446
+ provenance: "`Request desktop site` rewrites the User-Agent to claim a Mac. The browser is the same Safari and the cookie jar is the same jar, so the marker comes back \u2014 which means the library can see, correctly, that one client has now described itself two different ways.",
14447
+ requires: ["marker-probe"],
14448
+ notes: "The reason `identity-drift` weighs a changed *browser family* at `strong` and a changed platform only at `moderate`. Software does not change what it is; a platform changes when a person taps a menu item, and this is that person.",
14449
+ requests: [
14450
+ ...humanPaced({ ...browser("safariIos"), ip: "203.0.115.24", headers: [...browser("safariIos").headers, ["Cookie", "sid=phone-session"]] }, [
14451
+ "/",
14452
+ "/collections/lamps",
14453
+ "/products/brass-desk-lamp",
14454
+ "/products/brass-desk-lamp/reviews"
14455
+ ]),
14456
+ // The same person, same session, having tapped "Request desktop site".
14457
+ ...humanPaced(
14458
+ { ...browser("safariMac"), ip: "203.0.115.24", headers: [...browser("safariMac").headers, ["Cookie", "sid=phone-session"]] },
14459
+ ["/products/brass-desk-lamp", "/delivery", "/products/brass-desk-lamp", "/basket"]
14460
+ ).map((request) => ({ ...request, atMs: (request.atMs ?? 0) + 47e3 }))
14461
+ ],
14462
+ expect: { certain: false, action: ["allow", "tag", "log", "delay", "challenge", "rate-limit"] },
14463
+ tags: ["known-cost"]
14464
+ }),
14465
+ human({
14466
+ id: "broken-link-shared-widely",
14467
+ title: "A crowd of people following one mistyped link",
14468
+ category: "mangled-by-infrastructure",
14469
+ provenance: "Somebody shares a URL with a typo in it and thousands of real people follow it within the hour. From the server this is a path the site has never served, requested by many unrelated clients, and answered `not found` to every one of them \u2014 which is the exact shape `path-campaign` reads.",
14470
+ requires: ["site-baseline"],
14471
+ notes: "The known cost of comparing a client with the rest of the traffic: most of the evidence is about what *other* people did, and a person following a bad link is indistinguishable here from one running a list. It is capped at `moderate` for this case specifically, and the guarantee it must keep is this one \u2014 reported, never refused.",
14472
+ requests: Array.from({ length: 16 }, (_, index) => ({
14473
+ ...browser("chromeWindows", { kind: "cross-site-navigate", referer: "https://social.example/" }),
14474
+ ip: `203.0.114.${index + 1}`,
14475
+ path: "/blog/anouncing-our-new-thing",
14476
+ status: 404,
14477
+ atMs: index * 4e3
14478
+ })),
14479
+ expect: { certain: false, action: ["allow", "tag", "log", "delay", "challenge", "rate-limit"] },
14480
+ tags: ["known-cost"]
14481
+ }),
12366
14482
  human({
12367
14483
  id: "cgnat-shared-address",
12368
14484
  title: "Many people behind one carrier-grade NAT address",
@@ -13283,6 +15399,23 @@ function checkExpectations(item, result, assertActions) {
13283
15399
  }
13284
15400
  return failures;
13285
15401
  }
15402
+ function withExtraCookies(request, extra) {
15403
+ const headers = [];
15404
+ let merged = false;
15405
+ for (const [name, value] of request.headers) {
15406
+ if (!merged && name.toLowerCase() === "cookie") {
15407
+ headers.push([name, [value, ...extra].join("; ")]);
15408
+ merged = true;
15409
+ } else {
15410
+ headers.push([name, value]);
15411
+ }
15412
+ }
15413
+ if (!merged) headers.push(["Cookie", extra.join("; ")]);
15414
+ return { ...request, headers };
15415
+ }
15416
+ function keepsCookies(request) {
15417
+ return request.headers.some(([name]) => name.toLowerCase() === "cookie");
15418
+ }
13286
15419
  async function runCase(handler, clock, item, startedAt, provides, assertActions = true) {
13287
15420
  const missing = (item.requires ?? []).filter((capability) => !provides.has(capability));
13288
15421
  if (missing.length > 0) {
@@ -13305,11 +15438,24 @@ async function runCase(handler, clock, item, startedAt, provides, assertActions
13305
15438
  const seed = toFacts(item.requests[0], fallbackIp, clock.now());
13306
15439
  clearanceCookie = handler.grantClearance(seed, item.clearance)?.split(";")[0];
13307
15440
  }
15441
+ if (item.challengeHistory !== void 0) {
15442
+ clock.set(startedAt);
15443
+ const seed = toFacts(item.requests[0], fallbackIp, clock.now());
15444
+ const key = handler.actorKeyFor(seed);
15445
+ const state = handler.registry.observe(key, seed);
15446
+ for (let i = 0; i < (item.challengeHistory.replayedSolutions ?? 0); i++) state.noteChallengeAnomaly("replay");
15447
+ for (let i = 0; i < (item.challengeHistory.implausibleSolves ?? 0); i++) state.noteChallengeAnomaly("implausible-speed");
15448
+ }
15449
+ let issuedCookies;
13308
15450
  for (const request of item.requests) {
13309
15451
  clock.set(startedAt + (request.atMs ?? 0));
13310
- const withClearance = clearanceCookie === void 0 ? request : { ...request, headers: [...request.headers, ["Cookie", clearanceCookie]] };
13311
- const facts = toFacts(withClearance, fallbackIp, clock.now());
15452
+ const extraCookies = [clearanceCookie, item.keepsCookies ?? keepsCookies(request) ? issuedCookies : void 0].filter((value) => value !== void 0);
15453
+ const withCookies = extraCookies.length === 0 ? request : withExtraCookies(request, extraCookies);
15454
+ const facts = toFacts(withCookies, fallbackIp, clock.now());
13312
15455
  const { assessment, decision, outcome } = await handler.handle(facts);
15456
+ const setCookie = outcome.kind === "continue" ? outcome.responseHeaders?.["set-cookie"] : outcome.kind === "respond" ? outcome.headers["set-cookie"] : void 0;
15457
+ if (setCookie !== void 0) issuedCookies = setCookie.split(";")[0];
15458
+ handler.recordOutcome(facts, request.status ?? 200);
13313
15459
  requests.push({ assessment, decision, outcome });
13314
15460
  }
13315
15461
  const final = requests[requests.length - 1];
@@ -14004,14 +16150,21 @@ function robots(flags) {
14004
16150
  }
14005
16151
  function detectors(flags) {
14006
16152
  const preset = flags.get("preset");
14007
- const handler = new BotHandler(preset !== void 0 && preset in PRESETS ? { preset } : {});
14008
- for (const entry of handler.describeDetectors()) {
16153
+ if (preset !== void 0 && !(preset in PRESETS)) {
16154
+ process.stderr.write(`Unknown preset "${preset}". One of: ${Object.keys(PRESETS).join(", ")}
16155
+ `);
16156
+ return 1;
16157
+ }
16158
+ const handler = new BotHandler(preset !== void 0 ? { preset } : {});
16159
+ const installed = handler.describeDetectors();
16160
+ for (const entry of installed) {
14009
16161
  process.stdout.write(`${entry.id.padEnd(24)} ${entry.cost.padEnd(6)} ${entry.stage.padEnd(11)} ${entry.description}
14010
16162
  `);
14011
16163
  }
14012
16164
  process.stdout.write(`
14013
- ${handler.describeDetectors().length} detectors installed.
16165
+ ${installed.length} detectors installed.
14014
16166
  `);
16167
+ process.stderr.write("note: a preset selects rules, not detectors. `challenge`, `probe` and `site` are what add to this list.\n");
14015
16168
  return 0;
14016
16169
  }
14017
16170
  var COMMON_LOG = /^(\S+) \S+ \S+ \[([^\]]+)\] "(\S+) (\S+)(?: (HTTP\/[\d.]+))?" (\d{3}) (\S+)(?: "([^"]*)" "([^"]*)")?/;