@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
@@ -125,12 +125,23 @@ var init_dom = __esm({
125
125
  });
126
126
 
127
127
  // src/dashboard/client/boot.ts
128
- var raw, BOOT, API, SECTIONS;
128
+ var boot_exports = {};
129
+ __export(boot_exports, {
130
+ API: () => API,
131
+ BOOT: () => BOOT,
132
+ SECTIONS: () => SECTIONS,
133
+ applyBoot: () => applyBoot
134
+ });
135
+ function applyBoot(next) {
136
+ BOOT = next;
137
+ API = next.base;
138
+ SECTIONS = next.sections;
139
+ }
140
+ var FALLBACK, BOOT, API, SECTIONS;
129
141
  var init_boot = __esm({
130
142
  "src/dashboard/client/boot.ts"() {
131
143
  "use strict";
132
- raw = globalThis.__BOOTSTRAP__;
133
- BOOT = raw ?? {
144
+ FALLBACK = {
134
145
  base: "",
135
146
  title: "bothandlerjs",
136
147
  allowReset: false,
@@ -141,6 +152,7 @@ var init_boot = __esm({
141
152
  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 },
142
153
  links: []
143
154
  };
155
+ BOOT = globalThis.__BOOTSTRAP__ ?? FALLBACK;
144
156
  API = BOOT.base;
145
157
  SECTIONS = BOOT.sections;
146
158
  }
@@ -215,48 +227,172 @@ var init_outcome = __esm({
215
227
  });
216
228
 
217
229
  // src/dashboard/client/query.ts
230
+ function suggestFor(input, caret) {
231
+ const before = input.slice(0, caret);
232
+ const start2 = Math.max(before.lastIndexOf(" ") + 1, 0);
233
+ const token = before.slice(start2);
234
+ const to = caret;
235
+ const negated = token.startsWith("-") || token.startsWith("!");
236
+ const body = negated ? token.slice(1) : token;
237
+ const colon = body.indexOf(":");
238
+ if (colon === -1) {
239
+ const partial2 = body.toLowerCase();
240
+ if (partial2.startsWith("$")) {
241
+ return { options: OPERATORS.filter((name) => name.startsWith(partial2)), from: start2, to };
242
+ }
243
+ const options2 = FIELD_NAMES.filter((name) => name.startsWith(partial2)).map((name) => `${negated ? token[0] : ""}${name}:`);
244
+ return { options: options2, from: start2, to };
245
+ }
246
+ const field2 = body.slice(0, colon).toLowerCase();
247
+ const values = FIELD_VALUES[field2];
248
+ if (values === void 0) return { options: [], from: start2, to };
249
+ const partial = body.slice(colon + 1).toLowerCase();
250
+ const prefix = `${negated ? token[0] : ""}${field2}:`;
251
+ const forms = partial.startsWith("$") ? ["$in(", "$notin("].filter((form) => form.startsWith(partial)).map((form) => `${prefix}${form}`) : [];
252
+ const options = [...forms, ...values.filter((value) => value.toLowerCase().startsWith(partial)).map((value) => `${prefix}${value}`)];
253
+ return { options, from: start2, to };
254
+ }
218
255
  function tokenize(input) {
219
256
  const tokens = [];
220
257
  let current = "";
221
258
  let quoted = false;
222
- for (const character of input) {
259
+ let depth = 0;
260
+ const flush = () => {
261
+ if (current === "") return;
262
+ const lower = current.toLowerCase();
263
+ if (lower === "$and" || lower === "$or" || lower === "$not") tokens.push({ kind: "op", op: lower.slice(1) });
264
+ else tokens.push({ kind: "word", text: current });
265
+ current = "";
266
+ };
267
+ for (let i = 0; i < input.length; i++) {
268
+ const character = input[i];
223
269
  if (character === '"') {
224
270
  quoted = !quoted;
225
271
  continue;
226
272
  }
227
- if (!quoted && /\s/.test(character)) {
228
- if (current !== "") tokens.push(current);
229
- current = "";
273
+ if (quoted) {
274
+ current += character;
275
+ continue;
276
+ }
277
+ if (depth > 0) {
278
+ current += character;
279
+ if (character === "(") depth++;
280
+ else if (character === ")") {
281
+ depth--;
282
+ if (depth === 0) flush();
283
+ }
284
+ continue;
285
+ }
286
+ if (character === "(") {
287
+ if (/\$(in|notin)$/i.test(current)) {
288
+ current += character;
289
+ depth = 1;
290
+ continue;
291
+ }
292
+ flush();
293
+ tokens.push({ kind: "open" });
294
+ continue;
295
+ }
296
+ if (character === ")") {
297
+ flush();
298
+ tokens.push({ kind: "close" });
299
+ continue;
300
+ }
301
+ if (/\s/.test(character)) {
302
+ flush();
230
303
  continue;
231
304
  }
232
305
  current += character;
233
306
  }
234
- if (current !== "") tokens.push(current);
307
+ flush();
235
308
  return tokens;
236
309
  }
237
- function parseQuery(input) {
238
- const terms = [];
239
- for (const token of tokenize(input.trim())) {
240
- const negated = token.startsWith("-") || token.startsWith("!");
241
- const body = negated ? token.slice(1) : token;
242
- if (body === "") continue;
243
- const colon = body.indexOf(":");
244
- const name = colon === -1 ? "" : body.slice(0, colon).toLowerCase();
245
- const field2 = FIELDS[name];
246
- if (colon === -1 || field2 === void 0) {
247
- terms.push({ field: void 0, value: body.toLowerCase(), negated });
248
- continue;
310
+ function toTerm(word) {
311
+ const negated = word.startsWith("-") || word.startsWith("!");
312
+ const body = negated ? word.slice(1) : word;
313
+ if (body === "") return void 0;
314
+ const colon = body.indexOf(":");
315
+ const name = colon === -1 ? "" : body.slice(0, colon).toLowerCase();
316
+ const field2 = FIELDS[name];
317
+ if (colon === -1 || field2 === void 0) return { field: void 0, value: body.toLowerCase(), negated };
318
+ const rest = body.slice(colon + 1);
319
+ const set = /^\$(in|notin)\(([\s\S]*)$/i.exec(rest);
320
+ if (set !== null) {
321
+ const inside = set[2].endsWith(")") ? set[2].slice(0, -1) : set[2];
322
+ const values = inside.split(",").map((entry) => entry.trim().toLowerCase()).filter((entry) => entry !== "");
323
+ const inverted = set[1].toLowerCase() === "notin";
324
+ return { field: field2, value: values[0] ?? "", negated: negated !== inverted, values };
325
+ }
326
+ let value = rest.toLowerCase();
327
+ let compare;
328
+ if (NUMERIC.has(field2)) {
329
+ compare = value.startsWith(">") ? ">" : value.startsWith("<") ? "<" : "=";
330
+ if (compare !== "=") value = value.slice(1);
331
+ }
332
+ if (value === "") return void 0;
333
+ return { field: field2, value, negated, compare };
334
+ }
335
+ function parseTokens(tokens) {
336
+ let at = 0;
337
+ let depth = 0;
338
+ const parseUnary = () => {
339
+ const token = tokens[at];
340
+ if (token === void 0) return void 0;
341
+ if (token.kind === "op" && token.op === "not") {
342
+ at++;
343
+ const of = parseUnary();
344
+ return of === void 0 ? void 0 : { kind: "not", of };
249
345
  }
250
- let value = body.slice(colon + 1).toLowerCase();
251
- let compare;
252
- if (NUMERIC.has(field2)) {
253
- compare = value.startsWith(">") ? ">" : value.startsWith("<") ? "<" : "=";
254
- if (compare !== "=") value = value.slice(1);
346
+ if (token.kind === "open") {
347
+ at++;
348
+ if (depth >= MAX_GROUP_DEPTH) return void 0;
349
+ depth++;
350
+ const inner = parseOr();
351
+ depth--;
352
+ if (tokens[at]?.kind === "close") at++;
353
+ return inner.kind === "all" ? void 0 : inner;
255
354
  }
256
- if (value === "") continue;
257
- terms.push({ field: field2, value, negated, compare });
258
- }
259
- return terms;
355
+ if (token.kind === "close") return void 0;
356
+ if (token.kind === "op") {
357
+ at++;
358
+ return parseUnary();
359
+ }
360
+ at++;
361
+ const term = toTerm(token.text);
362
+ return term === void 0 ? void 0 : { kind: "term", term };
363
+ };
364
+ const parseAnd = () => {
365
+ const parts = [];
366
+ while (at < tokens.length) {
367
+ const token = tokens[at];
368
+ if (token === void 0 || token.kind === "close") break;
369
+ if (token.kind === "op" && token.op === "or") break;
370
+ if (token.kind === "op" && token.op === "and") {
371
+ at++;
372
+ continue;
373
+ }
374
+ const before = at;
375
+ const part = parseUnary();
376
+ if (part !== void 0) parts.push(part);
377
+ if (at === before) at++;
378
+ }
379
+ return parts.length === 0 ? { kind: "all" } : parts.length === 1 ? parts[0] : { kind: "and", parts };
380
+ };
381
+ const parseOr = () => {
382
+ const parts = [parseAnd()];
383
+ while (tokens[at]?.kind === "op" && tokens[at].op === "or") {
384
+ at++;
385
+ parts.push(parseAnd());
386
+ }
387
+ const real = parts.filter((part) => part.kind !== "all");
388
+ if (real.length === 0) return { kind: "all" };
389
+ return real.length === 1 ? real[0] : { kind: "or", parts: real };
390
+ };
391
+ return parseOr();
392
+ }
393
+ function parseFilter(input) {
394
+ const trimmed = input.trim();
395
+ return parseTokens(tokenize(trimmed.length > MAX_QUERY_CHARS ? trimmed.slice(0, MAX_QUERY_CHARS) : trimmed));
260
396
  }
261
397
  function searchableText(entry) {
262
398
  const parts = [
@@ -310,6 +446,11 @@ function fieldValue(entry, field2) {
310
446
  }
311
447
  function matchesTerm(term, entry, haystack) {
312
448
  if (term.field === void 0) return haystack.includes(term.value);
449
+ if (term.values !== void 0) {
450
+ if (term.values.length === 0) return false;
451
+ const actual = fieldValue(entry, term.field).toLowerCase();
452
+ return term.values.some((value) => actual.includes(value));
453
+ }
313
454
  if (term.field === "score") {
314
455
  const wanted = Number(term.value);
315
456
  if (Number.isNaN(wanted)) return false;
@@ -319,11 +460,19 @@ function matchesTerm(term, entry, haystack) {
319
460
  }
320
461
  return fieldValue(entry, term.field).toLowerCase().includes(term.value);
321
462
  }
322
- function matchesQuery(terms, entry, haystack) {
323
- for (const term of terms) {
324
- if (matchesTerm(term, entry, haystack) === term.negated) return false;
463
+ function matches(filter, entry, haystack) {
464
+ switch (filter.kind) {
465
+ case "all":
466
+ return true;
467
+ case "term":
468
+ return matchesTerm(filter.term, entry, haystack) !== filter.term.negated;
469
+ case "not":
470
+ return !matches(filter.of, entry, haystack);
471
+ case "and":
472
+ return filter.parts.every((part) => matches(part, entry, haystack));
473
+ case "or":
474
+ return filter.parts.some((part) => matches(part, entry, haystack));
325
475
  }
326
- return true;
327
476
  }
328
477
  function matchesFilter(filter, entry) {
329
478
  switch (filter) {
@@ -345,7 +494,7 @@ function matchesFilter(filter, entry) {
345
494
  return true;
346
495
  }
347
496
  }
348
- var FIELDS, NUMERIC;
497
+ var FIELDS, NUMERIC, FIELD_NAMES, FIELD_VALUES, OPERATORS, MAX_GROUP_DEPTH, MAX_QUERY_CHARS;
349
498
  var init_query = __esm({
350
499
  "src/dashboard/client/query.ts"() {
351
500
  "use strict";
@@ -374,6 +523,19 @@ var init_query = __esm({
374
523
  certain: "certain"
375
524
  };
376
525
  NUMERIC = /* @__PURE__ */ new Set(["score"]);
526
+ FIELD_NAMES = Object.keys(FIELDS).sort();
527
+ FIELD_VALUES = {
528
+ verdict: ["confirmed-bot", "verified-bot", "suspected-bot", "human", "unknown"],
529
+ class: ["scanner", "scraper", "impersonator", "automation", "http-client", "declared-bot", "verified-bot", "human", "unknown"],
530
+ botclass: ["scanner", "scraper", "impersonator", "automation", "http-client", "declared-bot", "verified-bot", "human", "unknown"],
531
+ action: ["allow", "tag", "log", "challenge", "rate-limit", "delay", "block", "drop", "redirect"],
532
+ outcome: ["allow", "mitigate", "deny", "pending"],
533
+ certain: ["true", "false"],
534
+ method: ["GET", "POST", "HEAD", "PUT", "PATCH", "DELETE", "OPTIONS"]
535
+ };
536
+ OPERATORS = ["$and", "$or", "$not", "$in", "$notin"];
537
+ MAX_GROUP_DEPTH = 32;
538
+ MAX_QUERY_CHARS = 8192;
377
539
  }
378
540
  });
379
541
 
@@ -407,7 +569,12 @@ function clearFeed() {
407
569
  }
408
570
  function setSearch(value) {
409
571
  state.search = value;
410
- state.terms = parseQuery(value);
572
+ state.query = parseFilter(value);
573
+ resetPaging();
574
+ }
575
+ function setTimeframe(from, to) {
576
+ state.fromMs = from;
577
+ state.toMs = to;
411
578
  resetPaging();
412
579
  }
413
580
  function resetPaging() {
@@ -421,14 +588,16 @@ function textOf(row) {
421
588
  function sortRows() {
422
589
  state.rows.sort((a, b) => a.entry.at - b.entry.at);
423
590
  }
424
- function matches(row) {
425
- return matchesFilter(state.filter, row.entry) && matchesQuery(state.terms, row.entry, textOf(row));
591
+ function matches2(row) {
592
+ if (state.fromMs !== void 0 && row.entry.at < state.fromMs) return false;
593
+ if (state.toMs !== void 0 && row.entry.at > state.toMs) return false;
594
+ return matchesFilter(state.filter, row.entry) && matches(state.query, row.entry, textOf(row));
426
595
  }
427
596
  function matchingRows(limit = Number.POSITIVE_INFINITY) {
428
597
  const shown = [];
429
598
  for (let i = state.rows.length - 1; i >= 0 && shown.length < limit; i--) {
430
599
  const row = state.rows[i];
431
- if (row !== void 0 && matches(row)) shown.push(row);
600
+ if (row !== void 0 && matches2(row)) shown.push(row);
432
601
  }
433
602
  return shown;
434
603
  }
@@ -450,7 +619,7 @@ function goToFeedPage(page) {
450
619
  }
451
620
  function matchingCount() {
452
621
  let count = 0;
453
- for (const row of state.rows) if (matches(row)) count++;
622
+ for (const row of state.rows) if (matches2(row)) count++;
454
623
  return count;
455
624
  }
456
625
  function oldestAt() {
@@ -501,10 +670,11 @@ var init_store = __esm({
501
670
  policy: void 0,
502
671
  actors: [],
503
672
  actorsTracked: 0,
673
+ actorScope: "tracked",
504
674
  paused: false,
505
675
  filter: "all",
506
676
  search: "",
507
- terms: [],
677
+ query: { kind: "all" },
508
678
  tab: "live",
509
679
  open: /* @__PURE__ */ new Set(),
510
680
  actor: void 0,
@@ -521,6 +691,8 @@ var init_store = __esm({
521
691
  actorsPage: 0,
522
692
  feedPageSize: 50,
523
693
  actorsPageSize: 25,
694
+ fromMs: void 0,
695
+ toMs: void 0,
524
696
  caughtUp: 0
525
697
  };
526
698
  }
@@ -552,6 +724,49 @@ var init_api = __esm({
552
724
  }
553
725
  });
554
726
 
727
+ // src/dashboard/client/saved.ts
728
+ function read(key, fallback) {
729
+ if (isEmbedded()) return fallback;
730
+ try {
731
+ const raw = localStorage.getItem(key);
732
+ if (raw === null) return fallback;
733
+ const parsed = JSON.parse(raw);
734
+ return Array.isArray(parsed) ? parsed : fallback;
735
+ } catch {
736
+ return fallback;
737
+ }
738
+ }
739
+ function write(key, value) {
740
+ if (isEmbedded()) return;
741
+ try {
742
+ localStorage.setItem(key, JSON.stringify(value));
743
+ } catch {
744
+ }
745
+ }
746
+ function savedFilters() {
747
+ return read(FILTERS_KEY, []).filter((entry) => typeof entry?.name === "string");
748
+ }
749
+ function saveFilter(entry) {
750
+ const kept = savedFilters().filter((existing) => existing.name !== entry.name);
751
+ const next = [entry, ...kept].slice(0, MAX_SAVED);
752
+ write(FILTERS_KEY, next);
753
+ return next;
754
+ }
755
+ function deleteFilter(name) {
756
+ const next = savedFilters().filter((entry) => entry.name !== name);
757
+ write(FILTERS_KEY, next);
758
+ return next;
759
+ }
760
+ var FILTERS_KEY, MAX_SAVED;
761
+ var init_saved = __esm({
762
+ "src/dashboard/client/saved.ts"() {
763
+ "use strict";
764
+ init_dom();
765
+ FILTERS_KEY = "bothandler.filters";
766
+ MAX_SAVED = 50;
767
+ }
768
+ });
769
+
555
770
  // src/dashboard/client/format.ts
556
771
  function n(value) {
557
772
  return numbers.format(value ?? 0);
@@ -715,7 +930,7 @@ var init_replay = __esm({
715
930
  function isConfirming() {
716
931
  return armed > 0;
717
932
  }
718
- function actorActions(key, after) {
933
+ function actorActions(key, after, current) {
719
934
  if (!BOOT.allowActing) return [];
720
935
  const forget = el("button", null, "Forget");
721
936
  forget.title = "Discard this actor's history \u2014 the cure for a false positive that has stuck";
@@ -727,7 +942,76 @@ function actorActions(key, after) {
727
942
  clear2.addEventListener("click", () => {
728
943
  void act({ key, action: "clear", forMs: 60 * 6e4 }, `Cleared ${key}`, "Held as human for an hour, then reassessed.", after);
729
944
  });
730
- return [confirmingButton("Allowlist", `Allowlist ${key} \u2014 it stops being assessed at all`, () => allowlist(key, after)), forget, clear2];
945
+ const label2 = labelControl(key, current, after);
946
+ return [confirmingButton("Allowlist", `Allowlist ${key} \u2014 it stops being assessed at all`, () => allowlist(key, after)), forget, clear2, label2];
947
+ }
948
+ function labelControl(key, current, after) {
949
+ const host = el("span", "label-edit");
950
+ const button = el("button", null, current === void 0 ? "Label" : "Relabel");
951
+ button.title = "Give this actor a name, for whoever reads this next. It never changes a verdict.";
952
+ const commit = (value) => {
953
+ const trimmed = value.trim().slice(0, 120);
954
+ void act(
955
+ { key, action: "label", ...trimmed === "" ? {} : { label: trimmed } },
956
+ trimmed === "" ? `Cleared the label on ${key}` : `Labelled ${key}`,
957
+ trimmed === "" ? "It shows as its address again." : `Shown as "${trimmed}" wherever it appears.`,
958
+ after
959
+ );
960
+ };
961
+ button.addEventListener("click", () => {
962
+ const container2 = host.parentElement;
963
+ clear(host);
964
+ const input = el("input", "label-input");
965
+ input.type = "text";
966
+ input.value = current ?? "";
967
+ input.placeholder = "A name for this client";
968
+ input.setAttribute("aria-label", `Name for ${key}`);
969
+ input.maxLength = 120;
970
+ const save = el("button", "label-save", "Save");
971
+ save.title = "Save this name";
972
+ const cancel = el("button", null, "Cancel");
973
+ cancel.title = "Leave the name as it was";
974
+ input.title = "Enter to save, Escape to cancel";
975
+ armed++;
976
+ container2?.classList.add("editing");
977
+ let settled = false;
978
+ const finish = (accept) => {
979
+ if (settled) return;
980
+ settled = true;
981
+ armed--;
982
+ container2?.classList.remove("editing");
983
+ if (accept) commit(input.value);
984
+ else {
985
+ clear(host);
986
+ host.appendChild(button);
987
+ }
988
+ };
989
+ for (const control of [input, save, cancel]) {
990
+ control.addEventListener("keydown", (event) => {
991
+ const pressed = event.key;
992
+ if (pressed === "Escape") {
993
+ event.preventDefault();
994
+ finish(false);
995
+ } else if (pressed === "Enter" && control === input) {
996
+ event.preventDefault();
997
+ finish(true);
998
+ }
999
+ });
1000
+ }
1001
+ for (const control of [save, cancel]) control.addEventListener("mousedown", (event) => event.preventDefault());
1002
+ save.addEventListener("click", () => finish(true));
1003
+ cancel.addEventListener("click", () => finish(false));
1004
+ host.addEventListener("focusout", (event) => {
1005
+ const next = event.relatedTarget;
1006
+ if (next !== null && host.contains(next)) return;
1007
+ finish(false);
1008
+ });
1009
+ host.append(input, save, cancel);
1010
+ input.focus();
1011
+ input.select();
1012
+ });
1013
+ host.appendChild(button);
1014
+ return host;
731
1015
  }
732
1016
  function confirmingButton(label2, confirmation, run2) {
733
1017
  const button = el("button", "danger", label2);
@@ -1025,8 +1309,8 @@ function drawGuard() {
1025
1309
  preview.addEventListener("click", () => {
1026
1310
  void previewGuard();
1027
1311
  });
1028
- const apply = el("button", "primary", "Apply");
1029
- apply.addEventListener("click", () => {
1312
+ const apply2 = el("button", "primary", "Apply");
1313
+ apply2.addEventListener("click", () => {
1030
1314
  void applyGuard();
1031
1315
  });
1032
1316
  const revert = el("button", null, "Revert");
@@ -1036,7 +1320,7 @@ function drawGuard() {
1036
1320
  drawGuard();
1037
1321
  });
1038
1322
  bar.appendChild(preview);
1039
- bar.appendChild(apply);
1323
+ bar.appendChild(apply2);
1040
1324
  bar.appendChild(revert);
1041
1325
  panel.appendChild(bar);
1042
1326
  }
@@ -1960,7 +2244,11 @@ function initFeed() {
1960
2244
  setSearch(search.value.trim());
1961
2245
  app.syncUrl();
1962
2246
  app.drawNow();
2247
+ showSuggestions(search);
1963
2248
  });
2249
+ initSuggestions(search);
2250
+ initTimeframe();
2251
+ initSavedFilters(search);
1964
2252
  const exportShown = byId("feed-export");
1965
2253
  exportShown.hidden = !SECTIONS.evidence;
1966
2254
  exportShown.addEventListener("click", () => {
@@ -1984,15 +2272,17 @@ function reflectFilterButtons() {
1984
2272
  }
1985
2273
  async function loadSkipped() {
1986
2274
  const before = state.rows.length;
2275
+ let served;
1987
2276
  try {
1988
2277
  const body = await getJson("/api/feed");
1989
2278
  for (const entry of body.entries) ingest(entry);
2279
+ served = body.skipped;
1990
2280
  sortRows();
1991
2281
  } catch {
1992
2282
  toast("bad", "Could not load them", "The dashboard did not answer. The entries are still in the window; try again.");
1993
2283
  return;
1994
2284
  }
1995
- state.caughtUp = (state.snapshot?.skipped ?? 0) + state.laggedDrops;
2285
+ state.caughtUp = (served ?? state.snapshot?.skipped ?? 0) + state.laggedDrops;
1996
2286
  const added = state.rows.length - before;
1997
2287
  resetPaging();
1998
2288
  app.drawNow();
@@ -2002,6 +2292,160 @@ async function loadSkipped() {
2002
2292
  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."
2003
2293
  );
2004
2294
  }
2295
+ function suggestionList() {
2296
+ return $("search-suggest");
2297
+ }
2298
+ function closeSuggestions(input) {
2299
+ const list = suggestionList();
2300
+ list.hidden = true;
2301
+ clear(list);
2302
+ highlighted = -1;
2303
+ input.setAttribute("aria-expanded", "false");
2304
+ }
2305
+ function showSuggestions(input) {
2306
+ const caret = input.selectionStart ?? input.value.length;
2307
+ const { options, from, to } = suggestFor(input.value, caret);
2308
+ const list = suggestionList();
2309
+ if (options.length === 0 || input.value.slice(from, to) === options[0]) {
2310
+ closeSuggestions(input);
2311
+ return;
2312
+ }
2313
+ clear(list);
2314
+ highlighted = -1;
2315
+ options.slice(0, 12).forEach((option, index) => {
2316
+ const item = el("li", null, option);
2317
+ item.setAttribute("role", "option");
2318
+ item.setAttribute("aria-selected", "false");
2319
+ item.addEventListener("mousedown", (event) => {
2320
+ event.preventDefault();
2321
+ apply(input, option, from, to);
2322
+ });
2323
+ item.dataset["index"] = String(index);
2324
+ list.appendChild(item);
2325
+ });
2326
+ list.hidden = false;
2327
+ input.setAttribute("aria-expanded", "true");
2328
+ }
2329
+ function apply(input, option, from, to) {
2330
+ const before = input.value.slice(0, from);
2331
+ const after = input.value.slice(to);
2332
+ input.value = `${before}${option}${after}`;
2333
+ const caret = before.length + option.length;
2334
+ input.setSelectionRange(caret, caret);
2335
+ setSearch(input.value.trim());
2336
+ app.syncUrl();
2337
+ app.drawNow();
2338
+ closeSuggestions(input);
2339
+ showSuggestions(input);
2340
+ input.focus();
2341
+ }
2342
+ function initSuggestions(input) {
2343
+ input.addEventListener("keydown", (event) => {
2344
+ const list = suggestionList();
2345
+ const items = Array.from(list.querySelectorAll("li"));
2346
+ if (list.hidden || items.length === 0) return;
2347
+ if (event.key === "Escape") {
2348
+ closeSuggestions(input);
2349
+ return;
2350
+ }
2351
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
2352
+ event.preventDefault();
2353
+ highlighted = (highlighted + (event.key === "ArrowDown" ? 1 : items.length - 1) + (highlighted === -1 && event.key === "ArrowUp" ? 1 : 0)) % items.length;
2354
+ items.forEach((item, index) => item.setAttribute("aria-selected", String(index === highlighted)));
2355
+ return;
2356
+ }
2357
+ if ((event.key === "Enter" || event.key === "Tab") && highlighted >= 0) {
2358
+ const chosen = items[highlighted]?.textContent ?? "";
2359
+ const caret = input.selectionStart ?? input.value.length;
2360
+ const { from, to } = suggestFor(input.value, caret);
2361
+ event.preventDefault();
2362
+ apply(input, chosen, from, to);
2363
+ }
2364
+ });
2365
+ input.addEventListener("blur", () => {
2366
+ setTimeout(() => closeSuggestions(input), 120);
2367
+ });
2368
+ input.addEventListener("focus", () => showSuggestions(input));
2369
+ }
2370
+ function initSavedFilters(input) {
2371
+ const host = $("saved-filters");
2372
+ const redraw = () => {
2373
+ clear(host);
2374
+ const entries = savedFilters();
2375
+ const select2 = document.createElement("select");
2376
+ select2.setAttribute("aria-label", "Saved filters");
2377
+ const first = document.createElement("option");
2378
+ first.value = "";
2379
+ first.textContent = entries.length === 0 ? "No saved filters" : "Saved filters\u2026";
2380
+ select2.appendChild(first);
2381
+ for (const entry of entries) {
2382
+ const option = document.createElement("option");
2383
+ option.value = entry.name;
2384
+ option.textContent = entry.name;
2385
+ select2.appendChild(option);
2386
+ }
2387
+ select2.addEventListener("change", () => {
2388
+ const chosen = entries.find((entry) => entry.name === select2.value);
2389
+ if (chosen === void 0) return;
2390
+ input.value = chosen.query;
2391
+ setSearch(chosen.query);
2392
+ state.filter = chosen.filter;
2393
+ reflectFilterButtons();
2394
+ resetPaging();
2395
+ app.syncUrl();
2396
+ app.drawNow();
2397
+ });
2398
+ host.appendChild(select2);
2399
+ const save = el("button", null, "Save");
2400
+ save.type = "button";
2401
+ save.title = "Save this filter, in this browser, under a name";
2402
+ save.addEventListener("click", () => {
2403
+ const name = prompt("Save this filter as:")?.trim();
2404
+ if (name === void 0 || name === "") return;
2405
+ saveFilter({ name, query: input.value.trim(), filter: state.filter });
2406
+ redraw();
2407
+ toast("ok", "Filter saved", `"${name}" is in this browser. It is not shared with anybody else.`);
2408
+ });
2409
+ host.appendChild(save);
2410
+ if (select2.value !== "") {
2411
+ const remove = el("button", null, "Delete");
2412
+ remove.type = "button";
2413
+ remove.addEventListener("click", () => {
2414
+ deleteFilter(select2.value);
2415
+ redraw();
2416
+ });
2417
+ host.appendChild(remove);
2418
+ }
2419
+ };
2420
+ redraw();
2421
+ }
2422
+ function initTimeframe() {
2423
+ const from = byId("from-at");
2424
+ const to = byId("to-at");
2425
+ const clearButton = byId("timeframe-clear");
2426
+ const read2 = (input) => {
2427
+ if (input.value === "") return void 0;
2428
+ const parsed = new Date(input.value).getTime();
2429
+ return Number.isFinite(parsed) ? parsed : void 0;
2430
+ };
2431
+ const apply2 = () => {
2432
+ const start2 = read2(from);
2433
+ const end = read2(to);
2434
+ if (start2 !== void 0 && end !== void 0 && end < start2) {
2435
+ toast("warn", "That window runs backwards", "The end is before the start, so nothing can fall inside it.");
2436
+ }
2437
+ setTimeframe(start2, end);
2438
+ clearButton.hidden = start2 === void 0 && end === void 0;
2439
+ app.drawNow();
2440
+ };
2441
+ from.addEventListener("change", apply2);
2442
+ to.addEventListener("change", apply2);
2443
+ clearButton.addEventListener("click", () => {
2444
+ from.value = "";
2445
+ to.value = "";
2446
+ apply2();
2447
+ });
2448
+ }
2005
2449
  function drawPager(paged) {
2006
2450
  const size = state.feedPageSize;
2007
2451
  const hidden = paged.pages <= 1;
@@ -2170,12 +2614,13 @@ function buildDetail(entry) {
2170
2614
  } else {
2171
2615
  const list = el("div", "ev");
2172
2616
  for (const item of entry.evidence) {
2173
- const row = el("div", `ev-item${item.direction === "human" ? " human" : ""}`);
2617
+ const row = el("div", `ev-item${item.direction === "human" ? " human" : ""}${item.shadow === true ? " shadow" : ""}`);
2174
2618
  row.appendChild(el("div", `tier t-${item.certainty}`, item.certainty));
2175
2619
  const body = el("div");
2176
2620
  body.appendChild(el("div", null, item.summary));
2177
2621
  let meta = `${item.detector} \xB7 points to ${item.direction}`;
2178
2622
  if (item.family !== void 0) meta += ` \xB7 family \u201C${item.family}\u201D, counted once with its siblings`;
2623
+ if (item.shadow === true) meta += " \xB7 shadowed: counted, and part of no decision";
2179
2624
  body.appendChild(el("div", "ev-meta", meta));
2180
2625
  if (item.deterministicBasis !== void 0) body.appendChild(el("div", "basis", item.deterministicBasis));
2181
2626
  row.appendChild(body);
@@ -2183,6 +2628,17 @@ function buildDetail(entry) {
2183
2628
  }
2184
2629
  cell.appendChild(list);
2185
2630
  }
2631
+ if (entry.shadowVerdict !== void 0) {
2632
+ const would = entry.shadowVerdict;
2633
+ const changed = would.verdict !== entry.verdict;
2634
+ cell.appendChild(
2635
+ el(
2636
+ "div",
2637
+ `ev-meta shadow-verdict${changed ? " changed" : ""}`,
2638
+ 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}`}.`
2639
+ )
2640
+ );
2641
+ }
2186
2642
  for (const failure of entry.failures) {
2187
2643
  cell.appendChild(el("div", "ev-meta", `Detector ${failure.detector} ${failure.reason}: ${failure.message}`));
2188
2644
  }
@@ -2284,12 +2740,14 @@ function showText(text) {
2284
2740
  selection.addRange(range);
2285
2741
  }
2286
2742
  }
2287
- var FILTERS, rendered, FEED_PAGE_SIZES;
2743
+ var FILTERS, rendered, highlighted, FEED_PAGE_SIZES;
2288
2744
  var init_feed = __esm({
2289
2745
  "src/dashboard/client/feed.ts"() {
2290
2746
  "use strict";
2291
2747
  init_dom();
2292
2748
  init_store();
2749
+ init_saved();
2750
+ init_query();
2293
2751
  init_api();
2294
2752
  init_pager();
2295
2753
  init_boot();
@@ -2310,6 +2768,7 @@ var init_feed = __esm({
2310
2768
  ["allow", "Served"]
2311
2769
  ];
2312
2770
  rendered = /* @__PURE__ */ new Map();
2771
+ highlighted = -1;
2313
2772
  FEED_PAGE_SIZES = [25, 50, 100, 200];
2314
2773
  }
2315
2774
  });
@@ -2512,6 +2971,13 @@ function drawStatsPanels() {
2512
2971
  ["Actors tracked", n(metrics.actorsTracked)],
2513
2972
  ["Guard stops", n(metrics.downgrades)]
2514
2973
  ];
2974
+ const shadowed = pairs(metrics.shadowFirings);
2975
+ if (shadowed.length > 0) {
2976
+ const moved = Object.entries(metrics.shadowChanges).filter(([, count]) => count > 0);
2977
+ rows.push(["Shadowed findings", n(shadowed.reduce((total, [, count]) => total + count, 0))]);
2978
+ for (const [verdict, count] of moved) rows.push([`Would have become ${verdict}`, n(count)]);
2979
+ if (moved.length === 0) rows.push(["Verdicts they would have changed", "none"]);
2980
+ }
2515
2981
  const failures = pairs(metrics.detectorFailures);
2516
2982
  for (const [detector, count] of failures) rows.push([`Detector failures \u2014 ${detector}`, n(count)]);
2517
2983
  for (const [key, value] of rows) health.appendChild(statRow(key, value));
@@ -2528,14 +2994,14 @@ function drawStatsPanels() {
2528
2994
  clear(list);
2529
2995
  $("detector-count").textContent = `${snapshot.detectors.length} installed`;
2530
2996
  for (const detector of snapshot.detectors) {
2531
- const row = el("div", "det");
2997
+ const row = el("div", `det${detector.shadow === true ? " shadow" : ""}`);
2532
2998
  const left = el("div");
2533
2999
  left.appendChild(el("div", "mono", detector.id));
2534
3000
  left.appendChild(el("div", "d", detector.description));
2535
3001
  row.appendChild(left);
2536
- const fires = metrics?.detectorFirings[detector.id] ?? 0;
3002
+ const fires = (detector.shadow === true ? metrics?.shadowFirings[detector.id] : metrics?.detectorFirings[detector.id]) ?? 0;
2537
3003
  const timing = metrics?.detectorTimings[detector.id];
2538
- let right = `${n(fires)} \xB7 ${detector.cost} \xB7 ${detector.stage}`;
3004
+ let right = `${n(fires)}${detector.shadow === true ? " shadowed" : ""} \xB7 ${detector.cost} \xB7 ${detector.stage}`;
2539
3005
  if (timing !== void 0 && timing.count > 0) right += ` \xB7 ${ms(timing.totalMs / timing.count)} avg`;
2540
3006
  row.appendChild(el("div", "n", right));
2541
3007
  list.appendChild(row);
@@ -3059,6 +3525,7 @@ function trackActors() {
3059
3525
  void loadActors();
3060
3526
  timer = setInterval(() => {
3061
3527
  if (state.tab !== "actors" || state.paused || isConfirming()) return;
3528
+ if (state.actorScope === "feed") return;
3062
3529
  void loadActors();
3063
3530
  }, 4e3);
3064
3531
  }
@@ -3097,26 +3564,95 @@ function drawActorsPager(full) {
3097
3564
  else renderPager(host, model, { withSize });
3098
3565
  }
3099
3566
  }
3567
+ function feedActors() {
3568
+ const byKey = /* @__PURE__ */ new Map();
3569
+ for (const row of matchingRows()) {
3570
+ const entry = row.entry;
3571
+ let seen = byKey.get(entry.actor);
3572
+ if (seen === void 0) {
3573
+ seen = { rows: 0, paths: /* @__PURE__ */ new Set(), agents: /* @__PURE__ */ new Set(), first: entry.at, last: entry.at };
3574
+ byKey.set(entry.actor, seen);
3575
+ }
3576
+ seen.rows++;
3577
+ seen.paths.add(entry.path);
3578
+ seen.agents.add(entry.userAgent);
3579
+ if (entry.at < seen.first) seen.first = entry.at;
3580
+ if (entry.at > seen.last) seen.last = entry.at;
3581
+ seen.stats = entry.actorStats ?? seen.stats;
3582
+ }
3583
+ const labels = new Map(state.actors.filter((actor) => actor.label !== void 0).map((actor) => [actor.key, actor.label]));
3584
+ const out = [];
3585
+ for (const [key, seen] of byKey) {
3586
+ const label2 = labels.get(key);
3587
+ out.push({
3588
+ key,
3589
+ ...label2 === void 0 ? {} : { label: label2 },
3590
+ requests: seen.rows,
3591
+ recentRate: Number.NaN,
3592
+ distinctPaths: seen.paths.size,
3593
+ distinctUserAgents: seen.agents.size,
3594
+ cadenceCv: void 0,
3595
+ // A dash where the feed cannot know, like the three columns below it. A confident
3596
+ // zero under a heading that means "how many times has this client been proven a bot"
3597
+ // is worse than an admission, and it was the only column here still guessing.
3598
+ priorConfirmations: seen.stats?.priorConfirmations ?? Number.NaN,
3599
+ unsolvedChallenges: Number.NaN,
3600
+ cleared: seen.stats?.cleared ?? false,
3601
+ firstSeen: seen.stats?.firstSeen ?? seen.first,
3602
+ lastSeen: seen.last
3603
+ });
3604
+ }
3605
+ return out.sort((a, b) => b.requests - a.requests);
3606
+ }
3607
+ function applyActorScope(scope) {
3608
+ if (!SECTIONS.registry) return;
3609
+ state.actorScope = scope;
3610
+ for (const [id, on] of [
3611
+ ["actors-scope-tracked", scope === "tracked"],
3612
+ ["actors-scope-feed", scope === "feed"]
3613
+ ]) {
3614
+ byId(id).className = on ? "on" : "";
3615
+ byId(id).setAttribute("aria-pressed", String(on));
3616
+ }
3617
+ drawActors();
3618
+ }
3619
+ function initActorScope() {
3620
+ if (!SECTIONS.registry) return;
3621
+ const choose = (scope) => {
3622
+ applyActorScope(scope);
3623
+ app.syncUrl({ replace: false });
3624
+ };
3625
+ $("actors-scope-tracked").addEventListener("click", () => choose("tracked"));
3626
+ $("actors-scope-feed").addEventListener("click", () => choose("feed"));
3627
+ }
3100
3628
  function drawActors() {
3101
3629
  if (!SECTIONS.registry) return;
3102
3630
  if (isConfirming()) return;
3103
3631
  const body = byId("actor-rows");
3104
3632
  clear(body);
3105
- const actors = state.actors;
3106
- $("actors-count").textContent = `${n(actors.length)} shown \xB7 ${n(state.actorsTracked)} tracked`;
3107
- drawActorsPager(actors.length === state.actorsPageSize);
3633
+ const fromFeed = state.actorScope === "feed";
3634
+ const actors = fromFeed ? feedActors() : state.actors;
3635
+ $("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`;
3636
+ drawActorsPager(!fromFeed && actors.length === state.actorsPageSize);
3637
+ if (fromFeed) for (const id of ["actors-pager-top", "actors-pager"]) byId(id).hidden = true;
3108
3638
  byId("actors-empty").hidden = actors.length > 0;
3109
3639
  for (const actor of actors) {
3110
3640
  const row = el("tr");
3111
- row.appendChild(el("td", "who", actor.key));
3641
+ const who = el("td", "who");
3642
+ if (actor.label === void 0) who.textContent = actor.key;
3643
+ else {
3644
+ who.appendChild(el("div", "label", actor.label));
3645
+ who.appendChild(el("div", "sub", actor.key));
3646
+ }
3647
+ row.appendChild(who);
3112
3648
  row.appendChild(el("td", "num tnum", n(actor.requests)));
3113
- row.appendChild(el("td", "num tnum", n(actor.recentRate)));
3649
+ row.appendChild(el("td", "num tnum", Number.isNaN(actor.recentRate) ? "\u2014" : n(actor.recentRate)));
3114
3650
  row.appendChild(el("td", "num tnum", n(actor.distinctPaths)));
3115
3651
  const cadence = el("td", "num tnum", actor.cadenceCv === void 0 ? "\u2014" : actor.cadenceCv.toFixed(2));
3116
3652
  if (actor.cadenceCv !== void 0 && actor.cadenceCv < 0.15) cadence.className += " warn-text";
3117
3653
  row.appendChild(cadence);
3118
- row.appendChild(el("td", "num tnum", n(actor.priorConfirmations)));
3119
- const unsolved = el("td", "num tnum", n(actor.unsolvedChallenges));
3654
+ row.appendChild(el("td", "num tnum", Number.isNaN(actor.priorConfirmations) ? "\u2014" : n(actor.priorConfirmations)));
3655
+ const unsolved = el("td", "num tnum", Number.isNaN(actor.unsolvedChallenges) ? "\u2014" : n(actor.unsolvedChallenges));
3120
3656
  if (actor.unsolvedChallenges >= 3) unsolved.className += " warn-text";
3121
3657
  row.appendChild(unsolved);
3122
3658
  const stateCell = el("td");
@@ -3137,7 +3673,7 @@ function drawActors() {
3137
3673
  app.syncUrl();
3138
3674
  });
3139
3675
  actions.appendChild(inFeed);
3140
- for (const button of actorActions(actor.key, () => void loadActors())) actions.appendChild(button);
3676
+ for (const button of actorActions(actor.key, () => void loadActors(), actor.label)) actions.appendChild(button);
3141
3677
  row.appendChild(actions);
3142
3678
  body.appendChild(row);
3143
3679
  }
@@ -3170,13 +3706,13 @@ function initTester() {
3170
3706
  });
3171
3707
  }
3172
3708
  async function run() {
3173
- const raw2 = byId("test-input").value;
3174
- if (raw2.trim() === "") {
3709
+ const raw = byId("test-input").value;
3710
+ if (raw.trim() === "") {
3175
3711
  toast("warn", "Nothing to assess", "Paste a User-Agent, a curl command, or a header block.");
3176
3712
  return;
3177
3713
  }
3178
3714
  const result = await postJson("/api/test", {
3179
- raw: raw2,
3715
+ raw,
3180
3716
  ip: byId("test-ip").value.trim(),
3181
3717
  url: byId("test-url").value.trim()
3182
3718
  });
@@ -3316,12 +3852,12 @@ function initHeader() {
3316
3852
  }
3317
3853
  const header = rootNode().querySelector("header");
3318
3854
  if (header !== null) {
3319
- const apply = () => {
3855
+ const apply2 = () => {
3320
3856
  themeElement().style.setProperty("--header-h", `${header.getBoundingClientRect().height}px`);
3321
3857
  };
3322
- apply();
3323
- if (typeof ResizeObserver === "function") new ResizeObserver(apply).observe(header);
3324
- else addEventListener("resize", apply);
3858
+ apply2();
3859
+ if (typeof ResizeObserver === "function") new ResizeObserver(apply2).observe(header);
3860
+ else addEventListener("resize", apply2);
3325
3861
  }
3326
3862
  }
3327
3863
  function syncUrl(replace = true) {
@@ -3329,21 +3865,25 @@ function syncUrl(replace = true) {
3329
3865
  const params = new URLSearchParams();
3330
3866
  if (state.filter !== "all") params.set("f", state.filter);
3331
3867
  if (state.search !== "") params.set("q", state.search);
3868
+ if (state.actorScope !== "tracked") params.set("a", state.actorScope);
3332
3869
  const query = params.toString();
3333
3870
  const hash = `#${state.tab}${query === "" ? "" : `?${query}`}`;
3334
3871
  if (location.hash === hash) return;
3335
3872
  history[replace ? "replaceState" : "pushState"]({ tab: state.tab }, "", hash);
3336
3873
  }
3337
3874
  function readUrl() {
3338
- const raw2 = isEmbedded() ? "" : location.hash.slice(1);
3339
- const split = raw2.indexOf("?");
3340
- const name = split === -1 ? raw2 : raw2.slice(0, split);
3341
- const params = new URLSearchParams(split === -1 ? "" : raw2.slice(split + 1));
3875
+ const raw = isEmbedded() ? "" : location.hash.slice(1);
3876
+ const split = raw.indexOf("?");
3877
+ const name = split === -1 ? raw : raw.slice(0, split);
3878
+ const params = new URLSearchParams(split === -1 ? "" : raw.slice(split + 1));
3342
3879
  const filter = params.get("f") ?? "all";
3343
3880
  return {
3344
3881
  tab: isTab(name) ? name : FIRST,
3345
3882
  filter,
3346
- search: params.get("q") ?? ""
3883
+ search: params.get("q") ?? "",
3884
+ // Anything but the one alternative reads as the default rather than as an error:
3885
+ // a hand-edited URL should land somewhere, and this is the somewhere it lands.
3886
+ actorScope: params.get("a") === "feed" ? "feed" : "tracked"
3347
3887
  };
3348
3888
  }
3349
3889
  function showTab(name, options = {}) {
@@ -3387,6 +3927,7 @@ function initTabs() {
3387
3927
  state.filter = url.filter;
3388
3928
  setSearch(url.search);
3389
3929
  reflectFilterButtons();
3930
+ applyActorScope(url.actorScope);
3390
3931
  showTab(url.tab, { push: false });
3391
3932
  });
3392
3933
  }
@@ -3528,7 +4069,7 @@ function start() {
3528
4069
  app.draw = schedule;
3529
4070
  app.drawNow = drawNow;
3530
4071
  app.showTab = showTab;
3531
- app.syncUrl = () => syncUrl();
4072
+ app.syncUrl = (options) => syncUrl(options?.replace !== false);
3532
4073
  applySections();
3533
4074
  initHeader();
3534
4075
  drawPeers();
@@ -3538,6 +4079,7 @@ function start() {
3538
4079
  initRanges();
3539
4080
  if (SECTIONS.feed) initFeed();
3540
4081
  initActor();
4082
+ initActorScope();
3541
4083
  initTester();
3542
4084
  initPolicy();
3543
4085
  let resizeTimer;
@@ -3555,6 +4097,7 @@ function start() {
3555
4097
  state.filter = url.filter;
3556
4098
  setSearch(url.search);
3557
4099
  if (SECTIONS.feed) reflectFilterButtons();
4100
+ applyActorScope(url.actorScope);
3558
4101
  showTab(url.tab, { replace: true });
3559
4102
  suspendScrollAnchoring();
3560
4103
  void loadInitialSnapshot().finally(settleScrollAnchoring);
@@ -3658,12 +4201,12 @@ function resolveSections(fromServer, config) {
3658
4201
  }
3659
4202
  return { sections, warnings };
3660
4203
  }
3661
- function parseMount(raw2) {
3662
- const cut = raw2.search(/[?#]/);
3663
- if (cut === -1) return { base: raw2.replace(/\/$/, "") };
4204
+ function parseMount(raw) {
4205
+ const cut = raw.search(/[?#]/);
4206
+ if (cut === -1) return { base: raw.replace(/\/$/, "") };
3664
4207
  return {
3665
- base: raw2.slice(0, cut).replace(/\/$/, ""),
3666
- warning: `src "${raw2}" has a ${raw2[cut] === "?" ? "query string" : "fragment"} on it. The element asks for \`<src>/api/bootstrap\`, so only the path can mean anything here; the rest was ignored.`
4208
+ base: raw.slice(0, cut).replace(/\/$/, ""),
4209
+ warning: `src "${raw}" has a ${raw[cut] === "?" ? "query string" : "fragment"} on it. The element asks for \`<src>/api/bootstrap\`, so only the path can mean anything here; the rest was ignored.`
3667
4210
  };
3668
4211
  }
3669
4212
  function explainStatus(status, base) {
@@ -3878,7 +4421,10 @@ button[disabled] { opacity: .5; cursor: default; }
3878
4421
  .tab[aria-selected="true"] { color: var(--ink); border-bottom-color: var(--s1); }
3879
4422
 
3880
4423
  /* --- layout ------------------------------------------------------------- */
3881
- main { padding: 18px 20px 64px; max-width: 1680px; margin: 0 auto; }
4424
+ /* The top padding is the gap under the sticky header. At 18px the counter row sat almost
4425
+ against the header's border and read as part of it; the tiles carry their own border, so
4426
+ two lines were meeting with nothing between them. */
4427
+ main { padding: 28px 20px 64px; max-width: 1680px; margin: 0 auto; }
3882
4428
  .stack { display: grid; gap: 16px; }
3883
4429
  /* Everything above the feed is drawn by script once the first snapshot arrives, which
3884
4430
  inserts a block of content above what is already laid out. The browser's scroll
@@ -3909,6 +4455,33 @@ button.tile {
3909
4455
  cursor: pointer; appearance: none; transition: border-color .12s, box-shadow .12s;
3910
4456
  }
3911
4457
  button.tile:hover { border-color: var(--focus); }
4458
+ /* The suggestion list under the search box, positioned against the search wrapper. */
4459
+ .search { position: relative; }
4460
+ .suggest {
4461
+ position: absolute; top: calc(100% + 4px); left: 0; z-index: 30; margin: 0; padding: 4px;
4462
+ list-style: none; min-width: 220px; max-height: 260px; overflow-y: auto;
4463
+ background: var(--surface); border: 1px solid var(--line); border-radius: 9px; box-shadow: var(--shadow);
4464
+ }
4465
+ .suggest li { padding: 4px 9px; border-radius: 6px; cursor: pointer; font-size: 12px; }
4466
+ .suggest li[aria-selected="true"] { background: color-mix(in srgb, var(--focus) 18%, transparent); }
4467
+
4468
+ /* Saved filters: a list of small removable things. Quiet, because it is not what
4469
+ somebody came to the page to look at. */
4470
+ .saved { display: flex; align-items: center; gap: 6px; }
4471
+ /* Two open-ended bounds rather than a list of durations: "from the incident until now",
4472
+ "everything up to when it stopped" and "between these two moments" are the same control
4473
+ with one end left empty. */
4474
+ .timeframe { display: flex; align-items: center; gap: 8px; font-size: 11.5px; color: var(--muted); }
4475
+ .timeframe label { display: inline-flex; align-items: center; gap: 4px; }
4476
+ .timeframe input {
4477
+ font: inherit; font-size: 11.5px; padding: 2px 5px; border-radius: 6px;
4478
+ border: 1px solid var(--line); background: var(--surface); color: var(--ink);
4479
+ }
4480
+ .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; }
4481
+ .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; }
4482
+ .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; }
4483
+ .saved button:hover { border-color: var(--focus); }
4484
+
3912
4485
  /* The badge's companion: fetches the entries the stream skipped. Sits inline with the
3913
4486
  heading, so it is styled to read as part of the sentence rather than as a form control. */
3914
4487
  .load-skipped {
@@ -3930,6 +4503,10 @@ button.tile:hover { border-color: var(--focus); }
3930
4503
  .pager button:hover:not(:disabled) { border-color: var(--focus); }
3931
4504
  .pager button:disabled { opacity: .45; cursor: default; }
3932
4505
  .pager .where { font-variant-numeric: tabular-nums; }
4506
+ /* A labelled actor leads with its name and keeps the key underneath: whoever named it did
4507
+ so because the key was not the useful part, and the key is still what you search for. */
4508
+ td.who .label { font-weight: 560; }
4509
+ td.who .sub { color: var(--muted); font-size: 11px; }
3933
4510
  .pager.pager-top { padding: 2px 2px 9px; border-bottom: 1px solid var(--line); margin-bottom: 9px; }
3934
4511
  /* The feed's upper pager rides in the toolbar rather than owning a row of its own, which
3935
4512
  was thirty-six pixels of mostly empty rule above every screenful of requests. */
@@ -4050,7 +4627,29 @@ input[type="search"] {
4050
4627
  }
4051
4628
  input[type="search"]::placeholder { color: var(--muted); }
4052
4629
 
4053
- table { width: 100%; border-collapse: collapse; }
4630
+ /* separate with zero spacing rather than collapse, and the difference is the whole
4631
+ reason the column headers work in Safari.
4632
+
4633
+ Collapsed borders and sticky table cells are a long-standing sore point in WebKit: the
4634
+ CSSWG has an open issue on collapsed borders not following a cell when it sticks
4635
+ (csswg-drafts#3136), and Safari is widely reported to drop the stickiness of a th
4636
+ altogether under a collapsed table. Separating the borders is the standard remedy.
4637
+
4638
+ What was actually measured: the header sticks correctly in Chromium and in Firefox,
4639
+ both before and after this change, and it was reported adrift in Safari — which is what
4640
+ a sticky element that has stopped sticking looks like. WebKit could not be run on the
4641
+ machine this was written on, so the Safari half of it rests on that report and on the
4642
+ documented behaviour rather than on a measurement taken here.
4643
+
4644
+ The rendering is all but unchanged. Every border in these tables is a bottom border on
4645
+ the cell itself, plus the per-cell left accent on td.edge; no border is shared between
4646
+ two cells, so there is nothing for collapsing to merge and nothing for separating to
4647
+ double, and zero spacing keeps the cells touching. The one measurable difference is the
4648
+ accent column, which moves two pixels: collapsing centres that 3px border on the cell
4649
+ edge and leaves half of it outside the box, while separating puts all of it inside.
4650
+ Measured rather than assumed, and the leftmost column starting two pixels earlier is
4651
+ both imperceptible and the more correct of the two. */
4652
+ table { width: 100%; border-collapse: separate; border-spacing: 0; }
4054
4653
  thead th {
4055
4654
  /* Measured at runtime — see trackHeaderHeight(). The literal is the fallback for
4056
4655
  the instant before the first measurement, and for the tab strip wrapping. */
@@ -4342,9 +4941,44 @@ input::placeholder { color: color-mix(in srgb, var(--muted) 80%, transparent); }
4342
4941
  #actor-rows td { font-size: 12.5px; }
4343
4942
  #actor-rows td.who { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
4344
4943
  #actor-rows td.acts { text-align: right; white-space: nowrap; }
4944
+ /* The tracked/shown toggle above the actors table. A segmented pair rather than a
4945
+ dropdown: there are two answers and both are worth reading at a glance. */
4946
+ .scope { display: flex; gap: 6px; padding: 0 14px 10px; }
4947
+ .scope button { font-size: 11.5px; padding: 4px 10px; }
4948
+ .scope button.on { background: var(--accent); color: var(--on-accent, #fff); border-color: var(--accent); }
4949
+
4345
4950
  #actor-rows td.acts button { font-size: 11px; padding: 3px 8px; margin-left: 4px; }
4951
+ /* The Label control, which becomes a text box with a Save and a Cancel in place.
4952
+
4953
+ The cell does not wrap, so an editor that sat beside the row's other four buttons put
4954
+ Save off the right edge of the panel, where it could be seen and not clicked. While
4955
+ the editor is open it stands in for those buttons instead — which is also the right
4956
+ thing on its own, since Allowlist and Forget are not what somebody naming a client is
4957
+ reaching for. */
4958
+ .acts.editing > :not(.label-edit), .bar-actions.editing > :not(.label-edit) { display: none; }
4959
+ /* inline-flex rather than inline-block: the row is three fixed-size controls and a flex
4960
+ line is the layout that cannot spill them past its own edge. */
4961
+ .label-edit { display: inline-flex; align-items: center; gap: 4px; }
4962
+ .label-edit button { flex: 0 0 auto; }
4963
+ #actor-rows td.acts .label-save, #actor-actions .label-save { border-color: var(--accent); color: var(--accent); }
4964
+ /* Qualified with the element name on purpose: input[type="text"] { width: 100% } above
4965
+ outranks a bare class, so the width here was quietly ignored and the box grew to fill
4966
+ whatever it was in — which is what put Save and Cancel outside the panel. */
4967
+ input.label-input {
4968
+ font: inherit; font-size: 11px; padding: 3px 8px; width: 15ch; flex: 0 0 auto; box-sizing: border-box;
4969
+ color: var(--ink); background: var(--surface); border: 1px solid var(--accent); border-radius: 6px;
4970
+ }
4971
+ input.label-input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
4972
+
4346
4973
  /* A tag, not a warning: a cleared actor is a decision somebody made, and a metronomic
4347
4974
  one is a measurement. Neither is a verdict, so neither gets a verdict's colour. */
4975
+ /* A shadowed finding: shown at full detail, and visibly not part of the decision. Dimmed
4976
+ and set behind a rule rather than coloured, because every colour on this page already
4977
+ means something about a verdict and this one took no part in a verdict. */
4978
+ .det.shadow { opacity: 0.72; }
4979
+ .ev-item.shadow { opacity: 0.72; border-left: 2px dashed var(--line); padding-left: 8px; }
4980
+ .shadow-verdict { margin-top: 8px; font-style: italic; }
4981
+ .shadow-verdict.changed { color: var(--ink-2); font-style: normal; }
4348
4982
  .tagline { font-size: 11px; color: var(--muted); }
4349
4983
  .tagline b { color: var(--ink-2); font-weight: 600; }
4350
4984
 
@@ -4442,8 +5076,16 @@ var DASHBOARD_MARKUP = String.raw`
4442
5076
  <div class="toolbar">
4443
5077
  <div class="filters" id="filters"></div>
4444
5078
  <div class="search">
4445
- <input type="search" id="search" placeholder="path:/api actor:203.0.113.4 -rule:allow-crawlers" spellcheck="false" autocomplete="off">
5079
+ <input type="search" id="search" placeholder="path:/api actor:203.0.113.4 -rule:allow-crawlers" spellcheck="false" autocomplete="off"
5080
+ role="combobox" aria-expanded="false" aria-controls="search-suggest" aria-autocomplete="list">
4446
5081
  <kbd aria-hidden="true">/</kbd>
5082
+ <ul class="suggest" id="search-suggest" role="listbox" aria-label="Filter suggestions" hidden></ul>
5083
+ </div>
5084
+ <div class="saved" id="saved-filters"></div>
5085
+ <div class="timeframe" id="timeframe">
5086
+ <label>From <input type="datetime-local" id="from-at" step="1"></label>
5087
+ <label>To <input type="datetime-local" id="to-at" step="1"></label>
5088
+ <button type="button" id="timeframe-clear" hidden>Clear</button>
4447
5089
  </div>
4448
5090
  <button id="feed-export" title="Download every request matching this filter as replay JSONL">Export</button>
4449
5091
  <div class="pager pager-inline" id="feed-pager-top" hidden></div>
@@ -4507,6 +5149,12 @@ var DASHBOARD_MARKUP = String.raw`
4507
5149
  <div class="note" id="actors-note">Everyone the engine is currently remembering, busiest first — a far larger
4508
5150
  population than the feed's ring, which holds requests rather than clients. This is
4509
5151
  what <code>cadence</code>, <code>crawl-breadth</code> and <code>rate-anomaly</code> are reading.</div>
5152
+ <div class="scope" role="group" aria-label="Which actors to list">
5153
+ <button id="actors-scope-tracked" class="on" aria-pressed="true"
5154
+ title="Every client the engine is remembering, busiest first">Tracked</button>
5155
+ <button id="actors-scope-feed" aria-pressed="false"
5156
+ title="Only the clients that appear in the feed you are looking at, after its filter">Shown in the feed</button>
5157
+ </div>
4510
5158
  <div class="pager pager-top" id="actors-pager-top" hidden></div>
4511
5159
  <div class="feed-scroll">
4512
5160
  <table>
@@ -4951,6 +5599,8 @@ var BotDashboardElement = class extends ElementBase {
4951
5599
  const dom = await Promise.resolve().then(() => (init_dom(), dom_exports));
4952
5600
  dom.setRoot(frame, this);
4953
5601
  globalThis.__BOOTSTRAP__ = boot;
5602
+ const bootModule = await Promise.resolve().then(() => (init_boot(), boot_exports));
5603
+ bootModule.applyBoot(boot);
4954
5604
  await Promise.resolve().then(() => (init_client(), client_exports));
4955
5605
  this.relabelTabs(shadow);
4956
5606
  this.renderPanels(frame);