@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/index.js CHANGED
@@ -113,6 +113,45 @@ var init_lru = __esm({
113
113
  }
114
114
  });
115
115
 
116
+ // src/internal/text.ts
117
+ function safeSummary(text) {
118
+ let flawed = text.length > SUMMARY_CHARS;
119
+ if (!flawed) {
120
+ for (let i = 0; i < text.length; i++) {
121
+ const code = text.charCodeAt(i);
122
+ if (code < 32 || code >= 127 && code <= 159 || code >= 55296 && code <= 57343) {
123
+ flawed = true;
124
+ break;
125
+ }
126
+ }
127
+ }
128
+ if (!flawed) return text;
129
+ let out = "";
130
+ const limit = Math.min(text.length, SUMMARY_CHARS);
131
+ for (let i = 0; i < limit; i++) {
132
+ const code = text.charCodeAt(i);
133
+ if (code < 32 || code >= 127 && code <= 159) {
134
+ out += "\uFFFD";
135
+ } else if (code >= 55296 && code <= 56319) {
136
+ const next = text.charCodeAt(i + 1);
137
+ if (next >= 56320 && next <= 57343) {
138
+ out += text[i] + text[i + 1];
139
+ i++;
140
+ } else out += "\uFFFD";
141
+ } else if (code >= 56320 && code <= 57343) {
142
+ out += "\uFFFD";
143
+ } else out += text[i];
144
+ }
145
+ return text.length > SUMMARY_CHARS ? `${out}\u2026` : out;
146
+ }
147
+ var SUMMARY_CHARS;
148
+ var init_text = __esm({
149
+ "src/internal/text.ts"() {
150
+ "use strict";
151
+ SUMMARY_CHARS = 512;
152
+ }
153
+ });
154
+
116
155
  // src/state.ts
117
156
  function hashString(value) {
118
157
  let hash = 2166136261;
@@ -122,16 +161,50 @@ function hashString(value) {
122
161
  }
123
162
  return hash >>> 0;
124
163
  }
125
- var TIMESTAMP_RING, PATH_CAP, UA_CAP, MAX_TRACKED_ARRIVALS, MAX_TRACKED_PATHS, MAX_TRACKED_USER_AGENTS, ActorState, ActorRegistry;
164
+ function walkStepOf(path) {
165
+ if (path.length > MAX_WALK_PATH_CHARS) return void 0;
166
+ let depth = 0;
167
+ for (let i = 0; i < path.length; i++) {
168
+ if (path.charCodeAt(i) === 47 && ++depth > MAX_WALK_SEGMENTS) return void 0;
169
+ }
170
+ let value;
171
+ const parts = [];
172
+ for (const segment of path.split("/")) {
173
+ if (segment === "") continue;
174
+ if (DIGITS.test(segment)) {
175
+ const parsed = Number(segment);
176
+ if (Number.isSafeInteger(parsed) && parsed <= 1e7) value = parsed;
177
+ parts.push("#");
178
+ } else {
179
+ parts.push(segment);
180
+ }
181
+ }
182
+ if (value === void 0) return void 0;
183
+ let template = `/${parts.join("/")}`;
184
+ if (template.length > TEMPLATE_CHARS) template = `${template.slice(0, TEMPLATE_CHARS)}\u2026`;
185
+ return { template, id: value };
186
+ }
187
+ 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, MAX_TRACKED_USER_AGENTS, ActorState, ActorRegistry;
126
188
  var init_state = __esm({
127
189
  "src/state.ts"() {
128
190
  "use strict";
129
191
  init_lru();
192
+ init_text();
130
193
  TIMESTAMP_RING = 32;
131
194
  PATH_CAP = 64;
195
+ QUERY_CAP = 64;
196
+ METHOD_CAP = 12;
197
+ WALK_CAP = 4;
198
+ IDENTITY_CAP = 12;
199
+ TEMPLATE_CHARS = 120;
200
+ MAX_WALK_SEGMENTS = 24;
201
+ MAX_WALK_PATH_CHARS = 512;
202
+ MAX_QUERY_KEYS = 24;
203
+ DIGITS = /^\d+$/;
132
204
  UA_CAP = 4;
133
205
  MAX_TRACKED_ARRIVALS = TIMESTAMP_RING;
134
206
  MAX_TRACKED_PATHS = PATH_CAP;
207
+ MAX_TRACKED_QUERIES = QUERY_CAP;
135
208
  MAX_TRACKED_USER_AGENTS = UA_CAP;
136
209
  ActorState = class {
137
210
  key;
@@ -173,6 +246,96 @@ var init_state = __esm({
173
246
  paths = /* @__PURE__ */ new Set();
174
247
  pathsOverflowed = false;
175
248
  pathsSaturatedAtTotal = 0;
249
+ /**
250
+ * Distinct *parameterised* requests: the path together with its query.
251
+ *
252
+ * Counted apart from `paths` because the two answer different questions and a scraper
253
+ * lives in the gap between them. `/products?page=1` through `?page=200` is one path and
254
+ * two hundred requests, so breadth reads it as somebody rereading a single page — which
255
+ * is exactly what enumerating a catalogue looks like from the path alone.
256
+ */
257
+ queries = /* @__PURE__ */ new Set();
258
+ queriesOverflowed = false;
259
+ /**
260
+ * Which HTTP methods this actor has used.
261
+ *
262
+ * A browser navigating issues GET. Something that has issued nothing but HEAD across a
263
+ * long visit is checking what exists rather than reading it, and that is a fact about
264
+ * the actor rather than about any one of its requests — which is why it is kept here.
265
+ */
266
+ /**
267
+ * What has happened with this actor's marker cookie.
268
+ *
269
+ * Counted rather than listed: the useful questions are all "how often", and a list of
270
+ * marker ids would grow with a client's cookie jar for no benefit. The three drift
271
+ * flags are sticky — once a client has been seen claiming two different browsers under
272
+ * one marker it has done so, and a later request that looks tidy again does not undo
273
+ * it. That is the point of correlating a series rather than judging a request.
274
+ */
275
+ /**
276
+ * When this actor was last challenged, and how it described itself at that moment.
277
+ *
278
+ * Kept so that what a client does *in response* to being challenged can be read. That
279
+ * reaction is better evidence than anything observed passively, because the stimulus
280
+ * was ours: we chose the moment, so a change of identity that follows it within
281
+ * seconds is a reaction to it rather than a coincidence we went looking for.
282
+ */
283
+ /**
284
+ * Answers to challenges that were valid in form but wrong in a way only the series
285
+ * shows: a solution already spent, or one returned faster than the puzzle allows.
286
+ */
287
+ /** Requests for a path no other client had ever asked this site for. */
288
+ novelPaths = 0;
289
+ replayedSolutions = 0;
290
+ implausibleSolves = 0;
291
+ challengedAt = 0;
292
+ challengeShape;
293
+ markerIssues = 0;
294
+ markerReturns = 0;
295
+ markerForgeries = 0;
296
+ driftSeen = { browser: false, platform: false, language: false };
297
+ driftEvents = 0;
298
+ methods = /* @__PURE__ */ new Set();
299
+ /**
300
+ * Numeric walks in progress, by path shape: `/user/#` against the ids requested under it.
301
+ *
302
+ * Three numbers per shape, deliberately — a count, a lowest and a highest — rather than
303
+ * the ids themselves. What separates enumeration from reading is not which ids were
304
+ * asked for but whether they *cover a range*: thirty requests spanning thirty
305
+ * consecutive ids is a walk, and thirty scattered across a hundred thousand is somebody
306
+ * following links. Both are answerable from a count and a span, and only the count and
307
+ * the span survive an actor asking for ten thousand of them.
308
+ */
309
+ walks = /* @__PURE__ */ new Map();
310
+ /**
311
+ * Every named identity this actor has claimed, and what kind each was.
312
+ *
313
+ * Kept because the interesting question is not what one request said but what the *set*
314
+ * of them says. One address claiming sqlmap and nikto is a scan; one claiming Googlebot
315
+ * and Bingbot is a forgery, since at most one of those can be true of an address. Neither
316
+ * observation exists inside a single request.
317
+ */
318
+ identities = /* @__PURE__ */ new Map();
319
+ /**
320
+ * A name somebody gave this actor.
321
+ *
322
+ * Nothing in detection reads it. It exists because an address is not a memory: the
323
+ * person who worked out that `198.51.100.4` is the partner's price feed should be able
324
+ * to write that down where the next person will see it, rather than in a ticket.
325
+ */
326
+ actorLabel;
327
+ /** Requests from this actor that carried a scanner payload or target. */
328
+ probePayloads = 0;
329
+ /**
330
+ * What the application answered, for the requests anybody bothered to tell us about.
331
+ *
332
+ * The engine decides *before* the response exists, so this arrives afterwards and only
333
+ * when the adapter reports it. Kept as two counters rather than a list because the one
334
+ * question worth asking is a ratio: an actor whose requests are almost all misses is
335
+ * looking for something rather than reading anything.
336
+ */
337
+ responsesSeen = 0;
338
+ missesSeen = 0;
176
339
  userAgents = /* @__PURE__ */ new Set();
177
340
  constructor(key, now) {
178
341
  this.key = key;
@@ -192,6 +355,16 @@ var init_state = __esm({
192
355
  this.pathsOverflowed = true;
193
356
  this.pathsSaturatedAtTotal = this.total;
194
357
  }
358
+ const keys = Object.keys(facts.query);
359
+ if (keys.length > 0 && keys.length <= MAX_QUERY_KEYS) {
360
+ keys.sort();
361
+ const signature = `${facts.path}?${keys.map((key) => `${key}=${facts.query[key] ?? ""}`).join("&")}`;
362
+ const queryHash = hashString(signature);
363
+ if (this.queries.size < QUERY_CAP) this.queries.add(queryHash);
364
+ else if (!this.queries.has(queryHash)) this.queriesOverflowed = true;
365
+ }
366
+ if (this.methods.size < METHOD_CAP) this.methods.add(facts.method);
367
+ this.noteWalk(facts.path);
195
368
  const ua = facts.headers["user-agent"];
196
369
  if (ua !== void 0 && this.userAgents.size < UA_CAP) this.userAgents.add(ua);
197
370
  }
@@ -202,6 +375,145 @@ var init_state = __esm({
202
375
  get pathsSaturated() {
203
376
  return this.pathsOverflowed;
204
377
  }
378
+ /** Distinct path-and-query combinations seen. Saturates at {@link QUERY_CAP}. */
379
+ get distinctQueries() {
380
+ return this.queries.size;
381
+ }
382
+ get queriesSaturated() {
383
+ return this.queriesOverflowed;
384
+ }
385
+ /**
386
+ * Records what the application answered. Called after the response, if at all.
387
+ *
388
+ * 404 and 410 only. A 403 is usually this library's own doing and counting it would
389
+ * make the detector that reads this argue with itself; a 500 is the site's problem and
390
+ * says nothing about the client.
391
+ */
392
+ recordOutcome(status) {
393
+ this.responsesSeen++;
394
+ if (status === 404 || status === 410) this.missesSeen++;
395
+ }
396
+ /** Responses reported for this actor. Zero unless something is reporting them. */
397
+ get responses() {
398
+ return this.responsesSeen;
399
+ }
400
+ /** Of those, how many were 404 or 410. */
401
+ get misses() {
402
+ return this.missesSeen;
403
+ }
404
+ /** Names this actor, or clears the name when given nothing. Trimmed and bounded. */
405
+ setLabel(label) {
406
+ const trimmed = label === void 0 ? void 0 : safeSummary(label).trim().slice(0, 120);
407
+ this.actorLabel = trimmed === void 0 || trimmed === "" ? void 0 : trimmed;
408
+ }
409
+ get label() {
410
+ return this.actorLabel;
411
+ }
412
+ /** Records a named identity this actor claimed. Called once per matching signature. */
413
+ noteIdentity(id, category, verifiable) {
414
+ if (this.identities.has(id) || this.identities.size >= IDENTITY_CAP) return;
415
+ this.identities.set(id, { category, verifiable });
416
+ }
417
+ /** Records that this request was for a path the site had never served to anybody. */
418
+ noteNovelPath() {
419
+ if (this.novelPaths < 1e6) this.novelPaths++;
420
+ }
421
+ /** How many of this actor's requests were for a path nobody else had ever asked for. */
422
+ get novelPathCount() {
423
+ return this.novelPaths;
424
+ }
425
+ /** Records something wrong with a submitted solution that only its history reveals. */
426
+ noteChallengeAnomaly(kind) {
427
+ if (kind === "replay") {
428
+ if (this.replayedSolutions < 1e6) this.replayedSolutions++;
429
+ } else if (this.implausibleSolves < 1e6) this.implausibleSolves++;
430
+ }
431
+ /** Solutions this actor submitted that had already been spent, and ones returned too fast. */
432
+ get challengeAnomalies() {
433
+ return { replays: this.replayedSolutions, implausible: this.implausibleSolves };
434
+ }
435
+ /** Records that a challenge went out, and the identity claimed as it did. */
436
+ noteChallengeIssued(at, shape) {
437
+ this.challengedAt = at;
438
+ this.challengeShape = shape;
439
+ }
440
+ /** The moment of the last challenge, and the identity claimed then. `at` is 0 for none. */
441
+ get lastChallenge() {
442
+ return { at: this.challengedAt, shape: this.challengeShape };
443
+ }
444
+ /** Records that a marker was handed to this actor on the way out. */
445
+ noteMarkerIssued() {
446
+ if (this.markerIssues < 1e6) this.markerIssues++;
447
+ }
448
+ /** Records what this request's marker cookie turned out to be. */
449
+ noteMarker(returned, forged, drift) {
450
+ if (returned && this.markerReturns < 1e6) this.markerReturns++;
451
+ if (forged && this.markerForgeries < 1e6) this.markerForgeries++;
452
+ if (drift === void 0) return;
453
+ if (drift.browser || drift.platform || drift.language) {
454
+ if (this.driftEvents < 1e6) this.driftEvents++;
455
+ }
456
+ this.driftSeen.browser ||= drift.browser;
457
+ this.driftSeen.platform ||= drift.platform;
458
+ this.driftSeen.language ||= drift.language;
459
+ }
460
+ /** Markers handed to this actor, and how many came back. */
461
+ get markers() {
462
+ return { issued: this.markerIssues, returned: this.markerReturns, forged: this.markerForgeries };
463
+ }
464
+ /** Which parts of a claimed identity have ever changed under one marker. */
465
+ get identityDrift() {
466
+ return { ...this.driftSeen, events: this.driftEvents };
467
+ }
468
+ /** Records that this request carried a scanner payload, so later requests can know. */
469
+ notePayloadProbe() {
470
+ this.probePayloads++;
471
+ }
472
+ /** Every identity claimed so far, by id. */
473
+ get claimedIdentities() {
474
+ return this.identities;
475
+ }
476
+ /** How many of this actor's requests carried a scanner payload or target. */
477
+ get payloadProbes() {
478
+ return this.probePayloads;
479
+ }
480
+ /**
481
+ * Files a request under the shape of its path, if that path carries a number.
482
+ *
483
+ * The last numeric segment is the one taken to be the identifier: in `/api/v2/orders/42`
484
+ * the version is part of the shape and the order id is what is being walked.
485
+ */
486
+ noteWalk(path) {
487
+ const step = walkStepOf(path);
488
+ if (step === void 0) return;
489
+ const { template, id: value } = step;
490
+ const existing = this.walks.get(template);
491
+ if (existing !== void 0) {
492
+ existing.count++;
493
+ if (value < existing.min) existing.min = value;
494
+ if (value > existing.max) existing.max = value;
495
+ return;
496
+ }
497
+ if (this.walks.size < WALK_CAP) this.walks.set(template, { count: 1, min: value, max: value });
498
+ }
499
+ /**
500
+ * The path shape this actor has walked hardest, with how far it reached.
501
+ *
502
+ * `span` is inclusive of both ends, so a walk of 1 to 30 spans 30. Comparing the count
503
+ * against it is what separates covering a range from visiting a few points in one.
504
+ */
505
+ densestWalk() {
506
+ let best;
507
+ for (const [template, walk] of this.walks) {
508
+ const span = walk.max - walk.min + 1;
509
+ if (best === void 0 || walk.count > best.count) best = { template, count: walk.count, span };
510
+ }
511
+ return best;
512
+ }
513
+ /** Every HTTP method this actor has used, in first-seen order. */
514
+ get methodsSeen() {
515
+ return [...this.methods];
516
+ }
205
517
  /**
206
518
  * Requests seen when {@link distinctPaths} stopped being able to grow, or 0 if it
207
519
  * still can. Over that many requests the distinct count is exact, so it is the only
@@ -290,6 +602,13 @@ var init_state = __esm({
290
602
  key: this.key,
291
603
  requests: this.total,
292
604
  distinctPaths: this.distinctPaths,
605
+ distinctQueries: this.distinctQueries,
606
+ methodsSeen: this.methodsSeen,
607
+ ...this.actorLabel === void 0 ? {} : { label: this.actorLabel },
608
+ walk: this.densestWalk(),
609
+ responses: this.responses,
610
+ misses: this.misses,
611
+ queriesSaturated: this.queriesSaturated,
293
612
  firstSeen: this.firstSeen,
294
613
  lastSeen: this.lastSeen,
295
614
  sinceLastMs: this.sinceLast(now),
@@ -418,6 +737,58 @@ var init_crypto = __esm({
418
737
  }
419
738
  });
420
739
 
740
+ // src/challenge/token.ts
741
+ function issueToken(payload, secrets) {
742
+ const secret = secrets[0];
743
+ if (secret === void 0) throw new Error("At least one signing secret is required to issue a token");
744
+ const body = base64UrlEncode(JSON.stringify(payload));
745
+ return `${body}.${sign(body, secret)}`;
746
+ }
747
+ function verifyToken(token, secrets, now, expectedSubject) {
748
+ if (token.length === 0 || token.length > MAX_TOKEN_LENGTH) return { ok: false, reason: "malformed" };
749
+ const separator = token.lastIndexOf(".");
750
+ if (separator <= 0) return { ok: false, reason: "malformed" };
751
+ const body = token.slice(0, separator);
752
+ const signature = token.slice(separator + 1);
753
+ let valid = false;
754
+ for (const secret of secrets) {
755
+ if (constantTimeEqual(signature, sign(body, secret))) valid = true;
756
+ }
757
+ if (!valid) return { ok: false, reason: "bad-signature" };
758
+ let payload;
759
+ try {
760
+ const decoded = base64UrlDecode(body).toString("utf8");
761
+ payload = JSON.parse(decoded);
762
+ } catch {
763
+ return { ok: false, reason: "malformed" };
764
+ }
765
+ if (typeof payload !== "object" || payload === null) return { ok: false, reason: "malformed" };
766
+ if (typeof payload.exp !== "number" || typeof payload.sub !== "string") return { ok: false, reason: "malformed" };
767
+ if (payload.exp <= now) return { ok: false, reason: "expired" };
768
+ if (expectedSubject !== void 0) {
769
+ let bound = false;
770
+ for (const candidate of typeof expectedSubject === "string" ? [expectedSubject] : expectedSubject) {
771
+ if (constantTimeEqual(payload.sub, candidate)) bound = true;
772
+ }
773
+ if (!bound) return { ok: false, reason: "wrong-actor" };
774
+ }
775
+ return { ok: true, payload };
776
+ }
777
+ function newChallenge(subject, difficulty, ttlMs, now) {
778
+ return { v: 1, sub: subject, iat: now, exp: now + ttlMs, nonce: randomId(12), diff: difficulty };
779
+ }
780
+ function newClearance(subject, level, ttlMs, now) {
781
+ return { v: 1, sub: subject, iat: now, exp: now + ttlMs, jti: randomId(9), lvl: level };
782
+ }
783
+ var MAX_TOKEN_LENGTH;
784
+ var init_token = __esm({
785
+ "src/challenge/token.ts"() {
786
+ "use strict";
787
+ init_crypto();
788
+ MAX_TOKEN_LENGTH = 2048;
789
+ }
790
+ });
791
+
421
792
  // src/internal/http.ts
422
793
  function parseCookies(header) {
423
794
  const cookies = /* @__PURE__ */ Object.create(null);
@@ -532,6 +903,11 @@ function toPrometheus(snapshot, options = {}) {
532
903
  counter("downgrades_total", "Terminal actions the safety guard replaced for lack of proof.", [["", snapshot.downgrades]]);
533
904
  counter("proven_total", "Assessments resting on proven evidence.", [["", snapshot.proven]]);
534
905
  counter("detector_firings_total", "Evidence produced, by detector.", Object.entries(snapshot.detectorFirings).map(([detector, value]) => [`{detector="${escapeLabel(detector)}"}`, value]));
906
+ const shadowFirings = Object.entries(snapshot.shadowFirings);
907
+ if (shadowFirings.length > 0) {
908
+ counter("shadow_firings_total", "Evidence produced by shadowed detectors, which decided nothing.", shadowFirings.map(([detector, value]) => [`{detector="${escapeLabel(detector)}"}`, value]));
909
+ 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]));
910
+ }
535
911
  counter("detector_failures_total", "Detector errors and timeouts.", Object.entries(snapshot.detectorFailures).map(([detector, value]) => [`{detector="${escapeLabel(detector)}"}`, value]));
536
912
  const timings = Object.entries(snapshot.detectorTimings);
537
913
  if (timings.length > 0) {
@@ -599,6 +975,8 @@ var init_metrics = __esm({
599
975
  detectorFirings = /* @__PURE__ */ new Map();
600
976
  detectorFailures = /* @__PURE__ */ new Map();
601
977
  detectorTimings = /* @__PURE__ */ new Map();
978
+ shadowFirings = /* @__PURE__ */ new Map();
979
+ shadowChanges = zeroed(VERDICTS);
602
980
  challengesIssued = 0;
603
981
  challengesSolved = 0;
604
982
  challengesRejected = 0;
@@ -632,6 +1010,10 @@ var init_metrics = __esm({
632
1010
  }
633
1011
  for (const item of assessment.evidence) bump(this.detectorFirings, item.detector);
634
1012
  for (const item of assessment.humanEvidence) bump(this.detectorFirings, item.detector);
1013
+ for (const item of assessment.shadowEvidence) bump(this.shadowFirings, item.detector);
1014
+ if (assessment.shadowVerdict !== void 0 && assessment.shadowVerdict.verdict !== assessment.verdict) {
1015
+ this.shadowChanges[assessment.shadowVerdict.verdict]++;
1016
+ }
635
1017
  for (const failure of assessment.failures) bump(this.detectorFailures, failure.detector);
636
1018
  const ms = assessment.durationMs;
637
1019
  this.durationCount++;
@@ -698,6 +1080,8 @@ var init_metrics = __esm({
698
1080
  detectorFirings: Object.fromEntries(this.detectorFirings),
699
1081
  detectorFailures: Object.fromEntries(this.detectorFailures),
700
1082
  detectorTimings: Object.fromEntries([...this.detectorTimings].map(([id, timing]) => [id, { ...timing }])),
1083
+ shadowFirings: Object.fromEntries(this.shadowFirings),
1084
+ shadowChanges: { ...this.shadowChanges },
701
1085
  challenges: { issued: this.challengesIssued, solved: this.challengesSolved, rejected: this.challengesRejected },
702
1086
  clearances: Object.fromEntries(this.clearances),
703
1087
  challengeRejections: Object.fromEntries(this.challengeRejections),
@@ -722,8 +1106,21 @@ __export(ip_exports, {
722
1106
  networkKey: () => networkKey,
723
1107
  normalizeIp: () => normalizeIp,
724
1108
  parseCidr: () => parseCidr,
725
- parseIp: () => parseIp
1109
+ parseIp: () => parseIp,
1110
+ stripPort: () => stripPort
726
1111
  });
1112
+ function stripPort(value) {
1113
+ const input = value.trim();
1114
+ if (input.startsWith("[")) {
1115
+ const close = input.indexOf("]");
1116
+ if (close > 0) return input.slice(1, close);
1117
+ return input;
1118
+ }
1119
+ const colon = input.indexOf(":");
1120
+ if (colon === -1 || input.indexOf(":", colon + 1) !== -1) return input;
1121
+ const host = input.slice(0, colon);
1122
+ return parseIpv4(host) !== null ? host : input;
1123
+ }
727
1124
  function parseIp(value) {
728
1125
  const input = value.trim();
729
1126
  if (input.length === 0 || input.length > 45) return null;
@@ -736,11 +1133,11 @@ function parseIpv4(value) {
736
1133
  if (parts.length !== 4) return null;
737
1134
  const bytes = new Uint8Array(4);
738
1135
  for (let i = 0; i < 4; i++) {
739
- const part = parts[i];
740
- if (part.length === 0 || part.length > 3) return null;
741
- if (!/^\d+$/.test(part)) return null;
742
- if (part.length > 1 && part[0] === "0") return null;
743
- const n = Number(part);
1136
+ const part2 = parts[i];
1137
+ if (part2.length === 0 || part2.length > 3) return null;
1138
+ if (!/^\d+$/.test(part2)) return null;
1139
+ if (part2.length > 1 && part2[0] === "0") return null;
1140
+ const n = Number(part2);
744
1141
  if (n > 255) return null;
745
1142
  bytes[i] = n;
746
1143
  }
@@ -1007,9 +1404,13 @@ function redactAssessment(assessment, options) {
1007
1404
  removed,
1008
1405
  assessment: {
1009
1406
  ...assessment,
1407
+ ...assessment.marker === void 0 ? {} : { marker: reduceMarker(assessment.marker, options) },
1010
1408
  actor: options.maskIp ? { ...assessment.actor, key: maskActorKey(assessment.actor.key) } : assessment.actor,
1011
1409
  evidence: scrubEvidence(assessment.evidence, removed),
1012
1410
  humanEvidence: scrubEvidence(assessment.humanEvidence, removed),
1411
+ // Scrubbed on the same terms: a shadowed detector reads the same request as every
1412
+ // other one, so its summary can quote the same secret out of it.
1413
+ shadowEvidence: scrubEvidence(assessment.shadowEvidence, removed),
1013
1414
  facts: {
1014
1415
  ...assessment.facts,
1015
1416
  ip: options.maskIp ? maskIpValue(assessment.facts.ip) : assessment.facts.ip,
@@ -1061,6 +1462,15 @@ function maskActorKey(key) {
1061
1462
  if (separator === -1) return networkKey(key);
1062
1463
  return `${networkKey(key.slice(0, separator))}|${key.slice(separator + 1)}`;
1063
1464
  }
1465
+ function reduceMarker(marker, options) {
1466
+ return {
1467
+ ...marker,
1468
+ reading: { kind: marker.reading.kind },
1469
+ // The shape is three coarse parts of the User-Agent. If the User-Agent itself is
1470
+ // being withheld, the parts of it must go too, or the setting only half applies.
1471
+ ...options.dropUserAgent === true ? { shape: { b: "", o: "", l: "" } } : {}
1472
+ };
1473
+ }
1064
1474
  var REDACTED, CREDENTIAL_HEADERS, ALWAYS_STRIP, MIN_SCRUB_LENGTH;
1065
1475
  var init_redact = __esm({
1066
1476
  "src/notify/redact.ts"() {
@@ -1683,37 +2093,50 @@ var init_dns = __esm({
1683
2093
  });
1684
2094
 
1685
2095
  // src/detectors/clearance.ts
1686
- function clearanceDetector(service) {
2096
+ function withShared(shared, primary) {
2097
+ return shared === void 0 ? primary : [primary, shared];
2098
+ }
2099
+ function clearanceDetector(service, sharingThreshold = DEFAULT_SHARING_THRESHOLD) {
1687
2100
  return {
1688
2101
  id: "clearance",
1689
2102
  description: "Reads a signed clearance token proving the client previously passed a check",
1690
2103
  cost: "cheap",
1691
2104
  stage: "always",
1692
2105
  inspect(ctx) {
1693
- const claims = service.read(ctx.actor.key, ctx.facts.cookies);
1694
- if (!claims) return void 0;
2106
+ const { claims, boundElsewhere, presentedBy } = service.inspect(ctx.actor.key, ctx.facts.cookies);
2107
+ const shared = presentedBy >= sharingThreshold ? {
2108
+ detector: "clearance",
2109
+ summary: `The clearance token presented here has now been presented by ${presentedBy} different clients`,
2110
+ direction: "bot",
2111
+ certainty: "moderate",
2112
+ botClass: "scraper"
2113
+ } : void 0;
2114
+ if (!claims) {
2115
+ void boundElsewhere;
2116
+ return shared;
2117
+ }
1695
2118
  const ageMs = ctx.facts.timestamp - claims.iat;
1696
2119
  if (claims.lvl === "operator") {
1697
- return {
2120
+ return withShared(shared, {
1698
2121
  detector: "clearance",
1699
2122
  summary: "Client holds an operator-granted clearance token",
1700
2123
  direction: "human",
1701
2124
  certainty: "certain",
1702
2125
  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.",
1703
2126
  metadata: { level: claims.lvl, ageMs }
1704
- };
2127
+ });
1705
2128
  }
1706
2129
  if (claims.lvl === "interaction") {
1707
- return {
2130
+ return withShared(shared, {
1708
2131
  detector: "clearance",
1709
2132
  summary: "Client holds a clearance token granted after a trusted input event",
1710
2133
  direction: "human",
1711
2134
  certainty: "strong",
1712
2135
  weight: 0.7,
1713
2136
  metadata: { level: claims.lvl, ageMs }
1714
- };
2137
+ });
1715
2138
  }
1716
- return {
2139
+ return withShared(shared, {
1717
2140
  detector: "clearance",
1718
2141
  summary: "Client holds a clearance token granted for a completed proof of work",
1719
2142
  direction: "human",
@@ -1724,13 +2147,389 @@ function clearanceDetector(service) {
1724
2147
  ageMs,
1725
2148
  note: "Proof of work demonstrates a JavaScript engine and spent CPU. It does not demonstrate a person."
1726
2149
  }
1727
- };
2150
+ });
1728
2151
  }
1729
2152
  };
1730
2153
  }
2154
+ var DEFAULT_SHARING_THRESHOLD;
1731
2155
  var init_clearance = __esm({
1732
2156
  "src/detectors/clearance.ts"() {
1733
2157
  "use strict";
2158
+ DEFAULT_SHARING_THRESHOLD = 12;
2159
+ }
2160
+ });
2161
+
2162
+ // src/probe/marker.ts
2163
+ function identityShape(facts, ua) {
2164
+ const platform = facts.headers["sec-ch-ua-platform"]?.replace(/"/g, "").trim().toLowerCase();
2165
+ const language = facts.headers["accept-language"]?.split(",")[0]?.split("-")[0]?.trim().toLowerCase();
2166
+ return {
2167
+ // A client that names no browser is its own category, and an empty User-Agent must
2168
+ // not read as equal to every other empty one by accident — it reads as "none", which
2169
+ // is exactly what it is, and changing away from it is a real change.
2170
+ b: part(ua.browser ?? (ua.raw.length === 0 ? "none" : `t:${withoutVersions(ua.raw)}`)),
2171
+ o: part(ua.os ?? platform ?? "none"),
2172
+ l: part(language ?? "none")
2173
+ };
2174
+ }
2175
+ function part(value) {
2176
+ const trimmed = value.length > 40 ? value.slice(0, 40) : value;
2177
+ return trimmed.toLowerCase();
2178
+ }
2179
+ function withoutVersions(raw) {
2180
+ return raw.replace(VERSION_NUMBERS, "#");
2181
+ }
2182
+ function driftBetween(issued, now) {
2183
+ return { browser: issued.b !== now.b, platform: issued.o !== now.o, language: issued.l !== now.l };
2184
+ }
2185
+ function newMarker(shape, ttlMs, now) {
2186
+ return { v: 1, sub: randomId(9), iat: now, exp: now + ttlMs, ...shape };
2187
+ }
2188
+ function markerCookie(name, claims, secrets, options) {
2189
+ return serializeCookie(name, issueToken(claims, secrets), {
2190
+ maxAgeMs: claims.exp - claims.iat,
2191
+ sameSite: options.sameSite ?? "Lax",
2192
+ secure: options.secure ?? true,
2193
+ // Nothing in a page needs to read this, and a marker readable by script is one a
2194
+ // cross-site script can lift.
2195
+ httpOnly: true,
2196
+ ...options.domain === void 0 ? {} : { domain: options.domain }
2197
+ });
2198
+ }
2199
+ function readMarker(value, secrets, now) {
2200
+ if (value === void 0 || value.length === 0) return { kind: "absent" };
2201
+ if (!TOKEN_SHAPE.test(value)) return { kind: "absent" };
2202
+ const verified = verifyToken(value, secrets, now);
2203
+ if (verified.ok) return { kind: "valid", claims: verified.payload };
2204
+ return verified.reason === "expired" ? { kind: "expired" } : { kind: "forged" };
2205
+ }
2206
+ var VERSION_NUMBERS, TOKEN_SHAPE;
2207
+ var init_marker = __esm({
2208
+ "src/probe/marker.ts"() {
2209
+ "use strict";
2210
+ init_token();
2211
+ init_crypto();
2212
+ init_http();
2213
+ VERSION_NUMBERS = /\d+(?:[._]\d+)*/g;
2214
+ TOKEN_SHAPE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
2215
+ }
2216
+ });
2217
+
2218
+ // src/detectors/challenge-reaction.ts
2219
+ function challengeReactionDetector(options = {}) {
2220
+ const windowMs = options.windowMs ?? 6e4;
2221
+ const minUnsolved = options.minUnsolved ?? 4;
2222
+ return {
2223
+ id: "challenge-reaction",
2224
+ description: "Reads how a client responded to being challenged: a changed identity, or never answering at all",
2225
+ cost: "cheap",
2226
+ stage: "always",
2227
+ inspect(ctx) {
2228
+ const found = [];
2229
+ const { at, shape } = ctx.state.lastChallenge;
2230
+ const since = at === 0 ? Number.POSITIVE_INFINITY : ctx.facts.timestamp - at;
2231
+ if (shape !== void 0 && since >= 0 && since <= windowMs) {
2232
+ const now = ctx.marker?.shape ?? identityShape(ctx.facts, ctx.ua);
2233
+ if (now.b !== shape.b) {
2234
+ const proven = ctx.marker?.reading.kind === "valid";
2235
+ found.push({
2236
+ detector: "challenge-reaction",
2237
+ 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`,
2238
+ direction: "bot",
2239
+ certainty: proven ? "strong" : "moderate",
2240
+ botClass: "impersonator",
2241
+ // One cause with `identity-drift`: this client changed what it claims to be.
2242
+ // Both fire together whenever a challenge is what prompted the change.
2243
+ family: "identity-change"
2244
+ });
2245
+ }
2246
+ }
2247
+ if (ctx.state.unsolvedChallenges >= minUnsolved) {
2248
+ found.push({
2249
+ detector: "challenge-reaction",
2250
+ summary: `Challenged ${ctx.state.unsolvedChallenges} times and has never returned a solution`,
2251
+ direction: "bot",
2252
+ certainty: "moderate",
2253
+ botClass: "unknown"
2254
+ });
2255
+ }
2256
+ return found.length > 0 ? found : void 0;
2257
+ }
2258
+ };
2259
+ }
2260
+ var init_challenge_reaction = __esm({
2261
+ "src/detectors/challenge-reaction.ts"() {
2262
+ "use strict";
2263
+ init_marker();
2264
+ }
2265
+ });
2266
+
2267
+ // src/detectors/challenge-integrity.ts
2268
+ function challengeIntegrityDetector(options = {}) {
2269
+ const minReplays = options.minReplays ?? 3;
2270
+ const minImplausible = options.minImplausible ?? 1;
2271
+ return {
2272
+ id: "challenge-integrity",
2273
+ description: "Reports solutions that were replayed, or returned faster than the proof of work allows",
2274
+ cost: "cheap",
2275
+ stage: "always",
2276
+ inspect(ctx) {
2277
+ const { replays, implausible } = ctx.state.challengeAnomalies;
2278
+ const found = [];
2279
+ if (replays >= minReplays) {
2280
+ found.push({
2281
+ detector: "challenge-integrity",
2282
+ summary: `Submitted ${replays} solutions for challenges that had already been solved`,
2283
+ direction: "bot",
2284
+ certainty: "moderate",
2285
+ botClass: "unknown"
2286
+ });
2287
+ }
2288
+ if (implausible >= minImplausible) {
2289
+ found.push({
2290
+ detector: "challenge-integrity",
2291
+ 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",
2292
+ direction: "bot",
2293
+ certainty: "moderate",
2294
+ botClass: "automation"
2295
+ });
2296
+ }
2297
+ return found.length > 0 ? found : void 0;
2298
+ }
2299
+ };
2300
+ }
2301
+ var init_challenge_integrity = __esm({
2302
+ "src/detectors/challenge-integrity.ts"() {
2303
+ "use strict";
2304
+ }
2305
+ });
2306
+
2307
+ // src/detectors/site-baseline.ts
2308
+ function distributedWalkDetector(options = {}) {
2309
+ const minActors = options.minActors ?? 8;
2310
+ const minIds = options.minIds ?? 150;
2311
+ const minCoverage = options.minCoverage ?? 0.6;
2312
+ const maxRevisitRatio = options.maxRevisitRatio ?? 1.3;
2313
+ const minRevisitRatio = options.minRevisitRatio ?? 0.7;
2314
+ return {
2315
+ id: "distributed-walk",
2316
+ description: "Reports a numeric range being walked across many clients, none of which walks enough of it alone",
2317
+ cost: "cheap",
2318
+ stage: "always",
2319
+ inspect(ctx) {
2320
+ if (ctx.site === void 0 || !ctx.site.warm) return void 0;
2321
+ const step = walkStepOf(ctx.facts.path);
2322
+ if (step === void 0) return void 0;
2323
+ const spread = ctx.site.spreadOf(step.template);
2324
+ if (spread === void 0) return void 0;
2325
+ if (spread.actors < minActors || spread.ids < minIds) return void 0;
2326
+ if (spread.coverage < minCoverage) return void 0;
2327
+ const revisits = spread.visits / spread.ids;
2328
+ if (revisits > maxRevisitRatio || revisits < minRevisitRatio) return void 0;
2329
+ return {
2330
+ detector: "distributed-walk",
2331
+ 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`,
2332
+ direction: "bot",
2333
+ certainty: "moderate",
2334
+ botClass: "scraper"
2335
+ };
2336
+ }
2337
+ };
2338
+ }
2339
+ function pathNoveltyDetector(options = {}) {
2340
+ const minRequests = options.minRequests ?? 30;
2341
+ const minNovelShare = options.minNovelShare ?? 0.95;
2342
+ return {
2343
+ id: "path-novelty",
2344
+ description: "Reports a client whose requests are almost all for paths this site has never been asked for",
2345
+ cost: "cheap",
2346
+ stage: "always",
2347
+ inspect(ctx) {
2348
+ if (ctx.site === void 0 || !ctx.site.warm) return void 0;
2349
+ const total = ctx.state.total;
2350
+ if (total < minRequests) return void 0;
2351
+ const share = ctx.state.novelPathCount / total;
2352
+ if (share < minNovelShare) return void 0;
2353
+ return {
2354
+ detector: "path-novelty",
2355
+ summary: `${(share * 100).toFixed(0)}% of this client's ${total} requests were for paths no other client has ever asked this site for`,
2356
+ direction: "bot",
2357
+ certainty: "moderate",
2358
+ botClass: "scanner",
2359
+ // The same cause `probe-signature` names when a path is on a list it ships: this
2360
+ // client is walking a list rather than reading a site.
2361
+ family: "wordlist-probe"
2362
+ };
2363
+ }
2364
+ };
2365
+ }
2366
+ function missBaselineDetector(options = {}) {
2367
+ const minResponses = options.minResponses ?? 20;
2368
+ const minRatio = options.minRatio ?? 5;
2369
+ const floor = options.floor ?? 0.5;
2370
+ return {
2371
+ id: "miss-baseline",
2372
+ description: 'Compares how often a client is answered "not found" with how often this site answers that at all',
2373
+ cost: "cheap",
2374
+ stage: "always",
2375
+ inspect(ctx) {
2376
+ const siteRate = ctx.site?.missRate;
2377
+ if (siteRate === void 0) return void 0;
2378
+ const { responses, misses } = ctx.state;
2379
+ if (responses < minResponses) return void 0;
2380
+ const rate = misses / responses;
2381
+ if (rate < floor) return void 0;
2382
+ if (siteRate > 0 && rate / siteRate < minRatio) return void 0;
2383
+ return {
2384
+ detector: "miss-baseline",
2385
+ 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`,
2386
+ direction: "bot",
2387
+ certainty: "moderate",
2388
+ botClass: "scanner",
2389
+ // `probe-volume` reads the same misses against a fixed threshold. Two readings
2390
+ // of one cause, so the stronger stands and they do not sum.
2391
+ family: "misses"
2392
+ };
2393
+ }
2394
+ };
2395
+ }
2396
+ function pathCampaignDetector(options = {}) {
2397
+ const minClients = options.minClients ?? 12;
2398
+ const minMissShare = options.minMissShare ?? 0.9;
2399
+ const minAnswered = options.minAnswered ?? 10;
2400
+ return {
2401
+ id: "path-campaign",
2402
+ description: "Reports a path this site never served that many unrelated clients have suddenly begun requesting",
2403
+ cost: "cheap",
2404
+ stage: "always",
2405
+ inspect(ctx) {
2406
+ const surge = ctx.site?.surgeOf(ctx.facts.path);
2407
+ if (surge === void 0) return void 0;
2408
+ if (surge.clients < minClients || surge.answered < minAnswered) return void 0;
2409
+ if (surge.misses / surge.answered < minMissShare) return void 0;
2410
+ return {
2411
+ detector: "path-campaign",
2412
+ 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`,
2413
+ direction: "bot",
2414
+ certainty: "moderate",
2415
+ botClass: "scanner"
2416
+ };
2417
+ }
2418
+ };
2419
+ }
2420
+ var init_site_baseline = __esm({
2421
+ "src/detectors/site-baseline.ts"() {
2422
+ "use strict";
2423
+ init_state();
2424
+ }
2425
+ });
2426
+
2427
+ // src/detectors/marker.ts
2428
+ function identityDriftDetector(options = {}) {
2429
+ const reportSoft = options.reportSoftDrift ?? true;
2430
+ return {
2431
+ id: "identity-drift",
2432
+ description: "Compares the identity a client claims now with the one it claimed when it was given its marker",
2433
+ cost: "cheap",
2434
+ stage: "always",
2435
+ inspect(ctx) {
2436
+ const drift = ctx.marker?.drift;
2437
+ if (drift === void 0) return void 0;
2438
+ if (drift.browser) {
2439
+ return {
2440
+ detector: "identity-drift",
2441
+ summary: "Client is holding a marker this server issued to a different browser, so one of the two identities it has claimed is false",
2442
+ direction: "bot",
2443
+ certainty: "strong",
2444
+ botClass: "impersonator"
2445
+ };
2446
+ }
2447
+ if (!reportSoft || !(drift.platform || drift.language)) return void 0;
2448
+ const what = drift.platform && drift.language ? "platform and language" : drift.platform ? "platform" : "language";
2449
+ return {
2450
+ detector: "identity-drift",
2451
+ // Named precisely, because the operator reading this needs to know it is the
2452
+ // soft case: a person switching to the desktop site produces exactly this.
2453
+ summary: `Client's claimed ${what} changed while holding one marker, which a person can also do deliberately`,
2454
+ direction: "bot",
2455
+ certainty: "moderate",
2456
+ botClass: "unknown"
2457
+ };
2458
+ }
2459
+ };
2460
+ }
2461
+ function markerIntegrityDetector(options = {}) {
2462
+ const minForgeries = options.minForgeries ?? 1;
2463
+ return {
2464
+ id: "marker-integrity",
2465
+ description: "Reports a marker cookie presented with a signature this server could not have produced",
2466
+ cost: "cheap",
2467
+ stage: "always",
2468
+ inspect(ctx) {
2469
+ if (ctx.marker?.reading.kind !== "forged") return void 0;
2470
+ const { forged } = ctx.state.markers;
2471
+ if (forged < minForgeries) return void 0;
2472
+ return {
2473
+ detector: "marker-integrity",
2474
+ summary: forged > 1 ? `Presented a marker cookie this server never signed, ${forged} times` : "Presented a marker cookie this server never signed",
2475
+ direction: "bot",
2476
+ certainty: "strong",
2477
+ botClass: "scanner"
2478
+ };
2479
+ }
2480
+ };
2481
+ }
2482
+ function markerPersistenceDetector(options = {}) {
2483
+ const minIssued = options.minIssued ?? 5;
2484
+ return {
2485
+ id: "marker-persistence",
2486
+ description: "Reports a client that has been handed a marker repeatedly and has never returned one",
2487
+ cost: "cheap",
2488
+ stage: "always",
2489
+ inspect(ctx) {
2490
+ if (ctx.marker === void 0) return void 0;
2491
+ if (ctx.facts.headers["cookie"] === void 0) return void 0;
2492
+ const { issued, returned } = ctx.state.markers;
2493
+ if (returned > 0 || issued < minIssued) return void 0;
2494
+ return {
2495
+ detector: "marker-persistence",
2496
+ summary: `Sends cookies but has never returned the one this server set, across ${issued} responses that offered it`,
2497
+ direction: "bot",
2498
+ certainty: "moderate",
2499
+ botClass: "http-client",
2500
+ // The same cause `session-integrity` reports when it sees no cookie at all: one
2501
+ // client that does not keep state. Without this they are two moderate signals
2502
+ // for one observation, and the population that produces it is people who block
2503
+ // cookies — so the double count landed squarely on them. Measured on the corpus:
2504
+ // it took `cookies-blocked` from 21 to 38 before the family was named.
2505
+ family: "no-session"
2506
+ };
2507
+ }
2508
+ };
2509
+ }
2510
+ function markerFanoutDetector(options = {}) {
2511
+ const minNetworks = options.minNetworks ?? 16;
2512
+ return {
2513
+ id: "marker-fanout",
2514
+ description: "Counts the distinct networks one marker cookie has been presented from",
2515
+ cost: "cheap",
2516
+ stage: "always",
2517
+ inspect(ctx) {
2518
+ const networks = ctx.marker?.networks ?? 0;
2519
+ if (networks < minNetworks) return void 0;
2520
+ return {
2521
+ detector: "marker-fanout",
2522
+ summary: `One client has presented the same marker from ${networks} different networks`,
2523
+ direction: "bot",
2524
+ certainty: "moderate",
2525
+ botClass: "scraper"
2526
+ };
2527
+ }
2528
+ };
2529
+ }
2530
+ var init_marker2 = __esm({
2531
+ "src/detectors/marker.ts"() {
2532
+ "use strict";
1734
2533
  }
1735
2534
  });
1736
2535
 
@@ -1883,7 +2682,7 @@ function compileSignatures(signatures = BOT_SIGNATURES) {
1883
2682
  function indexSignatures(signatures = BOT_SIGNATURES) {
1884
2683
  return new Map(signatures.map((signature) => [signature.id, signature]));
1885
2684
  }
1886
- var BOT_CATEGORIES, SEARCH, AI, SEO, SOCIAL, MONITORING, ARCHIVE, FEED, SECURITY, LIBRARY, HEADLESS, EMBEDDED, ADVERTISING, COMMERCE, ACADEMIC, ACCESSIBILITY, BOT_SIGNATURES, BENIGN_CATEGORIES;
2685
+ var BOT_CATEGORIES, SEARCH, AI, SEO, SOCIAL, MONITORING, ARCHIVE, FEED, SECURITY, LIBRARY, HEADLESS, EMBEDDED, ADVERTISING, COMMERCE, ACADEMIC, EMAIL_SECURITY, ACCESSIBILITY, BOT_SIGNATURES, BENIGN_CATEGORIES;
1887
2686
  var init_known_bots = __esm({
1888
2687
  "src/detectors/known-bots.ts"() {
1889
2688
  "use strict";
@@ -1904,6 +2703,7 @@ var init_known_bots = __esm({
1904
2703
  "commerce",
1905
2704
  "accessibility",
1906
2705
  "academic",
2706
+ "email-security",
1907
2707
  "other"
1908
2708
  ];
1909
2709
  SEARCH = [
@@ -1928,6 +2728,13 @@ var init_known_bots = __esm({
1928
2728
  { id: "yisouspider", name: "Shenma (Yisou) Spider", tokens: ["yisouspider"], category: "search", benign: true, robotsAgent: "YisouSpider", verification: { kind: "none" } },
1929
2729
  { id: "yahoo-slurp", name: "Yahoo! Slurp", tokens: ["yahoo! slurp"], category: "search", benign: true, robotsAgent: "Slurp", verification: { kind: "fcrdns", domains: ["crawl.yahoo.net", "yahoo.com"] } },
1930
2730
  { id: "mail-ru", name: "Mail.Ru bot", tokens: ["mail.ru_bot"], category: "search", benign: true, verification: { kind: "none" } },
2731
+ { id: "brave-search", name: "Brave Search", tokens: ["bravesearchbot"], category: "search", benign: true, robotsAgent: "BraveSearchBot", verification: { kind: "none" } },
2732
+ { id: "ecosia", name: "Ecosia", tokens: ["ecosiabot"], category: "search", benign: true, verification: { kind: "none" } },
2733
+ { id: "startpage", name: "Startpage", tokens: ["startpagebot"], category: "search", benign: true, verification: { kind: "none" } },
2734
+ { id: "daum", name: "Daumoa", tokens: ["daumoa"], category: "search", benign: true, verification: { kind: "none" } },
2735
+ { id: "stract", name: "Stract", tokens: ["stractbot"], category: "search", benign: true, verification: { kind: "none" } },
2736
+ { id: "rightdao", name: "RightDao", tokens: ["rightdaobot"], category: "search", benign: true, verification: { kind: "none" } },
2737
+ { id: "gigablast", name: "Gigablast", tokens: ["gigablastopensource"], category: "search", benign: true, verification: { kind: "none" } },
1931
2738
  { id: "exabot", name: "Exabot (Exalead)", tokens: ["exabot"], category: "search", benign: true, verification: { kind: "none" } },
1932
2739
  { id: "kagibot", name: "Kagi", tokens: ["kagibot"], category: "search", benign: true, robotsAgent: "KagiBot", verification: { kind: "none" } }
1933
2740
  ];
@@ -1998,11 +2805,15 @@ var init_known_bots = __esm({
1998
2805
  { id: "pinterestbot", name: "Pinterestbot", tokens: ["pinterest/", "pinterestbot"], category: "social", benign: true, verification: { kind: "fcrdns", domains: ["pinterest.com"] } },
1999
2806
  { id: "mastodon", name: "Mastodon / Fediverse", tokens: ["mastodon/", "pleroma", "misskey/", "akkoma"], category: "social", benign: true, verification: { kind: "none" } },
2000
2807
  { id: "embedly", name: "Embedly", tokens: ["embedly"], category: "social", benign: true, verification: { kind: "none" } },
2001
- { id: "bluesky", name: "Bluesky card fetcher", tokens: ["bluesky cardyb", "cardyb/"], category: "social", benign: true, verification: { kind: "none" } },
2808
+ { id: "bluesky", name: "Bluesky card fetcher", tokens: ["bluesky cardyb", "cardyb/", "blueskybot"], category: "social", benign: true, verification: { kind: "none" } },
2002
2809
  { id: "iframely", name: "Iframely", tokens: ["iframely"], category: "social", benign: true, verification: { kind: "none" } },
2003
2810
  { id: "skype-preview", name: "Skype URI preview", tokens: ["skypeuripreview"], category: "social", benign: true, verification: { kind: "none" } },
2004
2811
  { id: "vk-share", name: "VK / Odnoklassniki preview", tokens: ["vkshare", "odklbot"], category: "social", benign: true, verification: { kind: "none" } },
2005
2812
  { id: "discourse-onebox", name: "Discourse Onebox", tokens: ["discourse forum onebox"], category: "social", benign: true, verification: { kind: "none" } },
2813
+ { id: "microsoft-preview", name: "Microsoft Teams preview", tokens: ["microsoftpreview"], category: "social", benign: true, verification: { kind: "none" } },
2814
+ { id: "zoom-preview", name: "Zoom link preview", tokens: ["zoombot"], category: "social", benign: true, verification: { kind: "none" } },
2815
+ { id: "signal-preview", name: "Signal link preview", tokens: ["signalbot"], category: "social", benign: true, verification: { kind: "none" } },
2816
+ { id: "matrix-synapse", name: "Matrix (Synapse) preview", tokens: ["synapse/"], category: "social", benign: true, verification: { kind: "none" } },
2006
2817
  { id: "yahoo-preview", name: "Yahoo Link Preview", tokens: ["yahoo link preview"], category: "social", benign: true, verification: { kind: "none" } }
2007
2818
  ];
2008
2819
  MONITORING = [
@@ -2042,6 +2853,10 @@ var init_known_bots = __esm({
2042
2853
  { id: "smartnews", name: "SmartNews", tokens: ["smartnewsbot"], category: "feed", benign: true, verification: { kind: "none" } },
2043
2854
  { id: "flipboard", name: "Flipboard", tokens: ["flipboardproxy"], category: "feed", benign: true, verification: { kind: "none" } },
2044
2855
  { id: "podcast-index", name: "Podcast Index", tokens: ["podcastindexbot"], category: "feed", benign: true, verification: { kind: "none" } },
2856
+ // Spotify's podcast fetcher sends `Spotify/1.0` — and so does the Spotify desktop app,
2857
+ // with a person driving it. There is no token that separates them, so this one is left
2858
+ // unnamed rather than named wrongly: the corpus proved the point immediately by blocking
2859
+ // a human under `protect-auth`, `indexers-only` and `under-attack` at once.
2045
2860
  { id: "freshrss", name: "FreshRSS", tokens: ["freshrss"], category: "feed", benign: true, verification: { kind: "none" } },
2046
2861
  { id: "netnewswire", name: "NetNewsWire", tokens: ["netnewswire"], category: "feed", benign: true, verification: { kind: "none" } },
2047
2862
  { id: "overcast", name: "Overcast", tokens: ["overcast/"], category: "feed", benign: true, verification: { kind: "none" } },
@@ -2168,7 +2983,23 @@ var init_known_bots = __esm({
2168
2983
  ];
2169
2984
  ADVERTISING = [
2170
2985
  { id: "adsbot-google", name: "AdsBot-Google", tokens: ["adsbot-google", "mediapartners-google", "adsbot"], category: "advertising", benign: true, verification: { kind: "fcrdns", domains: ["googlebot.com", "google.com"] } },
2171
- { id: "criteo", name: "Criteo", tokens: ["criteobot"], category: "advertising", benign: true, verification: { kind: "none" } }
2986
+ { id: "criteo", name: "Criteo", tokens: ["criteobot"], category: "advertising", benign: true, verification: { kind: "none" } },
2987
+ // Verification and contextual classification: they read a page to decide whether an ad
2988
+ // may appear beside it, or what the page is about. A publisher usually wants these and a
2989
+ // site with no advertising has no reason to.
2990
+ { id: "doubleverify", name: "DoubleVerify", tokens: ["doubleverifybot"], category: "advertising", benign: true, verification: { kind: "none" } },
2991
+ { id: "ias", name: "Integral Ad Science", tokens: ["ias crawler"], category: "advertising", benign: true, verification: { kind: "none" } },
2992
+ { id: "moat", name: "Moat", tokens: ["moatbot"], category: "advertising", benign: true, verification: { kind: "none" } },
2993
+ { id: "comscore", name: "comScore (Proximic)", tokens: ["proximic"], category: "advertising", benign: true, verification: { kind: "none" } },
2994
+ { id: "grapeshot", name: "Grapeshot", tokens: ["grapeshotcrawler"], category: "advertising", benign: true, verification: { kind: "none" } },
2995
+ { id: "peer39", name: "Peer39", tokens: ["peer39bot"], category: "advertising", benign: true, verification: { kind: "none" } },
2996
+ { id: "taboola", name: "Taboola", tokens: ["taboolabot"], category: "advertising", benign: true, verification: { kind: "none" } },
2997
+ { id: "outbrain", name: "Outbrain", tokens: ["outbrainbot"], category: "advertising", benign: true, verification: { kind: "none" } },
2998
+ { id: "pubmatic", name: "PubMatic", tokens: ["pubmaticbot"], category: "advertising", benign: true, verification: { kind: "none" } },
2999
+ { id: "thetradedesk", name: "The Trade Desk", tokens: ["ttd-content"], category: "advertising", benign: true, verification: { kind: "none" } },
3000
+ // Competitive ad intelligence rather than verification: it collects what everyone else
3001
+ // is running. Named, and left for the operator to decide about.
3002
+ { id: "adbeat", name: "Adbeat", tokens: ["adbeat_bot"], category: "advertising", benign: false, verification: { kind: "none" } }
2172
3003
  ];
2173
3004
  COMMERCE = [
2174
3005
  { id: "idealo", name: "idealo", tokens: ["idealo-bot"], category: "commerce", benign: false, verification: { kind: "none" } },
@@ -2184,6 +3015,12 @@ var init_known_bots = __esm({
2184
3015
  { id: "openalex", name: "OpenAlex", tokens: ["openalexbot"], category: "academic", benign: true, verification: { kind: "none" } },
2185
3016
  { id: "webis", name: "Webis research crawler", tokens: ["webisbot"], category: "academic", benign: true, verification: { kind: "none" } }
2186
3017
  ];
3018
+ EMAIL_SECURITY = [
3019
+ { id: "proofpoint", name: "Proofpoint URL Defense", tokens: ["proofpointurldefensebot"], category: "email-security", benign: true, verification: { kind: "none" } },
3020
+ { id: "mimecast", name: "Mimecast URL Protect", tokens: ["mimecasturlprotectbot"], category: "email-security", benign: true, verification: { kind: "none" } },
3021
+ { id: "barracuda", name: "Barracuda Link Protect", tokens: ["barracudalinkprotectbot"], category: "email-security", benign: true, verification: { kind: "none" } },
3022
+ { id: "cisco-esa", name: "Cisco Secure Email", tokens: ["ciscosecureemailbot"], category: "email-security", benign: true, verification: { kind: "none" } }
3023
+ ];
2187
3024
  ACCESSIBILITY = [
2188
3025
  { id: "siteimprove", name: "Siteimprove", tokens: ["siteimprovebot"], category: "accessibility", benign: true, robotsAgent: "SiteimproveBot", verification: { kind: "none" } }
2189
3026
  ];
@@ -2202,7 +3039,8 @@ var init_known_bots = __esm({
2202
3039
  ...EMBEDDED,
2203
3040
  ...COMMERCE,
2204
3041
  ...ACADEMIC,
2205
- ...ACCESSIBILITY
3042
+ ...ACCESSIBILITY,
3043
+ ...EMAIL_SECURITY
2206
3044
  ]);
2207
3045
  BENIGN_CATEGORIES = /* @__PURE__ */ new Set(["search", "social", "monitoring", "feed", "archive", "advertising"]);
2208
3046
  }
@@ -2779,10 +3617,241 @@ function crawlBreadthDetector(options = {}) {
2779
3617
  }
2780
3618
  };
2781
3619
  }
2782
- var init_crawl_breadth = __esm({
2783
- "src/detectors/crawl-breadth.ts"() {
3620
+ var init_crawl_breadth = __esm({
3621
+ "src/detectors/crawl-breadth.ts"() {
3622
+ "use strict";
3623
+ init_state();
3624
+ }
3625
+ });
3626
+
3627
+ // src/detectors/parameter-sweep.ts
3628
+ function parameterSweepDetector(options = {}) {
3629
+ const threshold = options.threshold ?? 25;
3630
+ const variantsPerPath = options.variantsPerPath ?? 8;
3631
+ const minRequests = options.minRequests ?? 20;
3632
+ if (threshold > MAX_TRACKED_QUERIES) {
3633
+ throw new RangeError(
3634
+ `parameterSweepDetector threshold ${threshold} can never be reached: an actor's distinct-query count saturates at ${MAX_TRACKED_QUERIES}. Use ${MAX_TRACKED_QUERIES} or fewer.`
3635
+ );
3636
+ }
3637
+ return {
3638
+ id: "parameter-sweep",
3639
+ description: "Counts distinct query strings against the paths they sit on, to catch enumeration that leaves the path unchanged",
3640
+ cost: "cheap",
3641
+ stage: "always",
3642
+ inspect(ctx) {
3643
+ const { distinctPaths, distinctQueries, queriesSaturated, total } = ctx.state;
3644
+ if (total < minRequests || distinctQueries < threshold) return void 0;
3645
+ const spread = distinctQueries / Math.max(1, distinctPaths);
3646
+ if (spread < variantsPerPath) return void 0;
3647
+ return {
3648
+ detector: "parameter-sweep",
3649
+ 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`,
3650
+ direction: "bot",
3651
+ certainty: "weak",
3652
+ // Saturation means the count stopped being able to grow, so the real spread is
3653
+ // wider than the one reported — the same argument breadth makes for itself.
3654
+ weight: queriesSaturated ? 0.25 : 0.15,
3655
+ botClass: "scraper",
3656
+ metadata: {
3657
+ distinctQueries,
3658
+ distinctPaths,
3659
+ variantsPerPath: Number(spread.toFixed(1)),
3660
+ totalRequests: total,
3661
+ saturated: queriesSaturated
3662
+ }
3663
+ };
3664
+ }
3665
+ };
3666
+ }
3667
+ var init_parameter_sweep = __esm({
3668
+ "src/detectors/parameter-sweep.ts"() {
3669
+ "use strict";
3670
+ init_state();
3671
+ }
3672
+ });
3673
+
3674
+ // src/detectors/transport-coherence.ts
3675
+ function transportCoherenceDetector(options = {}) {
3676
+ const checkLegacyHttp = options.legacyHttp ?? true;
3677
+ const minHeadRequests = options.minHeadRequests ?? 8;
3678
+ return {
3679
+ id: "transport-coherence",
3680
+ description: "Reads the HTTP version and the methods across a visit against the client the request claims to be",
3681
+ cost: "cheap",
3682
+ stage: "always",
3683
+ inspect(ctx) {
3684
+ if (!claimsBrowser(ctx.ua)) return void 0;
3685
+ const results = [];
3686
+ const version = ctx.facts.httpVersion;
3687
+ if (checkLegacyHttp && version !== void 0 && LEGACY_VERSIONS.has(version)) {
3688
+ results.push({
3689
+ detector: "transport-coherence",
3690
+ summary: `Client claims to be a browser but negotiated HTTP/${version}, which no shipping browser has offered in over a decade`,
3691
+ direction: "bot",
3692
+ certainty: "moderate",
3693
+ botClass: "impersonator",
3694
+ // One downgrading proxy in front of the application does this to every request
3695
+ // that passes through it, so this must count once rather than once per reason.
3696
+ family: "legacy-transport",
3697
+ metadata: { httpVersion: version, browser: ctx.ua.browser }
3698
+ });
3699
+ }
3700
+ const methods = ctx.state.methodsSeen;
3701
+ if (ctx.state.total >= minHeadRequests && methods.length > 0 && methods.every((method) => method === "HEAD")) {
3702
+ results.push({
3703
+ detector: "transport-coherence",
3704
+ summary: `Client claims to be a browser but has issued nothing but HEAD across ${ctx.state.total} requests`,
3705
+ direction: "bot",
3706
+ certainty: "moderate",
3707
+ botClass: "scraper",
3708
+ metadata: { requests: ctx.state.total, browser: ctx.ua.browser }
3709
+ });
3710
+ }
3711
+ return results.length > 0 ? results : void 0;
3712
+ }
3713
+ };
3714
+ }
3715
+ var LEGACY_VERSIONS;
3716
+ var init_transport_coherence = __esm({
3717
+ "src/detectors/transport-coherence.ts"() {
3718
+ "use strict";
3719
+ init_ua();
3720
+ LEGACY_VERSIONS = /* @__PURE__ */ new Set(["0.9", "1.0"]);
3721
+ }
3722
+ });
3723
+
3724
+ // src/detectors/probe-volume.ts
3725
+ function probeVolumeDetector(options = {}) {
3726
+ const minResponses = options.minResponses ?? 20;
3727
+ const missRatio = options.missRatio ?? 0.8;
3728
+ return {
3729
+ id: "probe-volume",
3730
+ description: "Reads the share of an actor's requests that were answered 404 or 410, where the application reports them",
3731
+ cost: "cheap",
3732
+ stage: "always",
3733
+ inspect(ctx) {
3734
+ const { responses, misses } = ctx.state;
3735
+ if (responses < minResponses) return void 0;
3736
+ const ratio = misses / responses;
3737
+ if (ratio < missRatio) return void 0;
3738
+ return {
3739
+ detector: "probe-volume",
3740
+ summary: `${misses} of this client's last ${responses} requests were answered "not found" (${(ratio * 100).toFixed(0)}%)`,
3741
+ direction: "bot",
3742
+ certainty: "moderate",
3743
+ botClass: "scanner",
3744
+ // `miss-baseline` reads the same misses relative to the site's own rate. One
3745
+ // cause, so the stronger reading stands rather than the two summing.
3746
+ family: "misses",
3747
+ metadata: { responses, misses, missRatio: Number(ratio.toFixed(3)) }
3748
+ };
3749
+ }
3750
+ };
3751
+ }
3752
+ var init_probe_volume = __esm({
3753
+ "src/detectors/probe-volume.ts"() {
3754
+ "use strict";
3755
+ }
3756
+ });
3757
+
3758
+ // src/detectors/id-enumeration.ts
3759
+ function idEnumerationDetector(options = {}) {
3760
+ const minRequests = options.minRequests ?? 30;
3761
+ const density = options.density ?? 0.9;
3762
+ return {
3763
+ id: "id-enumeration",
3764
+ description: "Reports an actor covering a contiguous range of numeric identifiers under one path shape",
3765
+ cost: "cheap",
3766
+ stage: "always",
3767
+ inspect(ctx) {
3768
+ const walk = ctx.state.densestWalk();
3769
+ if (walk === void 0 || walk.count < minRequests) return void 0;
3770
+ if (walk.span < minRequests) return void 0;
3771
+ const covered = Math.min(1, walk.count / walk.span);
3772
+ if (covered < density) return void 0;
3773
+ return {
3774
+ detector: "id-enumeration",
3775
+ summary: `${walk.count} requests to ${walk.template} covering ${(covered * 100).toFixed(0)}% of a ${walk.span}-wide range of ids`,
3776
+ direction: "bot",
3777
+ certainty: "moderate",
3778
+ botClass: "scraper",
3779
+ metadata: { template: walk.template, requests: walk.count, span: walk.span, coverage: Number(covered.toFixed(3)) }
3780
+ };
3781
+ }
3782
+ };
3783
+ }
3784
+ var init_id_enumeration = __esm({
3785
+ "src/detectors/id-enumeration.ts"() {
3786
+ "use strict";
3787
+ }
3788
+ });
3789
+
3790
+ // src/detectors/blended-identity.ts
3791
+ function blendedIdentityDetector(options = {}) {
3792
+ const scannerFloor = options.scannerIdentities ?? 2;
3793
+ const crawlerFloor = options.crawlerIdentities ?? 2;
3794
+ return {
3795
+ id: "blended-identity",
3796
+ description: "Reads the set of identities one actor has claimed across requests for combinations that cannot all be true",
3797
+ cost: "cheap",
3798
+ stage: "always",
3799
+ inspect(ctx) {
3800
+ const claimed = ctx.state.claimedIdentities;
3801
+ if (claimed.size === 0) return void 0;
3802
+ const scanners = [];
3803
+ const crawlers = [];
3804
+ const benignCrawlers = [];
3805
+ for (const [id, what] of claimed) {
3806
+ if (what.category === "security") scanners.push(id);
3807
+ if (what.verifiable) crawlers.push(id);
3808
+ if (what.category === "search" || what.category === "ai" || what.category === "social") benignCrawlers.push(id);
3809
+ }
3810
+ const results = [];
3811
+ if (scanners.length >= scannerFloor) {
3812
+ results.push({
3813
+ detector: "blended-identity",
3814
+ summary: `One client has arrived as ${scanners.length} different security tools: ${scanners.join(", ")}`,
3815
+ direction: "bot",
3816
+ certainty: "strong",
3817
+ weight: 0.7,
3818
+ botClass: "scanner",
3819
+ metadata: { identities: scanners }
3820
+ });
3821
+ }
3822
+ if (crawlers.length >= crawlerFloor) {
3823
+ results.push({
3824
+ detector: "blended-identity",
3825
+ summary: `One client has claimed ${crawlers.length} crawler identities that publish address proofs: ${crawlers.join(", ")}`,
3826
+ direction: "bot",
3827
+ certainty: "strong",
3828
+ weight: 0.7,
3829
+ botClass: "impersonator",
3830
+ // Not `certain`, and the line is worth holding. Each operator publishes a proof
3831
+ // tied to addresses it controls, so at most one claim can be true — but a shared
3832
+ // egress in front of two genuinely different clients would produce the same set,
3833
+ // and this library refuses to deny anybody on an inference.
3834
+ metadata: { identities: crawlers }
3835
+ });
3836
+ }
3837
+ if (ctx.state.payloadProbes > 0 && benignCrawlers.length > 0) {
3838
+ results.push({
3839
+ detector: "blended-identity",
3840
+ summary: `Client claims to be ${benignCrawlers.join(", ")} and has sent ${ctx.state.payloadProbes} scanner payload(s)`,
3841
+ direction: "bot",
3842
+ certainty: "strong",
3843
+ weight: 0.75,
3844
+ botClass: "impersonator",
3845
+ metadata: { identities: benignCrawlers, payloadProbes: ctx.state.payloadProbes }
3846
+ });
3847
+ }
3848
+ return results.length > 0 ? results : void 0;
3849
+ }
3850
+ };
3851
+ }
3852
+ var init_blended_identity = __esm({
3853
+ "src/detectors/blended-identity.ts"() {
2784
3854
  "use strict";
2785
- init_state();
2786
3855
  }
2787
3856
  });
2788
3857
 
@@ -3366,14 +4435,16 @@ function findPayload(path, query) {
3366
4435
  for (const { pattern, what, tier } of PAYLOADS) {
3367
4436
  if (pattern.test(path)) return { what, where: "path", sample: path, tier };
3368
4437
  }
3369
- for (const [key, value] of Object.entries(query)) {
4438
+ for (const key in query) {
4439
+ const value = query[key];
4440
+ if (!PAYLOAD_GATE.test(value)) continue;
3370
4441
  for (const { pattern, what, tier } of PAYLOADS) {
3371
4442
  if (pattern.test(value)) return { what, where: `query parameter "${key.slice(0, 40)}"`, sample: value, tier };
3372
4443
  }
3373
4444
  }
3374
4445
  return void 0;
3375
4446
  }
3376
- var EXPLOIT_PATHS, EXPLOIT_SUFFIXES, PLATFORM_PATHS, PAYLOADS, INJECTION_PUNCTUATION, PROBE_METHODS;
4447
+ var EXPLOIT_PATHS, EXPLOIT_SUFFIXES, PLATFORM_PATHS, PAYLOADS, PAYLOAD_GATE, INJECTION_PUNCTUATION, PROBE_METHODS;
3377
4448
  var init_probe_signature = __esm({
3378
4449
  "src/detectors/probe-signature.ts"() {
3379
4450
  "use strict";
@@ -3447,11 +4518,74 @@ var init_probe_signature = __esm({
3447
4518
  { pattern: /<script[\s>]/i, what: "an inline script tag", tier: "markup" },
3448
4519
  { pattern: /\bon(?:error|load|mouseover)\s*=/i, what: "an inline event handler", tier: "markup" }
3449
4520
  ];
4521
+ PAYLOAD_GATE = /[$`:(<=\s]/;
3450
4522
  INJECTION_PUNCTUATION = /['"]|--\s|\/\*|;|%27|%22/;
3451
4523
  PROBE_METHODS = /* @__PURE__ */ new Set(["TRACE", "TRACK", "DEBUG", "CONNECT"]);
3452
4524
  }
3453
4525
  });
3454
4526
 
4527
+ // src/detectors/target-integrity.ts
4528
+ function targetIntegrityDetector(options = {}) {
4529
+ const reportPlain = options.reportPlainTraversal ?? true;
4530
+ return {
4531
+ id: "target-integrity",
4532
+ description: "Reports a request target spelled to get past something rather than to fetch something",
4533
+ cost: "cheap",
4534
+ stage: "always",
4535
+ inspect(ctx) {
4536
+ const raw = ctx.facts.rawPath;
4537
+ if (raw === void 0) return void 0;
4538
+ const findings = [];
4539
+ if (ABSOLUTE_FORM.test(raw)) {
4540
+ findings.push({ what: "asked this server to fetch a URL elsewhere, which is a request addressed to a proxy", certainty: "strong" });
4541
+ }
4542
+ if (DOUBLE_ENCODED.test(raw)) {
4543
+ findings.push({ what: "encoded its own encoding, so one round of decoding leaves it still encoded", certainty: "strong" });
4544
+ }
4545
+ if (ENCODED_CONTROL.test(raw)) {
4546
+ findings.push({ what: "carried a control character in the target", certainty: "strong" });
4547
+ }
4548
+ if (TRAVERSAL.test(raw)) {
4549
+ if (ENCODED_SEPARATOR.test(raw)) {
4550
+ findings.push({ what: "spelled the dots and slashes of a directory traversal in percent-encoding", certainty: "strong" });
4551
+ } else if (reportPlain) {
4552
+ findings.push({ what: "walked up out of the site root", certainty: "moderate" });
4553
+ }
4554
+ } else if (ENCODED_SLASH.test(raw)) {
4555
+ findings.push({ what: "hid a path separator inside a segment by encoding it", certainty: "moderate" });
4556
+ }
4557
+ if (findings.length === 0) return void 0;
4558
+ const certainty = findings.some((finding) => finding.certainty === "strong") ? "strong" : "moderate";
4559
+ const what = findings.map((finding) => finding.what);
4560
+ const listed = what.length === 1 ? what[0] : `${what.slice(0, -1).join(", ")}, and ${what[what.length - 1]}`;
4561
+ return {
4562
+ detector: "target-integrity",
4563
+ summary: `The request target ${listed}`,
4564
+ direction: "bot",
4565
+ certainty,
4566
+ botClass: "scanner",
4567
+ // One act, however many ways it shows. A traversal is usually encoded and an
4568
+ // encoded traversal is often double-encoded; compounding them would turn one
4569
+ // request into three independent reasons to be suspicious.
4570
+ family: "evasive-target",
4571
+ metadata: { target: raw.length > 200 ? `${raw.slice(0, 200)}\u2026` : raw }
4572
+ };
4573
+ }
4574
+ };
4575
+ }
4576
+ var ENCODED_SEPARATOR, ENCODED_SLASH, DOUBLE_ENCODED, ENCODED_CONTROL, TRAVERSAL, ABSOLUTE_FORM;
4577
+ var init_target_integrity = __esm({
4578
+ "src/detectors/target-integrity.ts"() {
4579
+ "use strict";
4580
+ ENCODED_SEPARATOR = /%2e|%2f|%5c/i;
4581
+ ENCODED_SLASH = /%2f|%5c/i;
4582
+ DOUBLE_ENCODED = /%25[0-9a-f]{2}/i;
4583
+ ENCODED_CONTROL = /%0[0-9a-f]|%1[0-9a-f]|%7f/i;
4584
+ TRAVERSAL = /\.\.|%2e%2e|%2e\.|\.%2e/i;
4585
+ ABSOLUTE_FORM = /^[a-z][a-z0-9+.-]*:\/\//i;
4586
+ }
4587
+ });
4588
+
3455
4589
  // src/detectors/rate-anomaly.ts
3456
4590
  function rateAnomalyDetector(options = {}) {
3457
4591
  const windowMs = options.windowMs ?? 1e4;
@@ -3733,7 +4867,10 @@ function submittedFields(extra) {
3733
4867
  const source = extra?.[TRAP_FIELD_SOURCE];
3734
4868
  return typeof source === "object" && source !== null ? source : void 0;
3735
4869
  }
3736
- function renderTrapLink(path, options = {}) {
4870
+ function renderTrapLink(path = DEFAULT_TRAP_PATHS[0], options = {}) {
4871
+ if (!path.startsWith("/")) {
4872
+ throw new TypeError(`A trap path must begin with "/" \u2014 it is matched against the request path. Received: ${JSON.stringify(path.slice(0, 60))}`);
4873
+ }
3737
4874
  const label = escapeHtml2(options.label ?? "Archive index");
3738
4875
  const href = escapeHtml2(path);
3739
4876
  return `<a href="${href}" rel="nofollow noindex" aria-hidden="true" tabindex="-1" style="position:absolute;left:-10000px;top:auto;width:1px;height:1px;overflow:hidden">${label}</a>`;
@@ -4154,20 +5291,26 @@ function defaultDetectors(options = {}) {
4154
5291
  // Identity first: a self-declaration or a verified crawler settles the question
4155
5292
  // outright, and the engine can then skip everything that would only add nuance.
4156
5293
  selfIdentifiedDetector(),
5294
+ blendedIdentityDetector(),
4157
5295
  trapDetector(),
4158
5296
  ipIntelligenceDetector(),
4159
5297
  probeSignatureDetector(),
5298
+ targetIntegrityDetector(),
4160
5299
  // Single-request consistency.
4161
5300
  headerIntegrityDetector(),
4162
5301
  uaCoherenceDetector(),
4163
5302
  clientHintsDetector(),
4164
5303
  fetchMetadataDetector(),
4165
5304
  acceptSignatureDetector(),
5305
+ transportCoherenceDetector(),
4166
5306
  headerOrderDetector(),
4167
5307
  // Behaviour across requests.
4168
5308
  rateAnomalyDetector(),
4169
5309
  cadenceDetector(),
4170
5310
  crawlBreadthDetector(),
5311
+ parameterSweepDetector(),
5312
+ probeVolumeDetector(),
5313
+ idEnumerationDetector(),
4171
5314
  sessionIntegrityDetector(),
4172
5315
  // The other side of the argument: what a real browsing session looks like.
4173
5316
  browsingCoherenceDetector(),
@@ -4183,12 +5326,18 @@ var init_detectors = __esm({
4183
5326
  init_cadence();
4184
5327
  init_client_hints();
4185
5328
  init_crawl_breadth();
5329
+ init_parameter_sweep();
5330
+ init_transport_coherence();
5331
+ init_probe_volume();
5332
+ init_id_enumeration();
5333
+ init_blended_identity();
4186
5334
  init_crawler_verification();
4187
5335
  init_fetch_metadata();
4188
5336
  init_header_integrity();
4189
5337
  init_header_order();
4190
5338
  init_ip_intelligence();
4191
5339
  init_probe_signature();
5340
+ init_target_integrity();
4192
5341
  init_rate_anomaly();
4193
5342
  init_self_identified();
4194
5343
  init_session_integrity();
@@ -4205,6 +5354,15 @@ var init_detectors = __esm({
4205
5354
  init_rate_anomaly();
4206
5355
  init_cadence();
4207
5356
  init_crawl_breadth();
5357
+ init_parameter_sweep();
5358
+ init_transport_coherence();
5359
+ init_probe_volume();
5360
+ init_id_enumeration();
5361
+ init_blended_identity();
5362
+ init_challenge_reaction();
5363
+ init_challenge_integrity();
5364
+ init_site_baseline();
5365
+ init_marker2();
4208
5366
  init_session_integrity();
4209
5367
  init_identity_rotation();
4210
5368
  init_trap();
@@ -4212,6 +5370,7 @@ var init_detectors = __esm({
4212
5370
  init_tls_fingerprint();
4213
5371
  init_clearance();
4214
5372
  init_ua_coherence();
5373
+ init_target_integrity();
4215
5374
  init_probe_signature();
4216
5375
  init_browsing_coherence();
4217
5376
  init_client_signals();
@@ -4466,6 +5625,7 @@ function resolveConfig(config = {}) {
4466
5625
  }
4467
5626
  seen.add(detector.id);
4468
5627
  }
5628
+ const shadowDetectors = new Set(config.shadowDetectors ?? []);
4469
5629
  const rules = [...config.rules ?? []];
4470
5630
  if (config.preset !== void 0) {
4471
5631
  const preset = PRESETS[config.preset];
@@ -4494,6 +5654,36 @@ function resolveConfig(config = {}) {
4494
5654
  if (proxyConfig.trustProxy !== true && (trustedProxies !== void 0 || proxyConfig.hops !== void 0)) {
4495
5655
  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.");
4496
5656
  }
5657
+ if (config.probe !== void 0) {
5658
+ if (config.probe.cookieName !== void 0 && config.probe.cookieName === config.challenge?.cookieName) {
5659
+ throw new ConfigError(
5660
+ `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.`
5661
+ );
5662
+ }
5663
+ if (config.probe.secure === false) {
5664
+ warnings.push(
5665
+ "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."
5666
+ );
5667
+ }
5668
+ if (config.probe.domain !== void 0 && config.probe.domain.startsWith(".") === false && config.probe.domain.includes(".") === false) {
5669
+ 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.`);
5670
+ }
5671
+ if (config.probe.ttlMs !== void 0 && config.probe.ttlMs < 6e4) {
5672
+ warnings.push(
5673
+ `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.`
5674
+ );
5675
+ }
5676
+ }
5677
+ if (config.site !== void 0 && config.site.warmupRequests !== void 0 && config.site.warmupRequests < 500) {
5678
+ warnings.push(
5679
+ `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.`
5680
+ );
5681
+ }
5682
+ if (config.actorKey === void 0 && detectors.some((detector) => detector.id === "identity-rotation")) {
5683
+ warnings.push(
5684
+ "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."
5685
+ );
5686
+ }
4497
5687
  const strictEvidence = config.strictEvidence ?? process.env["NODE_ENV"] !== "production";
4498
5688
  const falsePositivePolicy = config.falsePositivePolicy ?? "strict";
4499
5689
  if (falsePositivePolicy === "aggressive") {
@@ -4508,6 +5698,7 @@ function resolveConfig(config = {}) {
4508
5698
  }
4509
5699
  return {
4510
5700
  detectors,
5701
+ shadowDetectors,
4511
5702
  rules,
4512
5703
  ranges,
4513
5704
  signatures,
@@ -4545,11 +5736,11 @@ function clamp(value, min, max) {
4545
5736
  return Math.min(max, Math.max(min, value));
4546
5737
  }
4547
5738
  function resolveClientIp(socketAddress, headers, proxy) {
4548
- const direct = socketAddress !== void 0 ? normalizeIp(socketAddress) ?? socketAddress : "";
5739
+ const direct = socketAddress !== void 0 ? normalizeIp(stripPort(socketAddress)) ?? socketAddress : "";
4549
5740
  if (!proxy.trustProxy) return direct;
4550
5741
  const header = headers[proxy.header];
4551
5742
  if (header === void 0) return direct;
4552
- const chain = header.slice(0, 2048).split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0 && parseIp(entry) !== null).map((entry) => normalizeIp(entry));
5743
+ const chain = header.slice(0, 2048).split(",").map((entry) => stripPort(entry)).filter((entry) => entry.length > 0 && parseIp(entry) !== null).map((entry) => normalizeIp(entry));
4553
5744
  if (chain.length === 0) return direct;
4554
5745
  if (proxy.trustedProxies) {
4555
5746
  if (direct !== "" && !proxy.trustedProxies.contains(direct)) return direct;
@@ -4782,11 +5973,15 @@ var init_feed = __esm({
4782
5973
  certain: assessment.certain,
4783
5974
  durationMs: Number(assessment.durationMs.toFixed(3)),
4784
5975
  bypass: assessment.bypass,
4785
- evidence: [...assessment.evidence, ...assessment.humanEvidence].map((item) => ({
5976
+ // Shadowed findings ride in the same list, flagged. They belong on the same screen
5977
+ // as the evidence that did decide — the comparison is the point — and the flag is
5978
+ // what stops the page, and `previewAssessment`, from treating them as such.
5979
+ evidence: [...assessment.evidence, ...assessment.humanEvidence, ...assessment.shadowEvidence].map((item) => ({
4786
5980
  detector: item.detector,
4787
5981
  summary: item.summary,
4788
5982
  certainty: item.certainty,
4789
5983
  direction: item.direction,
5984
+ ...item.shadow === true ? { shadow: true } : {},
4790
5985
  family: item.family,
4791
5986
  deterministicBasis: item.deterministicBasis,
4792
5987
  identity: item.identity,
@@ -4795,6 +5990,7 @@ var init_feed = __esm({
4795
5990
  category: typeof item.metadata?.["category"] === "string" ? item.metadata["category"] : void 0,
4796
5991
  weight: item.weight
4797
5992
  })),
5993
+ ...assessment.shadowVerdict === void 0 ? {} : { shadowVerdict: assessment.shadowVerdict },
4798
5994
  failures: assessment.failures.map((failure) => ({ detector: failure.detector, reason: failure.reason, message: failure.message })),
4799
5995
  actorStats: {
4800
5996
  requests: assessment.actor.requests,
@@ -5004,6 +6200,7 @@ function isDenial(action) {
5004
6200
  function assessmentFromEntry(entry) {
5005
6201
  const evidence2 = [];
5006
6202
  const humanEvidence = [];
6203
+ const shadowEvidence = [];
5007
6204
  for (const item of entry.evidence) {
5008
6205
  const rebuilt = {
5009
6206
  detector: item.detector,
@@ -5014,9 +6211,11 @@ function assessmentFromEntry(entry) {
5014
6211
  ...item.identity !== void 0 ? { identity: item.identity } : {},
5015
6212
  ...item.family !== void 0 ? { family: item.family } : {},
5016
6213
  // `category` is read off metadata by the matcher, so it has to go back there.
5017
- ...item.category !== void 0 ? { metadata: { category: item.category } } : {}
6214
+ ...item.category !== void 0 ? { metadata: { category: item.category } } : {},
6215
+ ...item.shadow === true ? { shadow: true } : {}
5018
6216
  };
5019
- (item.direction === "human" ? humanEvidence : evidence2).push(rebuilt);
6217
+ if (item.shadow === true) shadowEvidence.push(rebuilt);
6218
+ else (item.direction === "human" ? humanEvidence : evidence2).push(rebuilt);
5020
6219
  }
5021
6220
  return {
5022
6221
  requestId: entry.requestId,
@@ -5028,10 +6227,17 @@ function assessmentFromEntry(entry) {
5028
6227
  certain: entry.certain,
5029
6228
  evidence: evidence2,
5030
6229
  humanEvidence,
6230
+ shadowEvidence,
6231
+ ...entry.shadowVerdict === void 0 ? {} : { shadowVerdict: entry.shadowVerdict },
5031
6232
  actor: {
5032
6233
  key: entry.actor,
5033
6234
  requests: entry.actorStats.requests,
5034
6235
  distinctPaths: entry.actorStats.distinctPaths,
6236
+ distinctQueries: 0,
6237
+ queriesSaturated: false,
6238
+ methodsSeen: ["GET"],
6239
+ responses: 0,
6240
+ misses: 0,
5035
6241
  firstSeen: entry.actorStats.firstSeen,
5036
6242
  lastSeen: entry.at,
5037
6243
  ...entry.actorStats.sinceLastMs !== void 0 ? { sinceLastMs: entry.actorStats.sinceLastMs } : {},
@@ -5080,7 +6286,7 @@ var CLIENT_SCRIPT;
5080
6286
  var init_client_generated = __esm({
5081
6287
  "src/dashboard/client.generated.ts"() {
5082
6288
  "use strict";
5083
- 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';
6289
+ 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';
5084
6290
  }
5085
6291
  });
5086
6292
 
@@ -5300,7 +6506,10 @@ button[disabled] { opacity: .5; cursor: default; }
5300
6506
  .tab[aria-selected="true"] { color: var(--ink); border-bottom-color: var(--s1); }
5301
6507
 
5302
6508
  /* --- layout ------------------------------------------------------------- */
5303
- main { padding: 18px 20px 64px; max-width: 1680px; margin: 0 auto; }
6509
+ /* The top padding is the gap under the sticky header. At 18px the counter row sat almost
6510
+ against the header's border and read as part of it; the tiles carry their own border, so
6511
+ two lines were meeting with nothing between them. */
6512
+ main { padding: 28px 20px 64px; max-width: 1680px; margin: 0 auto; }
5304
6513
  .stack { display: grid; gap: 16px; }
5305
6514
  /* Everything above the feed is drawn by script once the first snapshot arrives, which
5306
6515
  inserts a block of content above what is already laid out. The browser's scroll
@@ -5331,6 +6540,33 @@ button.tile {
5331
6540
  cursor: pointer; appearance: none; transition: border-color .12s, box-shadow .12s;
5332
6541
  }
5333
6542
  button.tile:hover { border-color: var(--focus); }
6543
+ /* The suggestion list under the search box, positioned against the search wrapper. */
6544
+ .search { position: relative; }
6545
+ .suggest {
6546
+ position: absolute; top: calc(100% + 4px); left: 0; z-index: 30; margin: 0; padding: 4px;
6547
+ list-style: none; min-width: 220px; max-height: 260px; overflow-y: auto;
6548
+ background: var(--surface); border: 1px solid var(--line); border-radius: 9px; box-shadow: var(--shadow);
6549
+ }
6550
+ .suggest li { padding: 4px 9px; border-radius: 6px; cursor: pointer; font-size: 12px; }
6551
+ .suggest li[aria-selected="true"] { background: color-mix(in srgb, var(--focus) 18%, transparent); }
6552
+
6553
+ /* Saved filters: a list of small removable things. Quiet, because it is not what
6554
+ somebody came to the page to look at. */
6555
+ .saved { display: flex; align-items: center; gap: 6px; }
6556
+ /* Two open-ended bounds rather than a list of durations: "from the incident until now",
6557
+ "everything up to when it stopped" and "between these two moments" are the same control
6558
+ with one end left empty. */
6559
+ .timeframe { display: flex; align-items: center; gap: 8px; font-size: 11.5px; color: var(--muted); }
6560
+ .timeframe label { display: inline-flex; align-items: center; gap: 4px; }
6561
+ .timeframe input {
6562
+ font: inherit; font-size: 11.5px; padding: 2px 5px; border-radius: 6px;
6563
+ border: 1px solid var(--line); background: var(--surface); color: var(--ink);
6564
+ }
6565
+ .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; }
6566
+ .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; }
6567
+ .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; }
6568
+ .saved button:hover { border-color: var(--focus); }
6569
+
5334
6570
  /* The badge's companion: fetches the entries the stream skipped. Sits inline with the
5335
6571
  heading, so it is styled to read as part of the sentence rather than as a form control. */
5336
6572
  .load-skipped {
@@ -5352,6 +6588,10 @@ button.tile:hover { border-color: var(--focus); }
5352
6588
  .pager button:hover:not(:disabled) { border-color: var(--focus); }
5353
6589
  .pager button:disabled { opacity: .45; cursor: default; }
5354
6590
  .pager .where { font-variant-numeric: tabular-nums; }
6591
+ /* A labelled actor leads with its name and keeps the key underneath: whoever named it did
6592
+ so because the key was not the useful part, and the key is still what you search for. */
6593
+ td.who .label { font-weight: 560; }
6594
+ td.who .sub { color: var(--muted); font-size: 11px; }
5355
6595
  .pager.pager-top { padding: 2px 2px 9px; border-bottom: 1px solid var(--line); margin-bottom: 9px; }
5356
6596
  /* The feed's upper pager rides in the toolbar rather than owning a row of its own, which
5357
6597
  was thirty-six pixels of mostly empty rule above every screenful of requests. */
@@ -5472,7 +6712,29 @@ input[type="search"] {
5472
6712
  }
5473
6713
  input[type="search"]::placeholder { color: var(--muted); }
5474
6714
 
5475
- table { width: 100%; border-collapse: collapse; }
6715
+ /* separate with zero spacing rather than collapse, and the difference is the whole
6716
+ reason the column headers work in Safari.
6717
+
6718
+ Collapsed borders and sticky table cells are a long-standing sore point in WebKit: the
6719
+ CSSWG has an open issue on collapsed borders not following a cell when it sticks
6720
+ (csswg-drafts#3136), and Safari is widely reported to drop the stickiness of a th
6721
+ altogether under a collapsed table. Separating the borders is the standard remedy.
6722
+
6723
+ What was actually measured: the header sticks correctly in Chromium and in Firefox,
6724
+ both before and after this change, and it was reported adrift in Safari — which is what
6725
+ a sticky element that has stopped sticking looks like. WebKit could not be run on the
6726
+ machine this was written on, so the Safari half of it rests on that report and on the
6727
+ documented behaviour rather than on a measurement taken here.
6728
+
6729
+ The rendering is all but unchanged. Every border in these tables is a bottom border on
6730
+ the cell itself, plus the per-cell left accent on td.edge; no border is shared between
6731
+ two cells, so there is nothing for collapsing to merge and nothing for separating to
6732
+ double, and zero spacing keeps the cells touching. The one measurable difference is the
6733
+ accent column, which moves two pixels: collapsing centres that 3px border on the cell
6734
+ edge and leaves half of it outside the box, while separating puts all of it inside.
6735
+ Measured rather than assumed, and the leftmost column starting two pixels earlier is
6736
+ both imperceptible and the more correct of the two. */
6737
+ table { width: 100%; border-collapse: separate; border-spacing: 0; }
5476
6738
  thead th {
5477
6739
  /* Measured at runtime — see trackHeaderHeight(). The literal is the fallback for
5478
6740
  the instant before the first measurement, and for the tab strip wrapping. */
@@ -5764,9 +7026,44 @@ input::placeholder { color: color-mix(in srgb, var(--muted) 80%, transparent); }
5764
7026
  #actor-rows td { font-size: 12.5px; }
5765
7027
  #actor-rows td.who { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
5766
7028
  #actor-rows td.acts { text-align: right; white-space: nowrap; }
7029
+ /* The tracked/shown toggle above the actors table. A segmented pair rather than a
7030
+ dropdown: there are two answers and both are worth reading at a glance. */
7031
+ .scope { display: flex; gap: 6px; padding: 0 14px 10px; }
7032
+ .scope button { font-size: 11.5px; padding: 4px 10px; }
7033
+ .scope button.on { background: var(--accent); color: var(--on-accent, #fff); border-color: var(--accent); }
7034
+
5767
7035
  #actor-rows td.acts button { font-size: 11px; padding: 3px 8px; margin-left: 4px; }
7036
+ /* The Label control, which becomes a text box with a Save and a Cancel in place.
7037
+
7038
+ The cell does not wrap, so an editor that sat beside the row's other four buttons put
7039
+ Save off the right edge of the panel, where it could be seen and not clicked. While
7040
+ the editor is open it stands in for those buttons instead — which is also the right
7041
+ thing on its own, since Allowlist and Forget are not what somebody naming a client is
7042
+ reaching for. */
7043
+ .acts.editing > :not(.label-edit), .bar-actions.editing > :not(.label-edit) { display: none; }
7044
+ /* inline-flex rather than inline-block: the row is three fixed-size controls and a flex
7045
+ line is the layout that cannot spill them past its own edge. */
7046
+ .label-edit { display: inline-flex; align-items: center; gap: 4px; }
7047
+ .label-edit button { flex: 0 0 auto; }
7048
+ #actor-rows td.acts .label-save, #actor-actions .label-save { border-color: var(--accent); color: var(--accent); }
7049
+ /* Qualified with the element name on purpose: input[type="text"] { width: 100% } above
7050
+ outranks a bare class, so the width here was quietly ignored and the box grew to fill
7051
+ whatever it was in — which is what put Save and Cancel outside the panel. */
7052
+ input.label-input {
7053
+ font: inherit; font-size: 11px; padding: 3px 8px; width: 15ch; flex: 0 0 auto; box-sizing: border-box;
7054
+ color: var(--ink); background: var(--surface); border: 1px solid var(--accent); border-radius: 6px;
7055
+ }
7056
+ input.label-input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
7057
+
5768
7058
  /* A tag, not a warning: a cleared actor is a decision somebody made, and a metronomic
5769
7059
  one is a measurement. Neither is a verdict, so neither gets a verdict's colour. */
7060
+ /* A shadowed finding: shown at full detail, and visibly not part of the decision. Dimmed
7061
+ and set behind a rule rather than coloured, because every colour on this page already
7062
+ means something about a verdict and this one took no part in a verdict. */
7063
+ .det.shadow { opacity: 0.72; }
7064
+ .ev-item.shadow { opacity: 0.72; border-left: 2px dashed var(--line); padding-left: 8px; }
7065
+ .shadow-verdict { margin-top: 8px; font-style: italic; }
7066
+ .shadow-verdict.changed { color: var(--ink-2); font-style: normal; }
5770
7067
  .tagline { font-size: 11px; color: var(--muted); }
5771
7068
  .tagline b { color: var(--ink-2); font-weight: 600; }
5772
7069
 
@@ -5864,8 +7161,16 @@ input::placeholder { color: color-mix(in srgb, var(--muted) 80%, transparent); }
5864
7161
  <div class="toolbar">
5865
7162
  <div class="filters" id="filters"></div>
5866
7163
  <div class="search">
5867
- <input type="search" id="search" placeholder="path:/api actor:203.0.113.4 -rule:allow-crawlers" spellcheck="false" autocomplete="off">
7164
+ <input type="search" id="search" placeholder="path:/api actor:203.0.113.4 -rule:allow-crawlers" spellcheck="false" autocomplete="off"
7165
+ role="combobox" aria-expanded="false" aria-controls="search-suggest" aria-autocomplete="list">
5868
7166
  <kbd aria-hidden="true">/</kbd>
7167
+ <ul class="suggest" id="search-suggest" role="listbox" aria-label="Filter suggestions" hidden></ul>
7168
+ </div>
7169
+ <div class="saved" id="saved-filters"></div>
7170
+ <div class="timeframe" id="timeframe">
7171
+ <label>From <input type="datetime-local" id="from-at" step="1"></label>
7172
+ <label>To <input type="datetime-local" id="to-at" step="1"></label>
7173
+ <button type="button" id="timeframe-clear" hidden>Clear</button>
5869
7174
  </div>
5870
7175
  <button id="feed-export" title="Download every request matching this filter as replay JSONL">Export</button>
5871
7176
  <div class="pager pager-inline" id="feed-pager-top" hidden></div>
@@ -5929,6 +7234,12 @@ input::placeholder { color: color-mix(in srgb, var(--muted) 80%, transparent); }
5929
7234
  <div class="note" id="actors-note">Everyone the engine is currently remembering, busiest first — a far larger
5930
7235
  population than the feed's ring, which holds requests rather than clients. This is
5931
7236
  what <code>cadence</code>, <code>crawl-breadth</code> and <code>rate-anomaly</code> are reading.</div>
7237
+ <div class="scope" role="group" aria-label="Which actors to list">
7238
+ <button id="actors-scope-tracked" class="on" aria-pressed="true"
7239
+ title="Every client the engine is remembering, busiest first">Tracked</button>
7240
+ <button id="actors-scope-feed" aria-pressed="false"
7241
+ title="Only the clients that appear in the feed you are looking at, after its filter">Shown in the feed</button>
7242
+ </div>
5932
7243
  <div class="pager pager-top" id="actors-pager-top" hidden></div>
5933
7244
  <div class="feed-scroll">
5934
7245
  <table>
@@ -6242,18 +7553,21 @@ function createFacts(input) {
6242
7553
  const rawPath = queryStart === -1 ? url : url.slice(0, queryStart);
6243
7554
  const headers = /* @__PURE__ */ Object.create(null);
6244
7555
  for (const [name, value] of Object.entries(input.headers)) {
6245
- const joined = joinHeaderValue(value);
6246
- if (joined !== void 0) headers[name.toLowerCase()] = joined;
7556
+ const lower = name.toLowerCase();
7557
+ const joined = lower === "cookie" && Array.isArray(value) ? value.join("; ") : joinHeaderValue(value);
7558
+ if (joined !== void 0) headers[lower] = joined;
6247
7559
  }
7560
+ const normalized = normalizePath(rawPath);
6248
7561
  const facts = {
6249
7562
  method: (input.method ?? "GET").toUpperCase(),
6250
- path: normalizePath(rawPath),
7563
+ path: normalized,
6251
7564
  query: parseQuery(queryStart === -1 ? "" : url.slice(queryStart + 1)),
6252
7565
  headers,
6253
7566
  headerOrder: extractOrder(input.rawHeaders, headers),
6254
7567
  ip: normalizeIp(input.ip) ?? input.ip,
6255
7568
  timestamp: input.timestamp ?? Date.now()
6256
7569
  };
7570
+ if (rawPath !== normalized) facts.rawPath = rawPath.length > MAX_RAW_PATH ? rawPath.slice(0, MAX_RAW_PATH) : rawPath;
6257
7571
  const cookieHeader = headers["cookie"];
6258
7572
  if (cookieHeader !== void 0) facts.cookies = parseCookies(cookieHeader);
6259
7573
  if (input.protocol !== void 0) facts.protocol = input.protocol;
@@ -6286,12 +7600,23 @@ function parseQuery(search) {
6286
7600
  const query = /* @__PURE__ */ Object.create(null);
6287
7601
  if (search.length === 0) return query;
6288
7602
  let count = 0;
6289
- for (const [key, value] of new URLSearchParams(search)) {
7603
+ for (const [key, value] of new URLSearchParams(boundedSearch(search))) {
6290
7604
  if (count++ >= MAX_QUERY_PARAMS) break;
6291
7605
  query[key] = value.length > 1024 ? value.slice(0, 1024) : value;
6292
7606
  }
6293
7607
  return query;
6294
7608
  }
7609
+ function boundedSearch(search) {
7610
+ let seen = 0;
7611
+ let at = search.charCodeAt(0) === 63 ? 1 : 0;
7612
+ while (at < search.length) {
7613
+ let end = search.indexOf("&", at);
7614
+ if (end === -1) end = search.length;
7615
+ if (end !== at && ++seen > MAX_QUERY_PARAMS) return search.slice(0, at - 1);
7616
+ at = end + 1;
7617
+ }
7618
+ return search;
7619
+ }
6295
7620
  function extractOrder(rawHeaders, headers) {
6296
7621
  if (!rawHeaders || rawHeaders.length === 0) return EMPTY_ORDER;
6297
7622
  let isNodeStyle = rawHeaders.length % 2 === 0;
@@ -6327,13 +7652,14 @@ function isHeaderName(value) {
6327
7652
  }
6328
7653
  return true;
6329
7654
  }
6330
- var MAX_URL_LENGTH, MAX_QUERY_PARAMS, MAX_ORDERED_HEADERS, EMPTY_ORDER;
7655
+ var MAX_URL_LENGTH, MAX_RAW_PATH, MAX_QUERY_PARAMS, MAX_ORDERED_HEADERS, EMPTY_ORDER;
6331
7656
  var init_facts = __esm({
6332
7657
  "src/facts.ts"() {
6333
7658
  "use strict";
6334
7659
  init_http();
6335
7660
  init_ip();
6336
7661
  MAX_URL_LENGTH = 8192;
7662
+ MAX_RAW_PATH = 512;
6337
7663
  MAX_QUERY_PARAMS = 64;
6338
7664
  MAX_ORDERED_HEADERS = 64;
6339
7665
  EMPTY_ORDER = Object.freeze([]);
@@ -6618,7 +7944,7 @@ function buildDashboard(handler, options, host) {
6618
7944
  return send(response, 200, "application/json; charset=utf-8", JSON.stringify(snapshot()));
6619
7945
  case "/api/feed":
6620
7946
  if (!sections.feed) return sectionOff(response, "feed");
6621
- return send(response, 200, "application/json; charset=utf-8", JSON.stringify({ entries: feed.backlog().map(project) }));
7947
+ return send(response, 200, "application/json; charset=utf-8", JSON.stringify({ entries: feed.backlog().map(project), skipped: feed.skipped }));
6622
7948
  case "/api/stream":
6623
7949
  if (!sections.feed) return sectionOff(response, "feed");
6624
7950
  return stream(request, url, response);
@@ -6696,8 +8022,11 @@ function buildDashboard(handler, options, host) {
6696
8022
  } else if (action === "clear") {
6697
8023
  const forMs = typeof payload?.forMs === "number" && Number.isFinite(payload.forMs) ? Math.min(24 * 60 * 6e4, Math.max(0, payload.forMs)) : DEFAULT_CLEARANCE_MS;
6698
8024
  handler.clearActor(key, forMs, { by });
8025
+ } else if (action === "label") {
8026
+ const label = typeof payload?.label === "string" ? payload.label : void 0;
8027
+ handler.labelActor(key, label, { by });
6699
8028
  } else {
6700
- sendError(response, 400, 'Expected `action` to be "forget" or "clear".');
8029
+ sendError(response, 400, 'Expected `action` to be "forget", "clear" or "label".');
6701
8030
  return;
6702
8031
  }
6703
8032
  send(response, 200, "application/json; charset=utf-8", JSON.stringify({ ok: true }));
@@ -7132,7 +8461,7 @@ function headerValue(request, name) {
7132
8461
  if (value === void 0) return void 0;
7133
8462
  return (Array.isArray(value) ? value[0] : value)?.trim().toLowerCase();
7134
8463
  }
7135
- function stripPort(host) {
8464
+ function stripPort2(host) {
7136
8465
  if (host.startsWith("[")) {
7137
8466
  const end = host.indexOf("]");
7138
8467
  return end === -1 ? host : host.slice(0, end + 1);
@@ -7144,15 +8473,15 @@ function stripPort(host) {
7144
8473
  function resolveAllowedHosts(host, extra) {
7145
8474
  if (extra?.includes("*")) return void 0;
7146
8475
  if (!LOOPBACK_HOSTS.has(host)) return void 0;
7147
- const allowed = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]", "::1", stripPort(host).toLowerCase()]);
7148
- for (const entry of extra ?? []) allowed.add(stripPort(entry).toLowerCase());
8476
+ const allowed = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]", "::1", stripPort2(host).toLowerCase()]);
8477
+ for (const entry of extra ?? []) allowed.add(stripPort2(entry).toLowerCase());
7149
8478
  return allowed;
7150
8479
  }
7151
8480
  function hostAllowed(request, allowed) {
7152
8481
  if (allowed === void 0) return true;
7153
8482
  const host = headerValue(request, "host");
7154
8483
  if (host === void 0) return false;
7155
- return allowed.has(stripPort(host));
8484
+ return allowed.has(stripPort2(host));
7156
8485
  }
7157
8486
  function isSameOrigin(request) {
7158
8487
  const site = headerValue(request, "sec-fetch-site");
@@ -7195,7 +8524,7 @@ function rangeUpdate(body, handler) {
7195
8524
  }
7196
8525
  function resolveMountedHosts(extra) {
7197
8526
  if (extra === void 0 || extra.length === 0 || extra.includes("*")) return void 0;
7198
- return new Set(extra.map((name) => stripPort(name).toLowerCase()));
8527
+ return new Set(extra.map((name) => stripPort2(name).toLowerCase()));
7199
8528
  }
7200
8529
  function validateClients(entries) {
7201
8530
  const set = new IpRangeSet(entries);
@@ -7668,6 +8997,12 @@ function renderChallengePage(options) {
7668
8997
  <meta charset="utf-8">
7669
8998
  <meta name="viewport" content="width=device-width, initial-scale=1">
7670
8999
  <meta name="robots" content="noindex, nofollow">
9000
+ <!-- An empty icon, so the browser does not go looking for /favicon.ico on its own. It
9001
+ is the browser that makes that request rather than this page, and under
9002
+ default-src 'none' it is refused \u2014 which Firefox reports to the console as a
9003
+ security error on a page whose entire purpose is to reassure somebody that nothing
9004
+ is wrong. Declaring one stops the request being made at all. -->
9005
+ <link rel="icon" href="data:,">
7671
9006
  <title>${title}</title>
7672
9007
  <style>
7673
9008
  :root { color-scheme: light dark; --fg: #16181d; --muted: #5b6270; --bg: #fbfbfc; --line: #e2e5ea; --accent: #2f6feb; }
@@ -7853,8 +9188,8 @@ function parseAcceptLanguage(header) {
7853
9188
  if (header === void 0 || header.trim() === "") return [];
7854
9189
  const entries = [];
7855
9190
  const parts = header.split(",").slice(0, MAX_TAGS);
7856
- parts.forEach((part, order) => {
7857
- const [rawTag, ...parameters] = part.trim().split(";");
9191
+ parts.forEach((part2, order) => {
9192
+ const [rawTag, ...parameters] = part2.trim().split(";");
7858
9193
  const tag = (rawTag ?? "").trim().toLowerCase();
7859
9194
  if (tag === "" || tag === "*" || !/^[a-z]{1,8}(-[a-z\d]{1,8})*$/.test(tag)) return;
7860
9195
  let q = 1;
@@ -7920,51 +9255,8 @@ function clampDifficulty(difficulty) {
7920
9255
  return Math.min(MAX_DIFFICULTY, Math.max(1, Math.round(difficulty)));
7921
9256
  }
7922
9257
 
7923
- // src/challenge/token.ts
7924
- init_crypto();
7925
- var MAX_TOKEN_LENGTH = 2048;
7926
- function issueToken(payload, secrets) {
7927
- const secret = secrets[0];
7928
- if (secret === void 0) throw new Error("At least one signing secret is required to issue a token");
7929
- const body = base64UrlEncode(JSON.stringify(payload));
7930
- return `${body}.${sign(body, secret)}`;
7931
- }
7932
- function verifyToken(token, secrets, now, expectedSubject) {
7933
- if (token.length === 0 || token.length > MAX_TOKEN_LENGTH) return { ok: false, reason: "malformed" };
7934
- const separator = token.lastIndexOf(".");
7935
- if (separator <= 0) return { ok: false, reason: "malformed" };
7936
- const body = token.slice(0, separator);
7937
- const signature = token.slice(separator + 1);
7938
- let valid = false;
7939
- for (const secret of secrets) {
7940
- if (constantTimeEqual(signature, sign(body, secret))) valid = true;
7941
- }
7942
- if (!valid) return { ok: false, reason: "bad-signature" };
7943
- let payload;
7944
- try {
7945
- const decoded = base64UrlDecode(body).toString("utf8");
7946
- payload = JSON.parse(decoded);
7947
- } catch {
7948
- return { ok: false, reason: "malformed" };
7949
- }
7950
- if (typeof payload !== "object" || payload === null) return { ok: false, reason: "malformed" };
7951
- if (typeof payload.exp !== "number" || typeof payload.sub !== "string") return { ok: false, reason: "malformed" };
7952
- if (payload.exp <= now) return { ok: false, reason: "expired" };
7953
- if (expectedSubject !== void 0) {
7954
- let bound = false;
7955
- for (const candidate of typeof expectedSubject === "string" ? [expectedSubject] : expectedSubject) {
7956
- if (constantTimeEqual(payload.sub, candidate)) bound = true;
7957
- }
7958
- if (!bound) return { ok: false, reason: "wrong-actor" };
7959
- }
7960
- return { ok: true, payload };
7961
- }
7962
- function newChallenge(subject, difficulty, ttlMs, now) {
7963
- return { v: 1, sub: subject, iat: now, exp: now + ttlMs, nonce: randomId(12), diff: difficulty };
7964
- }
7965
- function newClearance(subject, level, ttlMs, now) {
7966
- return { v: 1, sub: subject, iat: now, exp: now + ttlMs, jti: randomId(9), lvl: level };
7967
- }
9258
+ // src/challenge/index.ts
9259
+ init_token();
7968
9260
 
7969
9261
  // src/challenge/interaction.ts
7970
9262
  init_crypto();
@@ -8048,7 +9340,14 @@ function analyseMovement(path) {
8048
9340
  timingVariation: coefficientOfVariation(gaps),
8049
9341
  accelerationChanges,
8050
9342
  straightness: pathLength === 0 ? 1 : Math.min(1, Math.hypot(netX, netY) / pathLength),
8051
- fractionalShare: fractional / samples.length,
9343
+ // Over the samples this actually looked at, not over everything that arrived. The
9344
+ // two differ by however many discontinuities were dropped above, and using the raw
9345
+ // count meant a path with pauses in it reported a *lower* fractional share than the
9346
+ // samples it was computed from — which reads as "these coordinates are integers"
9347
+ // when what happened is that most of them were never examined. It costs the people
9348
+ // most likely to have pauses: somebody who moved the pointer, stopped to read, and
9349
+ // moved again.
9350
+ fractionalShare: distances.length === 0 ? 0 : fractional / distances.length,
8052
9351
  totalTurning
8053
9352
  };
8054
9353
  }
@@ -8143,9 +9442,10 @@ function verifyInteraction(report, elapsedMs, settings = DEFAULT_INTERACTION_SET
8143
9442
  const failed = Object.keys(CAPABILITY_WEIGHTS).filter((name) => report.capabilities[name] !== true);
8144
9443
  notes.push(failed.length === 0 ? "capabilities 100%" : `capabilities ${(capabilityScore * 100).toFixed(0)}% (missing: ${failed.join(", ")})`);
8145
9444
  let score = capabilityScore * 0.6;
8146
- const measurable = report.via === "pointer" && report.path.length >= 4;
9445
+ const analysis = analyseMovement(report.path);
9446
+ const measurable = report.via === "pointer" && analysis.samples >= 4;
8147
9447
  if (measurable) {
8148
- const movement = scoreMovement(analyseMovement(report.path));
9448
+ const movement = scoreMovement(analysis);
8149
9449
  notes.push(`movement ${(movement * 100).toFixed(0)}%`);
8150
9450
  score += movement * 0.4;
8151
9451
  } else {
@@ -8161,7 +9461,12 @@ function verifyInteraction(report, elapsedMs, settings = DEFAULT_INTERACTION_SET
8161
9461
  // src/challenge/index.ts
8162
9462
  init_http();
8163
9463
  init_crypto();
9464
+ init_lru();
8164
9465
  init_clock();
9466
+ init_token();
9467
+ var IMPLAUSIBLE_HASHES_PER_MS = 2e4;
9468
+ var MAX_TRACKED_TOKENS = 2e4;
9469
+ var MAX_BEARERS = 64;
8165
9470
  var ChallengeService = class {
8166
9471
  constructor(options) {
8167
9472
  this.options = options;
@@ -8177,11 +9482,14 @@ var ChallengeService = class {
8177
9482
  this.verifyPath = options.verifyPath ?? "/__bothandler/verify";
8178
9483
  this.cookieName = options.cookieName ?? "__bh_clearance";
8179
9484
  this.clock = options.clock ?? systemClock;
9485
+ this.bearers = new TtlLru(MAX_TRACKED_TOKENS, this.clearanceTtlMs, this.clock);
8180
9486
  this.store = options.store;
8181
9487
  this.interaction = !wantsGesture ? void 0 : { ...DEFAULT_INTERACTION_SETTINGS, ...options.interaction === true ? {} : options.interaction };
8182
9488
  }
8183
9489
  options;
8184
9490
  secrets;
9491
+ /** Clearance token id to the actors that have presented it. Bounded both ways. */
9492
+ bearers;
8185
9493
  difficulty;
8186
9494
  challengeTtlMs;
8187
9495
  clock;
@@ -8264,7 +9572,11 @@ var ChallengeService = class {
8264
9572
  "cache-control": "no-store, private",
8265
9573
  // The page carries one inline script and nothing else. Locking the policy
8266
9574
  // this far down means the interstitial cannot be turned into a fetch primitive.
8267
- "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'`,
9575
+ // `img-src data:` permits nothing off this machine a data: URI is inline by
9576
+ // definition — and exists only so the empty icon the page declares is honoured.
9577
+ // Without it the browser asks for /favicon.ico by itself and is refused, which
9578
+ // Firefox prints as a security error in the console of every person challenged.
9579
+ "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'`,
8268
9580
  "referrer-policy": "no-referrer",
8269
9581
  "x-content-type-options": "nosniff",
8270
9582
  "x-robots-tag": "noindex, nofollow"
@@ -8309,6 +9621,11 @@ var ChallengeService = class {
8309
9621
  interactionScore = outcome.score;
8310
9622
  notes = outcome.notes;
8311
9623
  }
9624
+ const elapsedSinceIssue = this.clock.now() - verified.payload.iat;
9625
+ const floorMs = 2 ** verified.payload.diff / IMPLAUSIBLE_HASHES_PER_MS | 0;
9626
+ if (elapsedSinceIssue >= 0 && elapsedSinceIssue < floorMs) {
9627
+ return { ok: false, status: 400, reason: "solution returned faster than the puzzle allows", signal: "implausible-speed" };
9628
+ }
8312
9629
  if (this.store) {
8313
9630
  let claimed;
8314
9631
  try {
@@ -8316,7 +9633,7 @@ var ChallengeService = class {
8316
9633
  } catch {
8317
9634
  claimed = true;
8318
9635
  }
8319
- if (!claimed) return { ok: false, status: 409, reason: "challenge already solved" };
9636
+ if (!claimed) return { ok: false, status: 409, reason: "challenge already solved", signal: "replay" };
8320
9637
  }
8321
9638
  return {
8322
9639
  ok: true,
@@ -8346,10 +9663,55 @@ var ChallengeService = class {
8346
9663
  }
8347
9664
  /** Reads and validates the clearance cookie for an actor. Returns `undefined` if there is none valid. */
8348
9665
  read(actorKey, cookies) {
9666
+ const inspected = this.inspect(actorKey, cookies);
9667
+ return inspected.claims;
9668
+ }
9669
+ /**
9670
+ * Reads a clearance token and says what became of it.
9671
+ *
9672
+ * `read` answers the only question the clearance detector used to ask — is this client
9673
+ * cleared — and throws away the reason when the answer is no. One of those reasons is
9674
+ * worth keeping: a token whose *signature* is ours but whose subject is somebody
9675
+ * else's has been moved between clients. Usually that is innocent and extremely
9676
+ * common, because the subject is derived from the address and a phone changing
9677
+ * networks changes its address. It stops being innocent when one token turns up under
9678
+ * a great many different actors, which is a token being handed around.
9679
+ */
9680
+ inspect(actorKey, cookies) {
8349
9681
  const token = cookies?.[this.cookieName];
8350
- if (token === void 0) return void 0;
8351
- const verified = verifyToken(token, this.secrets, this.clock.now(), this.subjectsFor(actorKey));
8352
- return verified.ok ? verified.payload : void 0;
9682
+ if (token === void 0) return { presentedBy: 0 };
9683
+ const now = this.clock.now();
9684
+ const verified = verifyToken(token, this.secrets, now, this.subjectsFor(actorKey));
9685
+ if (verified.ok) return { claims: verified.payload, presentedBy: this.noteBearer(verified.payload.jti, actorKey) };
9686
+ if (verified.reason !== "wrong-actor") return { presentedBy: 0 };
9687
+ const claims = this.claimsOf(token);
9688
+ return claims === void 0 ? { presentedBy: 0 } : { boundElsewhere: true, presentedBy: this.noteBearer(claims.jti, actorKey) };
9689
+ }
9690
+ /** The claims inside a token whose signature has already been checked. */
9691
+ claimsOf(token) {
9692
+ const separator = token.lastIndexOf(".");
9693
+ if (separator <= 0) return void 0;
9694
+ try {
9695
+ const claims = JSON.parse(base64UrlDecode(token.slice(0, separator)).toString("utf8"));
9696
+ return typeof claims?.jti === "string" ? claims : void 0;
9697
+ } catch {
9698
+ return void 0;
9699
+ }
9700
+ }
9701
+ /**
9702
+ * Files this presentation under the token's own id, returning how many distinct actors
9703
+ * have now presented it. Bounded in both directions, and in process for the reason
9704
+ * given in `state.ts`: a store round trip per request buys precision nobody asked for.
9705
+ */
9706
+ noteBearer(jti, actorKey) {
9707
+ if (this.bearers === void 0) return 0;
9708
+ let seen = this.bearers.get(jti);
9709
+ if (seen === void 0) {
9710
+ seen = /* @__PURE__ */ new Set();
9711
+ this.bearers.set(jti, seen);
9712
+ }
9713
+ if (seen.size < MAX_BEARERS) seen.add(actorKey);
9714
+ return seen.size;
8353
9715
  }
8354
9716
  /** A `Set-Cookie` that removes any clearance. Call it on logout. */
8355
9717
  revoke() {
@@ -8871,6 +10233,10 @@ var NotificationHub = class {
8871
10233
  init_policy();
8872
10234
  init_dns();
8873
10235
  init_clearance();
10236
+ init_challenge_reaction();
10237
+ init_challenge_integrity();
10238
+ init_site_baseline();
10239
+ init_marker2();
8874
10240
  init_evidence();
8875
10241
  init_known_bots();
8876
10242
  init_types2();
@@ -9048,6 +10414,301 @@ function defineHandler(handler) {
9048
10414
  init_ua();
9049
10415
  init_pattern();
9050
10416
  init_crypto();
10417
+ init_text();
10418
+
10419
+ // src/probe/index.ts
10420
+ init_marker();
10421
+ init_lru();
10422
+ init_ip();
10423
+ init_marker();
10424
+ var DEFAULT_TTL_MS = 12 * 60 * 6e4;
10425
+ var FANOUT_WORDS = 4;
10426
+ var FANOUT_BITS = FANOUT_WORDS * 32;
10427
+ function hashToBit(value) {
10428
+ let hash = 2166136261;
10429
+ for (let i = 0; i < value.length; i++) {
10430
+ hash ^= value.charCodeAt(i);
10431
+ hash = Math.imul(hash, 16777619);
10432
+ }
10433
+ return (hash >>> 0) % FANOUT_BITS;
10434
+ }
10435
+ function estimateDistinct(sketch, cap) {
10436
+ let set = 0;
10437
+ for (let word = 0; word < FANOUT_WORDS; word++) {
10438
+ let bits = sketch[word];
10439
+ while (bits !== 0) {
10440
+ bits &= bits - 1;
10441
+ set++;
10442
+ }
10443
+ }
10444
+ if (set >= FANOUT_BITS) return cap;
10445
+ const estimate = Math.round(-FANOUT_BITS * Math.log(1 - set / FANOUT_BITS));
10446
+ return Math.min(estimate, cap);
10447
+ }
10448
+ var MarkerProbe = class {
10449
+ cookieName;
10450
+ secrets;
10451
+ ttlMs;
10452
+ cookieOptions;
10453
+ clock;
10454
+ /**
10455
+ * Marker id to a 128-bit sketch of the networks it has been presented from.
10456
+ *
10457
+ * A `Set` of network strings is the obvious structure and measured at **55.6 MB** with
10458
+ * both caps full — twenty thousand markers each seen from a few dozen networks — which
10459
+ * is far too much to hand somebody for switching on a detector. The question being
10460
+ * asked is only ever "has this marker come from more than about sixteen networks", and
10461
+ * a bitmap answers that in sixteen bytes by linear counting: hash each network to a
10462
+ * bit, then estimate the distinct count from how many bits are set.
10463
+ *
10464
+ * The estimate carries a few percent of error in **either** direction — measured, 16
10465
+ * real networks read as 17 and 32 read as 33 — so the threshold it feeds is a soft
10466
+ * boundary rather than a hard one. That is honest for this signal in particular, which
10467
+ * cannot separate a proxy pool from a heavily mobile person at any resolution, and is
10468
+ * why it is capped at `moderate` and never denies anybody by itself.
10469
+ */
10470
+ fanout;
10471
+ maxNetworks;
10472
+ /**
10473
+ * Markers already verified, by the exact cookie value that verified.
10474
+ *
10475
+ * A browsing session sends one identical cookie on every request, and verifying it is
10476
+ * an HMAC — which measured at roughly twenty microseconds, nearly doubling the cost of
10477
+ * an assessment to re-establish a fact that had not changed. The cache is only ever
10478
+ * populated with *successes*: caching failures would let anyone flood it with unique
10479
+ * junk, and a failure is cheap to reach anyway.
10480
+ *
10481
+ * Expiry is still checked on every hit, so a cached marker stops being accepted at the
10482
+ * moment it should. The key is the whole signed value, so a cache hit is only possible
10483
+ * for a string that already carried a valid signature.
10484
+ */
10485
+ verified;
10486
+ constructor(options) {
10487
+ if (options.secrets.length === 0) throw new Error("A marker probe requires at least one secret");
10488
+ for (const secret of options.secrets) {
10489
+ if (secret.length < 32) {
10490
+ throw new Error("Each marker secret must be at least 32 characters; generate one with `crypto.randomBytes(32).toString('base64url')`");
10491
+ }
10492
+ }
10493
+ this.secrets = options.secrets;
10494
+ this.cookieName = options.cookieName ?? "__bh_m";
10495
+ this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
10496
+ this.clock = options.clock;
10497
+ const tracked = options.maxTrackedMarkers ?? 2e4;
10498
+ this.maxNetworks = options.maxNetworksPerMarker ?? 96;
10499
+ this.fanout = tracked > 0 ? new TtlLru(tracked, this.ttlMs, options.clock) : void 0;
10500
+ const cached = options.maxVerifiedMarkers ?? 5e3;
10501
+ this.verified = cached > 0 ? new TtlLru(cached, this.ttlMs, options.clock) : void 0;
10502
+ this.cookieOptions = {
10503
+ ...options.sameSite === void 0 ? {} : { sameSite: options.sameSite },
10504
+ ...options.secure === void 0 ? {} : { secure: options.secure },
10505
+ ...options.domain === void 0 ? {} : { domain: options.domain }
10506
+ };
10507
+ try {
10508
+ markerCookie(this.cookieName, newMarker({ b: "x", o: "x", l: "x" }, this.ttlMs, 0), this.secrets, this.cookieOptions);
10509
+ } catch (error) {
10510
+ throw new Error(`The marker probe cannot issue a cookie with this configuration: ${error instanceof Error ? error.message : String(error)}`);
10511
+ }
10512
+ }
10513
+ /** Reads the marker this request carried, and measures it against the request. */
10514
+ observe(facts, ua) {
10515
+ const shape = identityShape(facts, ua);
10516
+ const reading = this.read(facts.cookies?.[this.cookieName]);
10517
+ const drift = reading.kind === "valid" ? driftBetween({ b: reading.claims.b, o: reading.claims.o, l: reading.claims.l }, shape) : void 0;
10518
+ const networks = reading.kind === "valid" ? this.noteNetwork(reading.claims.sub, facts.ip) : 0;
10519
+ return { reading, drift, shape, networks };
10520
+ }
10521
+ /**
10522
+ * Whether this response should carry a marker.
10523
+ *
10524
+ * Only when the client is not already holding a good one. An ordinary visitor is
10525
+ * therefore issued a cookie once and then browses with uncached-by-`Set-Cookie`
10526
+ * responses never again; a client that discards cookies is issued one every time,
10527
+ * which is itself the observation `marker-persistence` is built on.
10528
+ */
10529
+ shouldIssue(observation) {
10530
+ return observation.reading.kind !== "valid";
10531
+ }
10532
+ /** Verifies a presented marker, reusing an earlier verification of the same value. */
10533
+ read(value) {
10534
+ if (value === void 0 || value.length === 0) return { kind: "absent" };
10535
+ const now = this.clock.now();
10536
+ const remembered = this.verified?.get(value);
10537
+ if (remembered !== void 0) return remembered.exp > now ? { kind: "valid", claims: remembered } : { kind: "expired" };
10538
+ const reading = readMarker(value, this.secrets, now);
10539
+ if (reading.kind === "valid") this.verified?.set(value, reading.claims);
10540
+ return reading;
10541
+ }
10542
+ /**
10543
+ * Files this presentation under the marker's own id and returns how many distinct
10544
+ * networks it has now come from.
10545
+ *
10546
+ * A `/24` rather than an address, because a single visitor's address changes for
10547
+ * ordinary reasons all day — a phone moving between cells, a router relearning a
10548
+ * lease — while the network it sits behind usually does not. Counting addresses would
10549
+ * report every commuter.
10550
+ */
10551
+ noteNetwork(markerId, ip) {
10552
+ if (this.fanout === void 0) return 0;
10553
+ let sketch = this.fanout.get(markerId);
10554
+ if (sketch === void 0) {
10555
+ sketch = new Uint32Array(FANOUT_WORDS);
10556
+ this.fanout.set(markerId, sketch);
10557
+ }
10558
+ const bit = hashToBit(networkKey(ip));
10559
+ sketch[bit >>> 5] = sketch[bit >>> 5] | 1 << (bit & 31);
10560
+ return estimateDistinct(sketch, this.maxNetworks);
10561
+ }
10562
+ /** The `Set-Cookie` handing this client a marker bound to the identity it just claimed. */
10563
+ issue(observation) {
10564
+ return markerCookie(this.cookieName, newMarker(observation.shape, this.ttlMs, this.clock.now()), this.secrets, this.cookieOptions);
10565
+ }
10566
+ };
10567
+
10568
+ // src/site/index.ts
10569
+ init_lru();
10570
+ var WALK_BUCKETS = 1024;
10571
+ function bucketSet(record, bucket) {
10572
+ record.bits[bucket >>> 5] = record.bits[bucket >>> 5] | 1 << (bucket & 31);
10573
+ }
10574
+ function bucketGet(record, bucket) {
10575
+ return (record.bits[bucket >>> 5] & 1 << (bucket & 31)) !== 0;
10576
+ }
10577
+ function coarsen(record) {
10578
+ const merged = new Uint32Array(WALK_BUCKETS / 32);
10579
+ for (let bucket = 0; bucket < WALK_BUCKETS / 2; bucket++) {
10580
+ const low = bucket * 2;
10581
+ if (bucketGet(record, low) || bucketGet(record, low + 1)) {
10582
+ merged[bucket >>> 5] = merged[bucket >>> 5] | 1 << (bucket & 31);
10583
+ }
10584
+ }
10585
+ record.bits = merged;
10586
+ record.scale *= 2;
10587
+ }
10588
+ var DEFAULTS = {
10589
+ warmupRequests: 5e3,
10590
+ maxPaths: 5e4,
10591
+ maxTemplates: 256,
10592
+ maxActorsPerTemplate: 64,
10593
+ windowMs: 60 * 6e4,
10594
+ maxWatchedPaths: 2048,
10595
+ maxActorsPerPath: 64
10596
+ };
10597
+ var SiteProfile = class {
10598
+ options;
10599
+ paths;
10600
+ walks;
10601
+ watched;
10602
+ clock;
10603
+ observed = 0;
10604
+ misses = 0;
10605
+ answered = 0;
10606
+ constructor(options) {
10607
+ this.options = { ...DEFAULTS, ...stripUndefined2(options) };
10608
+ this.paths = new TtlLru(this.options.maxPaths, this.options.windowMs, options.clock);
10609
+ this.walks = new TtlLru(this.options.maxTemplates, this.options.windowMs, options.clock);
10610
+ this.clock = options.clock;
10611
+ this.watched = this.options.maxWatchedPaths > 0 ? new TtlLru(this.options.maxWatchedPaths, this.options.windowMs, options.clock) : void 0;
10612
+ }
10613
+ /**
10614
+ * Whether enough traffic has been seen for any of this to mean anything.
10615
+ *
10616
+ * Every reader checks this. A profile that answers during warmup is worse than one
10617
+ * that does not exist, because it answers confidently and wrongly.
10618
+ */
10619
+ get warm() {
10620
+ return this.observed >= this.options.warmupRequests;
10621
+ }
10622
+ get requestsObserved() {
10623
+ return this.observed;
10624
+ }
10625
+ /** The share of answered requests that were misses, or `undefined` before warmup. */
10626
+ get missRate() {
10627
+ return this.warm && this.answered > 0 ? this.misses / this.answered : void 0;
10628
+ }
10629
+ /** Files a request. Called once per assessed request, before the detectors run. */
10630
+ record(path, actorKey) {
10631
+ if (this.observed < Number.MAX_SAFE_INTEGER) this.observed++;
10632
+ const seen = this.paths.get(path);
10633
+ this.paths.set(path, (seen ?? 0) + 1);
10634
+ if (this.watched === void 0 || !this.warm) return;
10635
+ let surge = this.watched.get(path);
10636
+ if (surge === void 0) {
10637
+ if (seen !== void 0) return;
10638
+ surge = { actors: /* @__PURE__ */ new Set(), firstSeen: this.clock.now(), answered: 0, misses: 0 };
10639
+ this.watched.set(path, surge);
10640
+ }
10641
+ if (surge.actors.size < this.options.maxActorsPerPath) surge.actors.add(actorKey);
10642
+ }
10643
+ /** Files what the application answered, for the site's miss rate and each watched path. */
10644
+ recordOutcome(path, status) {
10645
+ if (this.answered < Number.MAX_SAFE_INTEGER) this.answered++;
10646
+ const missed = status === 404 || status === 410;
10647
+ if (missed) this.misses++;
10648
+ const surge = this.watched?.get(path);
10649
+ if (surge === void 0) return;
10650
+ surge.answered++;
10651
+ if (missed) surge.misses++;
10652
+ }
10653
+ /** What has happened to a path since it first appeared. `undefined` if not watched. */
10654
+ surgeOf(path) {
10655
+ if (!this.warm) return void 0;
10656
+ const surge = this.watched?.get(path);
10657
+ if (surge === void 0) return void 0;
10658
+ return { clients: surge.actors.size, ageMs: this.clock.now() - surge.firstSeen, answered: surge.answered, misses: surge.misses };
10659
+ }
10660
+ /**
10661
+ * How many times the site has served this path, to anybody.
10662
+ *
10663
+ * `undefined` before warmup, and `0` for a path this process has not seen — which is
10664
+ * not the same as one the site does not have, and is why the detector reading this
10665
+ * needs a great many of them before it says anything.
10666
+ */
10667
+ timesSeen(path) {
10668
+ return this.warm ? this.paths.get(path) ?? 0 : void 0;
10669
+ }
10670
+ /** Files one step of a numeric walk against the shape it belongs to. */
10671
+ recordWalk(template, id, actorKey) {
10672
+ let record = this.walks.get(template);
10673
+ if (record === void 0) {
10674
+ record = { actors: /* @__PURE__ */ new Set(), bits: new Uint32Array(WALK_BUCKETS / 32), scale: 1, min: id, max: id, visits: 0 };
10675
+ this.walks.set(template, record);
10676
+ }
10677
+ record.visits++;
10678
+ if (record.actors.size < this.options.maxActorsPerTemplate) record.actors.add(actorKey);
10679
+ if (id < record.min) record.min = id;
10680
+ if (id > record.max) record.max = id;
10681
+ while (Math.floor(record.max / record.scale) >= WALK_BUCKETS) coarsen(record);
10682
+ bucketSet(record, Math.floor(id / record.scale));
10683
+ }
10684
+ /** What the whole site has done with one numeric shape. `undefined` before warmup. */
10685
+ spreadOf(template) {
10686
+ if (!this.warm) return void 0;
10687
+ const record = this.walks.get(template);
10688
+ if (record === void 0) return void 0;
10689
+ const lowest = Math.floor(record.min / record.scale);
10690
+ const highest = Math.floor(record.max / record.scale);
10691
+ let touched = 0;
10692
+ for (let bucket = lowest; bucket <= highest; bucket++) if (bucketGet(record, bucket)) touched++;
10693
+ const window = highest - lowest + 1;
10694
+ return {
10695
+ actors: record.actors.size,
10696
+ ids: touched * record.scale,
10697
+ buckets: touched,
10698
+ scale: record.scale,
10699
+ visits: record.visits,
10700
+ coverage: touched / window
10701
+ };
10702
+ }
10703
+ };
10704
+ function stripUndefined2(value) {
10705
+ const out = {};
10706
+ for (const [key, entry] of Object.entries(value)) if (entry !== void 0) out[key] = entry;
10707
+ return out;
10708
+ }
10709
+
10710
+ // src/core.ts
10711
+ init_state();
9051
10712
  init_config();
9052
10713
 
9053
10714
  // src/internal/async.ts
@@ -9072,12 +10733,22 @@ function withTimeout(work, ms, fallback) {
9072
10733
  // src/core.ts
9073
10734
  init_ip();
9074
10735
  var TIMED_OUT = /* @__PURE__ */ Symbol("bothandler.timeout");
10736
+ function sanitize(item) {
10737
+ const summary = safeSummary(item.summary);
10738
+ const basis = item.deterministicBasis === void 0 ? void 0 : safeSummary(item.deterministicBasis);
10739
+ if (summary === item.summary && basis === item.deterministicBasis) return item;
10740
+ return { ...item, summary, ...basis === void 0 ? {} : { deterministicBasis: basis } };
10741
+ }
9075
10742
  var BotHandler = class {
9076
10743
  config;
9077
10744
  registry;
9078
10745
  store;
9079
10746
  policy;
9080
10747
  challenge;
10748
+ /** The marker-cookie probe, when the operator asked for one. See `probe` in the config. */
10749
+ probe;
10750
+ /** The site-wide baseline, when the operator asked for one. See `site` in the config. */
10751
+ site;
9081
10752
  notifications;
9082
10753
  /**
9083
10754
  * The traffic audit, or `undefined` when it was switched off with `audit: false`.
@@ -9093,6 +10764,8 @@ var BotHandler = class {
9093
10764
  cheapDetectors = [];
9094
10765
  ioDetectors = [];
9095
10766
  confirmingDetectors = [];
10767
+ /** Hoisted from the resolved config: read once per detector per request. */
10768
+ shadowIds;
9096
10769
  events;
9097
10770
  ignoreExact;
9098
10771
  ignorePatterns;
@@ -9107,12 +10780,15 @@ var BotHandler = class {
9107
10780
  this.store = options.store ?? new MemoryStore({ clock: this.config.clock });
9108
10781
  this.registry = new ActorRegistry(this.config.clock, { windowMs: this.config.actorWindowMs, maxActors: this.config.maxActors });
9109
10782
  this.signatures = compileSignatures(this.config.signatures);
10783
+ this.shadowIds = this.config.shadowDetectors;
9110
10784
  this.resolver = cachingResolver(options.resolver ?? nodeDnsResolver(this.config.detectorTimeoutMs));
9111
10785
  this.handlers = new Map((options.handlers ?? []).map((handler) => [handler.id, handler]));
9112
10786
  this.isHuman = options.isHuman;
9113
10787
  this.meter = options.metrics === false ? void 0 : new Metrics(typeof options.metrics === "object" ? options.metrics : {});
9114
10788
  this.timing = this.meter?.perDetectorTiming === true;
9115
10789
  this.challenge = options.challenge ? new ChallengeService({ ...options.challenge, store: this.store, clock: this.config.clock }) : void 0;
10790
+ this.probe = options.probe !== void 0 ? new MarkerProbe({ ...options.probe, clock: this.config.clock }) : void 0;
10791
+ this.site = options.site !== void 0 ? new SiteProfile({ ...options.site, clock: this.config.clock }) : void 0;
9116
10792
  this.notifications = new NotificationHub({
9117
10793
  ...options.notifications,
9118
10794
  clock: this.config.clock,
@@ -9133,12 +10809,35 @@ var BotHandler = class {
9133
10809
  const detectors = [...this.config.detectors];
9134
10810
  if (this.challenge && !detectors.some((detector) => detector.id === "clearance")) {
9135
10811
  detectors.unshift(clearanceDetector(this.challenge));
10812
+ if (!detectors.some((detector) => detector.id === "challenge-reaction")) {
10813
+ detectors.unshift(challengeReactionDetector());
10814
+ }
10815
+ if (!detectors.some((detector) => detector.id === "challenge-integrity")) {
10816
+ detectors.unshift(challengeIntegrityDetector());
10817
+ }
10818
+ }
10819
+ if (this.site !== void 0) {
10820
+ for (const detector of [distributedWalkDetector(), pathNoveltyDetector(), missBaselineDetector(), pathCampaignDetector()]) {
10821
+ if (!detectors.some((installed) => installed.id === detector.id)) detectors.unshift(detector);
10822
+ }
10823
+ }
10824
+ if (this.probe !== void 0) {
10825
+ for (const detector of [identityDriftDetector(), markerIntegrityDetector(), markerPersistenceDetector(), markerFanoutDetector()]) {
10826
+ if (!detectors.some((installed) => installed.id === detector.id)) detectors.unshift(detector);
10827
+ }
9136
10828
  }
9137
10829
  for (const detector of detectors) {
9138
10830
  if (detector.stage === "confirming") this.confirmingDetectors.push(detector);
9139
10831
  else if (detector.cost === "io") this.ioDetectors.push(detector);
9140
10832
  else this.cheapDetectors.push(detector);
9141
10833
  }
10834
+ for (const id of this.shadowIds) {
10835
+ if (!detectors.some((detector) => detector.id === id)) {
10836
+ this.warn(
10837
+ `shadowDetectors names "${id}", which is not an installed detector, so nothing is being shadowed by that entry. Installed: ${detectors.map((detector) => detector.id).join(", ")}.`
10838
+ );
10839
+ }
10840
+ }
9142
10841
  if (options.shareConfirmations === true) {
9143
10842
  this.registry.onFirstSight = (state) => this.loadSharedConfirmations(state);
9144
10843
  }
@@ -9293,6 +10992,45 @@ var BotHandler = class {
9293
10992
  this.warn(`Actor "${key}" was cleared as human at runtime${attribute(context)}, until ${new Date(until).toISOString()}.`);
9294
10993
  this.events.emit("actor-change", { key, action: "clear", until, by: context.by });
9295
10994
  }
10995
+ /**
10996
+ * Tells the engine what the application answered.
10997
+ *
10998
+ * The one thing detection cannot see for itself. Every verdict here is reached *before*
10999
+ * the response exists — that is what makes it useful, since it can shape the response —
11000
+ * and so the status is knowledge only the application holds. Handed back, it closes the
11001
+ * oldest gap in reading a scanner: an actor whose requests are almost all misses is
11002
+ * looking for something rather than reading anything, and no amount of header analysis
11003
+ * shows that.
11004
+ *
11005
+ * Optional, and silent when the actor has already been forgotten. Nothing about
11006
+ * detection depends on it being called; supplying it sharpens `probe-volume` and
11007
+ * nothing else. The bundled Node adapter wires it up for you.
11008
+ */
11009
+ recordOutcome(facts, status) {
11010
+ if (!this.isIgnoredPath(facts.path) && !this.isAllowlisted(facts.ip)) {
11011
+ this.site?.recordOutcome(facts.path, status);
11012
+ }
11013
+ if (!Number.isFinite(status)) return;
11014
+ this.registry.peek(this.actorKeyFor(facts))?.recordOutcome(status);
11015
+ }
11016
+ /**
11017
+ * Gives an actor a name, or clears it with `undefined`.
11018
+ *
11019
+ * Detection never reads it — a label cannot make anybody more or less suspicious, and
11020
+ * that separation is deliberate: the moment a note changes a verdict, writing notes
11021
+ * becomes a way to be wrong about people at scale. It is for the humans reading the
11022
+ * dashboard, and it survives exactly as long as the actor does.
11023
+ *
11024
+ * Available from code so a deployment can label what it already knows — its own
11025
+ * monitoring, a partner's feed, the office egress — rather than waiting for somebody to
11026
+ * recognise the address twice.
11027
+ */
11028
+ labelActor(key, label, context = {}) {
11029
+ const state = this.registry.peek(key);
11030
+ if (state === void 0) return;
11031
+ state.setLabel(label);
11032
+ this.warn(`Actor "${key}" was ${label === void 0 ? "unlabelled" : `labelled "${state.label ?? ""}"`} at runtime${attribute(context)}.`);
11033
+ }
9296
11034
  /** Convenience for `updateRanges("crawler:<id>", …)`, matching a signature id. */
9297
11035
  updateCrawlerRanges(signatureId, entries, context = {}) {
9298
11036
  this.updateRanges(`crawler:${signatureId}`, entries, context);
@@ -9405,7 +11143,10 @@ var BotHandler = class {
9405
11143
  id: detector.id,
9406
11144
  description: detector.description,
9407
11145
  cost: detector.cost ?? "cheap",
9408
- stage: detector.stage ?? "always"
11146
+ stage: detector.stage ?? "always",
11147
+ // Present only when it is true, so a deployment shadowing nothing lists exactly
11148
+ // what it listed before.
11149
+ ...this.shadowIds.has(detector.id) ? { shadow: true } : {}
9409
11150
  }));
9410
11151
  }
9411
11152
  /** Recovers the client address from a socket address and headers, honouring the proxy config. */
@@ -9448,8 +11189,22 @@ var BotHandler = class {
9448
11189
  const state = record ? this.registry.observe(actorKey, facts) : detachedActor(actorKey, facts);
9449
11190
  const ua = parseUserAgent(facts.headers["user-agent"]);
9450
11191
  const signatureMatches = ua.lower.length > 0 ? this.signatures.matchAll(ua.lower) : [];
11192
+ for (const match of signatureMatches) state.noteIdentity(match.id, match.category, match.verification.kind !== "none");
11193
+ const marker = this.probe?.observe(facts, ua);
11194
+ if (marker !== void 0 && record) {
11195
+ state.noteMarker(marker.reading.kind === "valid", marker.reading.kind === "forged", marker.drift);
11196
+ }
11197
+ if (this.site !== void 0 && record) {
11198
+ const seenBefore = this.site.timesSeen(facts.path);
11199
+ this.site.record(facts.path, actorKey);
11200
+ if (seenBefore === 0) state.noteNovelPath();
11201
+ const step = walkStepOf(facts.path);
11202
+ if (step !== void 0) this.site.recordWalk(step.template, step.id, actorKey);
11203
+ }
9451
11204
  const context = {
9452
11205
  facts,
11206
+ marker,
11207
+ site: this.site,
9453
11208
  ua,
9454
11209
  actor: state.snapshot(facts.timestamp),
9455
11210
  state,
@@ -9461,21 +11216,22 @@ var BotHandler = class {
9461
11216
  shared: /* @__PURE__ */ new Map()
9462
11217
  };
9463
11218
  const evidence2 = [];
11219
+ const shadowEvidence = [];
9464
11220
  const failures = [];
9465
11221
  let pending;
9466
11222
  for (const detector of this.cheapDetectors) {
9467
- const inFlight = this.run(detector, context, evidence2, failures, 0);
11223
+ const inFlight = this.run(detector, context, evidence2, shadowEvidence, failures, 0);
9468
11224
  if (inFlight !== void 0) (pending ??= []).push(inFlight);
9469
11225
  }
9470
11226
  for (const detector of this.ioDetectors) {
9471
- const inFlight = this.run(detector, context, evidence2, failures, this.config.detectorTimeoutMs);
11227
+ const inFlight = this.run(detector, context, evidence2, shadowEvidence, failures, this.config.detectorTimeoutMs);
9472
11228
  if (inFlight !== void 0) (pending ??= []).push(inFlight);
9473
11229
  }
9474
11230
  if (pending !== void 0) await Promise.all(pending);
9475
11231
  if (signatureMatches.length > 0 && this.confirmingDetectors.length > 0) {
9476
11232
  let confirming;
9477
11233
  for (const detector of this.confirmingDetectors) {
9478
- const inFlight = this.run(detector, context, evidence2, failures, this.config.detectorTimeoutMs);
11234
+ const inFlight = this.run(detector, context, evidence2, shadowEvidence, failures, this.config.detectorTimeoutMs);
9479
11235
  if (inFlight !== void 0) (confirming ??= []).push(inFlight);
9480
11236
  }
9481
11237
  if (confirming !== void 0) await Promise.all(confirming);
@@ -9495,11 +11251,23 @@ var BotHandler = class {
9495
11251
  this.fail(error, "isHuman");
9496
11252
  }
9497
11253
  }
11254
+ if (evidence2.some((item) => item.detector === "probe-signature")) state.notePayloadProbe();
9498
11255
  const combined = combineEvidence(evidence2, {
9499
11256
  suspectThreshold: this.config.suspectThreshold,
9500
11257
  strictEvidence: this.config.strictEvidence,
9501
11258
  onEvidenceViolation: (message) => this.warn(message)
9502
11259
  });
11260
+ let shadowVerdict;
11261
+ if (shadowEvidence.length > 0) {
11262
+ const wouldBe = combineEvidence([...evidence2, ...shadowEvidence], {
11263
+ suspectThreshold: this.config.suspectThreshold,
11264
+ strictEvidence: this.config.strictEvidence,
11265
+ onEvidenceViolation: (message, item) => {
11266
+ if (item.shadow === true) this.warn(message);
11267
+ }
11268
+ });
11269
+ shadowVerdict = { verdict: wouldBe.verdict, botClass: wouldBe.botClass, score: wouldBe.score, certain: wouldBe.certain };
11270
+ }
9503
11271
  const actor = state.snapshot(facts.timestamp);
9504
11272
  if (combined.verdict === "confirmed-bot" && record) {
9505
11273
  state.confirmations++;
@@ -9515,10 +11283,13 @@ var BotHandler = class {
9515
11283
  certain: combined.certain,
9516
11284
  evidence: combined.botEvidence,
9517
11285
  humanEvidence: combined.humanEvidence,
11286
+ shadowEvidence: sortEvidence(shadowEvidence),
11287
+ ...shadowVerdict === void 0 ? {} : { shadowVerdict },
9518
11288
  actor,
9519
11289
  durationMs: this.config.clock.now() - started,
9520
11290
  failures,
9521
- facts
11291
+ facts,
11292
+ ...marker === void 0 ? {} : { marker }
9522
11293
  };
9523
11294
  if (!record) return assessment;
9524
11295
  this.meter?.recordAssessment(assessment);
@@ -9555,15 +11326,46 @@ var BotHandler = class {
9555
11326
  onChallenge: (event) => {
9556
11327
  this.meter?.recordChallenge(event);
9557
11328
  const state = this.registry.peek(assessment.actor.key);
9558
- if (state !== void 0) state.unsolvedChallenges++;
11329
+ if (state !== void 0) {
11330
+ state.unsolvedChallenges++;
11331
+ state.noteChallengeIssued(
11332
+ this.config.clock.now(),
11333
+ assessment.marker?.shape ?? identityShape(assessment.facts, parseUserAgent(assessment.facts.headers["user-agent"]))
11334
+ );
11335
+ }
9559
11336
  this.events.emit("challenge", { phase: event, actorKey: assessment.actor.key });
9560
11337
  }
9561
11338
  });
11339
+ const issued = this.markerFor(assessment);
11340
+ if (issued !== void 0) {
11341
+ if (outcome.kind === "continue" && outcome.responseHeaders?.["set-cookie"] === void 0) {
11342
+ outcome.responseHeaders = { ...outcome.responseHeaders, "set-cookie": issued };
11343
+ } else if (outcome.kind === "respond" && outcome.headers["set-cookie"] === void 0) {
11344
+ outcome.headers = { ...outcome.headers, "set-cookie": issued };
11345
+ }
11346
+ }
9562
11347
  if (this.notifications.enabled && (outcome.kind !== "continue" || outcome.delayMs !== void 0)) {
9563
11348
  this.notifications.emit({ type: "action", at: new Date(facts.timestamp).toISOString(), assessment, decision });
9564
11349
  }
9565
11350
  return { assessment, decision, outcome };
9566
11351
  }
11352
+ /**
11353
+ * The `Set-Cookie` this response should carry, if any.
11354
+ *
11355
+ * Nothing is issued to a client that already holds a valid marker, because a
11356
+ * `Set-Cookie` on every response makes every response uncacheable by shared caches —
11357
+ * a detection feature is not worth a site's cache-hit ratio. Nothing is issued to a
11358
+ * verified crawler either: Googlebot does not keep cookies, so a marker sent to it is
11359
+ * a header that will never come back and an issuance count that means nothing.
11360
+ */
11361
+ markerFor(assessment) {
11362
+ if (this.probe === void 0 || assessment.marker === void 0) return void 0;
11363
+ if (assessment.botClass === "verified-bot") return void 0;
11364
+ if (!this.probe.shouldIssue(assessment.marker)) return void 0;
11365
+ const state = this.registry.peek(assessment.actor.key);
11366
+ state?.noteMarkerIssued();
11367
+ return this.probe.issue(assessment.marker);
11368
+ }
9567
11369
  /** True when this request is the challenge verification endpoint. */
9568
11370
  isChallengeEndpoint(facts) {
9569
11371
  return this.challenge !== void 0 && facts.method === "POST" && facts.path === this.challenge.verifyPath;
@@ -9582,6 +11384,9 @@ var BotHandler = class {
9582
11384
  this.meter?.recordChallenge(outcome.ok ? "solved" : "rejected");
9583
11385
  if (outcome.ok) this.meter?.recordClearance(outcome.level);
9584
11386
  else this.meter?.recordChallengeRejection(outcome.reason);
11387
+ if (!outcome.ok && outcome.signal !== void 0) {
11388
+ this.registry.peek(actorKey)?.noteChallengeAnomaly(outcome.signal);
11389
+ }
9585
11390
  if (outcome.interactionScore !== void 0) this.meter?.recordInteractionScore(outcome.interactionScore);
9586
11391
  if (outcome.ok) this.audit?.recordChallengeSolved(this.config.clock.now());
9587
11392
  this.events.emit("challenge", {
@@ -9618,7 +11423,9 @@ var BotHandler = class {
9618
11423
  * await. Failures are absorbed here in both paths: a detector can throw, reject or
9619
11424
  * hang, and none of those may reach the request.
9620
11425
  */
9621
- run(detector, context, sink, failures, timeoutMs) {
11426
+ run(detector, context, sink, shadowSink, failures, timeoutMs) {
11427
+ const shadowed = this.shadowIds.has(detector.id);
11428
+ const target = shadowed ? shadowSink : sink;
9622
11429
  const startedAt = this.timing ? this.config.clock.now() : 0;
9623
11430
  let raw;
9624
11431
  try {
@@ -9629,7 +11436,7 @@ var BotHandler = class {
9629
11436
  return void 0;
9630
11437
  }
9631
11438
  if (!(raw instanceof Promise)) {
9632
- this.collect(raw, sink);
11439
+ this.collect(raw, target, shadowed);
9633
11440
  if (startedAt !== 0) this.meter?.recordDetectorTiming(detector.id, this.config.clock.now() - startedAt);
9634
11441
  return void 0;
9635
11442
  }
@@ -9644,7 +11451,7 @@ var BotHandler = class {
9644
11451
  this.events.emit("detector-failure", { detector: detector.id, reason: "timeout", message, requestId: "" });
9645
11452
  return;
9646
11453
  }
9647
- this.collect(result, sink);
11454
+ this.collect(result, target, shadowed);
9648
11455
  },
9649
11456
  (error) => {
9650
11457
  if (startedAt !== 0) this.meter?.recordDetectorTiming(detector.id, this.config.clock.now() - startedAt);
@@ -9652,13 +11459,14 @@ var BotHandler = class {
9652
11459
  }
9653
11460
  );
9654
11461
  }
9655
- collect(result, sink) {
11462
+ collect(result, sink, shadowed = false) {
9656
11463
  if (result === void 0 || result === null) return;
11464
+ const mark = (item) => shadowed ? { ...sanitize(item), shadow: true } : sanitize(item);
9657
11465
  if (Array.isArray(result)) {
9658
- for (let i = 0; i < result.length; i++) sink.push(result[i]);
11466
+ for (let i = 0; i < result.length; i++) sink.push(mark(result[i]));
9659
11467
  return;
9660
11468
  }
9661
- sink.push(result);
11469
+ sink.push(mark(result));
9662
11470
  }
9663
11471
  recordFailure(detector, failures, error, requestId = "") {
9664
11472
  const message = error instanceof Error ? error.message : String(error);
@@ -9678,10 +11486,16 @@ var BotHandler = class {
9678
11486
  certain: false,
9679
11487
  evidence: [],
9680
11488
  humanEvidence: [],
11489
+ shadowEvidence: [],
9681
11490
  actor: existing?.snapshot(facts.timestamp) ?? {
9682
11491
  key: actorKey,
9683
11492
  requests: 0,
9684
11493
  distinctPaths: 0,
11494
+ distinctQueries: 0,
11495
+ queriesSaturated: false,
11496
+ methodsSeen: ["GET"],
11497
+ responses: 0,
11498
+ misses: 0,
9685
11499
  firstSeen: facts.timestamp,
9686
11500
  lastSeen: facts.timestamp,
9687
11501
  priorConfirmations: 0,
@@ -9753,6 +11567,12 @@ var MAX_V6_BLOCK = 19;
9753
11567
  var MAX_PREFIXES = 1e4;
9754
11568
  var MAX_BYTES = 4 * 1024 * 1024;
9755
11569
  async function fetchCrawlerRanges(source, options = {}) {
11570
+ return fetchPrefixes(source, options, { maxPrefixes: MAX_PREFIXES, subject: `a crawler's address list`, id: source.id });
11571
+ }
11572
+ async function fetchAddressList(source, options = {}) {
11573
+ return fetchPrefixes(source, options, { maxPrefixes: options.maxPrefixes ?? 1e5, subject: "an address list", id: source.id });
11574
+ }
11575
+ async function fetchPrefixes(source, options, limits) {
9756
11576
  const url = new URL(source.url);
9757
11577
  if (url.protocol !== "https:") throw new ConfigError(`Crawler ranges must be published over HTTPS. "${source.url}" is not.`);
9758
11578
  const fetcher = options.fetch ?? globalThis.fetch;
@@ -9763,10 +11583,40 @@ async function fetchCrawlerRanges(source, options = {}) {
9763
11583
  redirect: "follow"
9764
11584
  });
9765
11585
  if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
9766
- const text = await response.text();
9767
- if (text.length > MAX_BYTES) throw new Error(`the list is ${Math.round(text.length / 1024)} kB, which is not a list of prefixes`);
11586
+ const text = await readCapped(response);
9768
11587
  const prefixes = text.trimStart().startsWith("{") ? fromJson(text) : fromLines(text);
9769
- return validate(prefixes, source.id);
11588
+ return validate(prefixes, limits);
11589
+ }
11590
+ async function readCapped(response) {
11591
+ const declared = Number(response.headers.get("content-length"));
11592
+ if (Number.isFinite(declared) && declared > MAX_BYTES) {
11593
+ throw new Error(`the list declares ${Math.round(declared / 1024)} kB, which is not a list of prefixes`);
11594
+ }
11595
+ const body = response.body;
11596
+ if (body === null || body === void 0 || typeof body.getReader !== "function") {
11597
+ const whole = await response.text();
11598
+ if (whole.length > MAX_BYTES) throw new Error(`the list is ${Math.round(whole.length / 1024)} kB, which is not a list of prefixes`);
11599
+ return whole;
11600
+ }
11601
+ const reader = body.getReader();
11602
+ const decoder = new TextDecoder();
11603
+ let text = "";
11604
+ let bytes = 0;
11605
+ try {
11606
+ while (true) {
11607
+ const { done, value } = await reader.read();
11608
+ if (done) break;
11609
+ bytes += value.byteLength;
11610
+ if (bytes > MAX_BYTES) {
11611
+ throw new Error(`the list is over ${Math.round(MAX_BYTES / 1024)} kB, which is not a list of prefixes`);
11612
+ }
11613
+ text += decoder.decode(value, { stream: true });
11614
+ }
11615
+ } finally {
11616
+ await reader.cancel().catch(() => {
11617
+ });
11618
+ }
11619
+ return text + decoder.decode();
9770
11620
  }
9771
11621
  function fromJson(text) {
9772
11622
  const document = JSON.parse(text);
@@ -9779,24 +11629,24 @@ function fromJson(text) {
9779
11629
  return out;
9780
11630
  }
9781
11631
  function fromLines(text) {
9782
- return text.split("\n").map((line) => line.split("#")[0]?.trim() ?? "").filter((line) => line !== "");
11632
+ return text.split("\n").map((line) => (line.split("#")[0] ?? "").split(";")[0]?.trim() ?? "").filter((line) => line !== "");
9783
11633
  }
9784
- function validate(prefixes, id) {
11634
+ function validate(prefixes, limits) {
9785
11635
  if (prefixes.length === 0) throw new Error("the list is empty");
9786
- if (prefixes.length > MAX_PREFIXES) throw new Error(`${prefixes.length} prefixes is not a crawler's address list`);
11636
+ if (prefixes.length > limits.maxPrefixes) throw new Error(`${prefixes.length} prefixes is not ${limits.subject}`);
9787
11637
  const accepted = [];
9788
11638
  for (const prefix of prefixes) {
9789
11639
  const cidr = parseCidr(prefix);
9790
11640
  if (cidr === void 0 || cidr === null) continue;
9791
11641
  const isV4 = cidr.bytes.length === 4;
9792
11642
  if (cidr.prefix < (isV4 ? MAX_V4_BLOCK : MAX_V6_BLOCK)) {
9793
- throw new Error(`"${prefix}" covers more of the internet than any crawler owns \u2014 refusing the whole list rather than verifying strangers`);
11643
+ throw new Error(`"${prefix}" covers more of the internet than any published list should \u2014 refusing the whole list rather than acting on it`);
9794
11644
  }
9795
11645
  accepted.push(prefix);
9796
11646
  }
9797
11647
  if (accepted.length === 0) throw new Error(`nothing in the list parsed as an address or CIDR (first entry: "${prefixes[0]}")`);
9798
11648
  const set = new IpRangeSet(accepted);
9799
- if (set.size === 0) throw new Error(`nothing in the list loaded for ${id}`);
11649
+ if (set.size === 0) throw new Error(`nothing in the list loaded for ${limits.id}`);
9800
11650
  return accepted;
9801
11651
  }
9802
11652
  async function refreshCrawlerRanges(handler, options = {}) {
@@ -9859,11 +11709,40 @@ var RedisStore = class {
9859
11709
  client;
9860
11710
  prefix;
9861
11711
  clock;
11712
+ /**
11713
+ * Counts one request into the fixed window `key` is bucketed by.
11714
+ *
11715
+ * This used to be `INCR`, then `PEXPIRE` when the count came back as 1 — which is
11716
+ * correct only if the process survives long enough to send the second command. A
11717
+ * deploy, an OOM kill or a dropped connection in between left a counter key behind
11718
+ * with no expiry at all, and nothing would ever clean it up: the next request falls
11719
+ * into the next bucket, under a different key, so the orphan is never touched again.
11720
+ * One per unlucky restart is nothing; the point is that it accumulates forever, in a
11721
+ * Redis the operator may well be running with `noeviction`.
11722
+ *
11723
+ * So the expiry is armed by the command that *creates* the key rather than by a
11724
+ * follow-up. `SET … PX … NX` writes the seed only if nothing is there, always with a
11725
+ * lifetime, and does nothing at all once the bucket exists — so it neither costs a
11726
+ * count nor re-arms a window under load. The `INCR` is issued without waiting for its
11727
+ * reply, so both commands are on the wire together and this stays one round trip.
11728
+ * Ordering holds because a Redis client writes commands to its connection in call
11729
+ * order and Redis executes them in arrival order, which means the key has a lifetime
11730
+ * from the instant it exists.
11731
+ *
11732
+ * Not a Lua script, which would make it a single command: `eval` is the one thing
11733
+ * `ioredis` and `node-redis` spell differently enough that this interface could not
11734
+ * describe both, and staying client-agnostic is worth more than the last round trip.
11735
+ */
9862
11736
  async increment(key, windowMs) {
9863
11737
  const bucket = Math.floor(this.clock.now() / windowMs);
9864
11738
  const full = `${this.prefix}c:${key}:${bucket}`;
11739
+ const remaining = (bucket + 1) * windowMs - this.clock.now();
11740
+ const ttl = Number.isFinite(remaining) && remaining > 0 ? remaining : windowMs;
11741
+ const armed = this.client.set(full, "0", "PX", ttl, "NX");
11742
+ armed.catch(() => {
11743
+ });
9865
11744
  const count = await this.client.incr(full);
9866
- if (count === 1) await this.client.pexpire(full, windowMs);
11745
+ await armed;
9867
11746
  return count;
9868
11747
  }
9869
11748
  async consumeOnce(key, ttlMs) {
@@ -9895,6 +11774,10 @@ function consoleNotifier(options = {}) {
9895
11774
  return;
9896
11775
  }
9897
11776
  const { assessment, decision } = event;
11777
+ if (event.error !== void 0) {
11778
+ target.error(`[bothandler] error ${event.error.source} \u2014 ${event.error.message}`);
11779
+ return;
11780
+ }
9898
11781
  if (assessment === void 0) {
9899
11782
  target.warn(`[bothandler] ${event.type} ${event.anomaly?.id ?? "unknown"} \u2014 ${event.anomaly?.summary ?? ""}`);
9900
11783
  return;
@@ -10066,6 +11949,7 @@ export {
10066
11949
  MAX_DIFFICULTY,
10067
11950
  MAX_USER_AGENT_LENGTH,
10068
11951
  ManualClock,
11952
+ MarkerProbe,
10069
11953
  MemoryStore,
10070
11954
  Metrics,
10071
11955
  MultiPatternMatcher,
@@ -10076,6 +11960,7 @@ export {
10076
11960
  RedisStore,
10077
11961
  SCORE_BUCKETS,
10078
11962
  SPECIAL_USE_RANGES,
11963
+ SiteProfile,
10079
11964
  TERMINAL_ACTIONS,
10080
11965
  TRAP_FIELD_SOURCE,
10081
11966
  TrafficAudit,
@@ -10086,9 +11971,12 @@ export {
10086
11971
  agentFor,
10087
11972
  allowCrawlers,
10088
11973
  analyseMovement,
11974
+ blendedIdentityDetector,
10089
11975
  browsingCoherenceDetector,
10090
11976
  cachingResolver,
10091
11977
  cadenceDetector,
11978
+ challengeIntegrityDetector,
11979
+ challengeReactionDetector,
10092
11980
  cidrContains,
10093
11981
  claimsBrowser,
10094
11982
  clampDifficulty,
@@ -10107,8 +11995,10 @@ export {
10107
11995
  declineAiTraining,
10108
11996
  defaultDetectors,
10109
11997
  defineHandler,
11998
+ distributedWalkDetector,
10110
11999
  evidence,
10111
12000
  executeAction,
12001
+ fetchAddressList,
10112
12002
  fetchCrawlerRanges,
10113
12003
  fetchMetadataDetector,
10114
12004
  formatIp,
@@ -10117,6 +12007,8 @@ export {
10117
12007
  headerIntegrityDetector,
10118
12008
  headerOrderDetector,
10119
12009
  headerOrderFingerprint,
12010
+ idEnumerationDetector,
12011
+ identityDriftDetector,
10120
12012
  identityRotationDetector,
10121
12013
  independentStrongSignals,
10122
12014
  indexSignatures,
@@ -10124,6 +12016,10 @@ export {
10124
12016
  ipIntelligenceDetector,
10125
12017
  isSpecialUse,
10126
12018
  issueToken,
12019
+ markerFanoutDetector,
12020
+ markerIntegrityDetector,
12021
+ markerPersistenceDetector,
12022
+ missBaselineDetector,
10127
12023
  monitorOnly,
10128
12024
  networkKey,
10129
12025
  newChallenge,
@@ -10132,15 +12028,19 @@ export {
10132
12028
  noisyOr,
10133
12029
  normalizeIp,
10134
12030
  notifyJsNotifier,
12031
+ parameterSweepDetector,
10135
12032
  parseAcceptLanguage,
10136
12033
  parseCidr,
10137
12034
  parseCookies,
10138
12035
  parseInteractionReport,
10139
12036
  parseIp,
10140
12037
  parseUserAgent,
12038
+ pathCampaignDetector,
12039
+ pathNoveltyDetector,
10141
12040
  pickTranslation,
10142
12041
  probeShapeFor,
10143
12042
  probeSignatureDetector,
12043
+ probeVolumeDetector,
10144
12044
  protectApi,
10145
12045
  protectAuth,
10146
12046
  protectContent,
@@ -10167,8 +12067,10 @@ export {
10167
12067
  startCrawlerRangeRefresh,
10168
12068
  startDashboard,
10169
12069
  systemClock,
12070
+ targetIntegrityDetector,
10170
12071
  tlsFingerprintDetector,
10171
12072
  toPrometheus,
12073
+ transportCoherenceDetector,
10172
12074
  trapDetector,
10173
12075
  trapRobotsEntries,
10174
12076
  uaCoherenceDetector,