@osqd/bothandlerjs 0.6.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 (84) 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 -13
  5. package/dist/adapters/index.cjs.map +1 -1
  6. package/dist/adapters/index.js +38 -13
  7. package/dist/adapters/index.js.map +1 -1
  8. package/dist/challenge/index.d.ts +40 -0
  9. package/dist/cli.cjs +1783 -83
  10. package/dist/cli.cjs.map +1 -1
  11. package/dist/cli.js +1783 -83
  12. package/dist/cli.js.map +1 -1
  13. package/dist/config.d.ts +55 -0
  14. package/dist/core.d.ts +33 -21
  15. package/dist/corpus/index.cjs +282 -8
  16. package/dist/corpus/index.cjs.map +1 -1
  17. package/dist/corpus/index.js +282 -8
  18. package/dist/corpus/index.js.map +1 -1
  19. package/dist/corpus/schema.d.ts +25 -0
  20. package/dist/dashboard/client/actions.d.ts +1 -1
  21. package/dist/dashboard/client/app.d.ts +9 -2
  22. package/dist/dashboard/client/boot.d.ts +32 -3
  23. package/dist/dashboard/client/query.d.ts +72 -12
  24. package/dist/dashboard/client/registry.d.ts +25 -0
  25. package/dist/dashboard/client/saved.d.ts +29 -0
  26. package/dist/dashboard/client/store.d.ts +16 -2
  27. package/dist/dashboard/client/types.d.ts +2 -0
  28. package/dist/dashboard/client.generated.d.ts +1 -1
  29. package/dist/dashboard/types.d.ts +15 -0
  30. package/dist/detectors/blended-identity.d.ts +34 -0
  31. package/dist/detectors/challenge-integrity.d.ts +26 -0
  32. package/dist/detectors/challenge-reaction.d.ts +39 -0
  33. package/dist/detectors/clearance.d.ts +1 -23
  34. package/dist/detectors/index.d.ts +16 -1
  35. package/dist/detectors/marker.d.ts +106 -0
  36. package/dist/detectors/probe-signature.d.ts +27 -0
  37. package/dist/detectors/site-baseline.d.ts +135 -0
  38. package/dist/detectors/target-integrity.d.ts +16 -0
  39. package/dist/detectors/trap.d.ts +10 -3
  40. package/dist/detectors/types.d.ts +17 -0
  41. package/dist/element/index.cjs +726 -79
  42. package/dist/element/index.cjs.map +1 -1
  43. package/dist/element/index.js +726 -79
  44. package/dist/element/index.js.map +1 -1
  45. package/dist/index.cjs +1657 -125
  46. package/dist/index.cjs.map +1 -1
  47. package/dist/index.d.ts +4 -0
  48. package/dist/index.js +1643 -125
  49. package/dist/index.js.map +1 -1
  50. package/dist/internal/async.d.ts +0 -3
  51. package/dist/internal/ip.d.ts +18 -0
  52. package/dist/internal/text.d.ts +28 -0
  53. package/dist/metrics.d.ts +18 -0
  54. package/dist/probe/index.d.ts +153 -0
  55. package/dist/probe/marker.d.ts +119 -0
  56. package/dist/site/index.d.ts +122 -0
  57. package/dist/state.d.ts +130 -0
  58. package/dist/stores/redis.d.ts +24 -1
  59. package/dist/types.d.ts +70 -0
  60. package/docs/course/05-detectors.md +6 -5
  61. package/docs/course/06-identity.md +1 -1
  62. package/docs/course/16-proving-it.md +15 -9
  63. package/docs/course/index.md +1 -1
  64. package/docs/design/decisions.md +1 -1
  65. package/docs/detection/correlation.md +284 -0
  66. package/docs/detection/detectors.md +139 -3
  67. package/docs/detection/index.md +2 -1
  68. package/docs/detection/shadow-mode.md +147 -0
  69. package/docs/detection/signatures.md +1 -1
  70. package/docs/index.md +3 -2
  71. package/docs/integration/client-ip.md +16 -0
  72. package/docs/operations/dashboard.md +40 -1
  73. package/docs/operations/filters.md +143 -0
  74. package/docs/operations/index.md +1 -0
  75. package/docs/operations/metrics.md +18 -0
  76. package/docs/policy/presets.md +1 -1
  77. package/docs/start/choosing-a-policy.md +1 -1
  78. package/docs/start/first-integration.md +1 -1
  79. package/docs/start/installation.md +2 -2
  80. package/docs/testing/cli.md +7 -1
  81. package/docs/testing/corpus.md +12 -8
  82. package/docs/testing/index.md +1 -1
  83. package/docs/testing/try-it.md +1 -1
  84. package/package.json +4 -1
@@ -113,12 +113,23 @@ var init_dom = __esm({
113
113
  });
114
114
 
115
115
  // src/dashboard/client/boot.ts
116
- var raw, BOOT, API, SECTIONS;
116
+ var boot_exports = {};
117
+ __export(boot_exports, {
118
+ API: () => API,
119
+ BOOT: () => BOOT,
120
+ SECTIONS: () => SECTIONS,
121
+ applyBoot: () => applyBoot
122
+ });
123
+ function applyBoot(next) {
124
+ BOOT = next;
125
+ API = next.base;
126
+ SECTIONS = next.sections;
127
+ }
128
+ var FALLBACK, BOOT, API, SECTIONS;
117
129
  var init_boot = __esm({
118
130
  "src/dashboard/client/boot.ts"() {
119
131
  "use strict";
120
- raw = globalThis.__BOOTSTRAP__;
121
- BOOT = raw ?? {
132
+ FALLBACK = {
122
133
  base: "",
123
134
  title: "bothandlerjs",
124
135
  allowReset: false,
@@ -129,6 +140,7 @@ var init_boot = __esm({
129
140
  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 },
130
141
  links: []
131
142
  };
143
+ BOOT = globalThis.__BOOTSTRAP__ ?? FALLBACK;
132
144
  API = BOOT.base;
133
145
  SECTIONS = BOOT.sections;
134
146
  }
@@ -203,48 +215,172 @@ var init_outcome = __esm({
203
215
  });
204
216
 
205
217
  // src/dashboard/client/query.ts
218
+ function suggestFor(input, caret) {
219
+ const before = input.slice(0, caret);
220
+ const start2 = Math.max(before.lastIndexOf(" ") + 1, 0);
221
+ const token = before.slice(start2);
222
+ const to = caret;
223
+ const negated = token.startsWith("-") || token.startsWith("!");
224
+ const body = negated ? token.slice(1) : token;
225
+ const colon = body.indexOf(":");
226
+ if (colon === -1) {
227
+ const partial2 = body.toLowerCase();
228
+ if (partial2.startsWith("$")) {
229
+ return { options: OPERATORS.filter((name) => name.startsWith(partial2)), from: start2, to };
230
+ }
231
+ const options2 = FIELD_NAMES.filter((name) => name.startsWith(partial2)).map((name) => `${negated ? token[0] : ""}${name}:`);
232
+ return { options: options2, from: start2, to };
233
+ }
234
+ const field2 = body.slice(0, colon).toLowerCase();
235
+ const values = FIELD_VALUES[field2];
236
+ if (values === void 0) return { options: [], from: start2, to };
237
+ const partial = body.slice(colon + 1).toLowerCase();
238
+ const prefix = `${negated ? token[0] : ""}${field2}:`;
239
+ const forms = partial.startsWith("$") ? ["$in(", "$notin("].filter((form) => form.startsWith(partial)).map((form) => `${prefix}${form}`) : [];
240
+ const options = [...forms, ...values.filter((value) => value.toLowerCase().startsWith(partial)).map((value) => `${prefix}${value}`)];
241
+ return { options, from: start2, to };
242
+ }
206
243
  function tokenize(input) {
207
244
  const tokens = [];
208
245
  let current = "";
209
246
  let quoted = false;
210
- for (const character of input) {
247
+ let depth = 0;
248
+ const flush = () => {
249
+ if (current === "") return;
250
+ const lower = current.toLowerCase();
251
+ if (lower === "$and" || lower === "$or" || lower === "$not") tokens.push({ kind: "op", op: lower.slice(1) });
252
+ else tokens.push({ kind: "word", text: current });
253
+ current = "";
254
+ };
255
+ for (let i = 0; i < input.length; i++) {
256
+ const character = input[i];
211
257
  if (character === '"') {
212
258
  quoted = !quoted;
213
259
  continue;
214
260
  }
215
- if (!quoted && /\s/.test(character)) {
216
- if (current !== "") tokens.push(current);
217
- current = "";
261
+ if (quoted) {
262
+ current += character;
263
+ continue;
264
+ }
265
+ if (depth > 0) {
266
+ current += character;
267
+ if (character === "(") depth++;
268
+ else if (character === ")") {
269
+ depth--;
270
+ if (depth === 0) flush();
271
+ }
272
+ continue;
273
+ }
274
+ if (character === "(") {
275
+ if (/\$(in|notin)$/i.test(current)) {
276
+ current += character;
277
+ depth = 1;
278
+ continue;
279
+ }
280
+ flush();
281
+ tokens.push({ kind: "open" });
282
+ continue;
283
+ }
284
+ if (character === ")") {
285
+ flush();
286
+ tokens.push({ kind: "close" });
287
+ continue;
288
+ }
289
+ if (/\s/.test(character)) {
290
+ flush();
218
291
  continue;
219
292
  }
220
293
  current += character;
221
294
  }
222
- if (current !== "") tokens.push(current);
295
+ flush();
223
296
  return tokens;
224
297
  }
225
- function parseQuery(input) {
226
- const terms = [];
227
- for (const token of tokenize(input.trim())) {
228
- const negated = token.startsWith("-") || token.startsWith("!");
229
- const body = negated ? token.slice(1) : token;
230
- if (body === "") continue;
231
- const colon = body.indexOf(":");
232
- const name = colon === -1 ? "" : body.slice(0, colon).toLowerCase();
233
- const field2 = FIELDS[name];
234
- if (colon === -1 || field2 === void 0) {
235
- terms.push({ field: void 0, value: body.toLowerCase(), negated });
236
- continue;
298
+ function toTerm(word) {
299
+ const negated = word.startsWith("-") || word.startsWith("!");
300
+ const body = negated ? word.slice(1) : word;
301
+ if (body === "") return void 0;
302
+ const colon = body.indexOf(":");
303
+ const name = colon === -1 ? "" : body.slice(0, colon).toLowerCase();
304
+ const field2 = FIELDS[name];
305
+ if (colon === -1 || field2 === void 0) return { field: void 0, value: body.toLowerCase(), negated };
306
+ const rest = body.slice(colon + 1);
307
+ const set = /^\$(in|notin)\(([\s\S]*)$/i.exec(rest);
308
+ if (set !== null) {
309
+ const inside = set[2].endsWith(")") ? set[2].slice(0, -1) : set[2];
310
+ const values = inside.split(",").map((entry) => entry.trim().toLowerCase()).filter((entry) => entry !== "");
311
+ const inverted = set[1].toLowerCase() === "notin";
312
+ return { field: field2, value: values[0] ?? "", negated: negated !== inverted, values };
313
+ }
314
+ let value = rest.toLowerCase();
315
+ let compare;
316
+ if (NUMERIC.has(field2)) {
317
+ compare = value.startsWith(">") ? ">" : value.startsWith("<") ? "<" : "=";
318
+ if (compare !== "=") value = value.slice(1);
319
+ }
320
+ if (value === "") return void 0;
321
+ return { field: field2, value, negated, compare };
322
+ }
323
+ function parseTokens(tokens) {
324
+ let at = 0;
325
+ let depth = 0;
326
+ const parseUnary = () => {
327
+ const token = tokens[at];
328
+ if (token === void 0) return void 0;
329
+ if (token.kind === "op" && token.op === "not") {
330
+ at++;
331
+ const of = parseUnary();
332
+ return of === void 0 ? void 0 : { kind: "not", of };
237
333
  }
238
- let value = body.slice(colon + 1).toLowerCase();
239
- let compare;
240
- if (NUMERIC.has(field2)) {
241
- compare = value.startsWith(">") ? ">" : value.startsWith("<") ? "<" : "=";
242
- if (compare !== "=") value = value.slice(1);
334
+ if (token.kind === "open") {
335
+ at++;
336
+ if (depth >= MAX_GROUP_DEPTH) return void 0;
337
+ depth++;
338
+ const inner = parseOr();
339
+ depth--;
340
+ if (tokens[at]?.kind === "close") at++;
341
+ return inner.kind === "all" ? void 0 : inner;
243
342
  }
244
- if (value === "") continue;
245
- terms.push({ field: field2, value, negated, compare });
246
- }
247
- return terms;
343
+ if (token.kind === "close") return void 0;
344
+ if (token.kind === "op") {
345
+ at++;
346
+ return parseUnary();
347
+ }
348
+ at++;
349
+ const term = toTerm(token.text);
350
+ return term === void 0 ? void 0 : { kind: "term", term };
351
+ };
352
+ const parseAnd = () => {
353
+ const parts = [];
354
+ while (at < tokens.length) {
355
+ const token = tokens[at];
356
+ if (token === void 0 || token.kind === "close") break;
357
+ if (token.kind === "op" && token.op === "or") break;
358
+ if (token.kind === "op" && token.op === "and") {
359
+ at++;
360
+ continue;
361
+ }
362
+ const before = at;
363
+ const part = parseUnary();
364
+ if (part !== void 0) parts.push(part);
365
+ if (at === before) at++;
366
+ }
367
+ return parts.length === 0 ? { kind: "all" } : parts.length === 1 ? parts[0] : { kind: "and", parts };
368
+ };
369
+ const parseOr = () => {
370
+ const parts = [parseAnd()];
371
+ while (tokens[at]?.kind === "op" && tokens[at].op === "or") {
372
+ at++;
373
+ parts.push(parseAnd());
374
+ }
375
+ const real = parts.filter((part) => part.kind !== "all");
376
+ if (real.length === 0) return { kind: "all" };
377
+ return real.length === 1 ? real[0] : { kind: "or", parts: real };
378
+ };
379
+ return parseOr();
380
+ }
381
+ function parseFilter(input) {
382
+ const trimmed = input.trim();
383
+ return parseTokens(tokenize(trimmed.length > MAX_QUERY_CHARS ? trimmed.slice(0, MAX_QUERY_CHARS) : trimmed));
248
384
  }
249
385
  function searchableText(entry) {
250
386
  const parts = [
@@ -298,6 +434,11 @@ function fieldValue(entry, field2) {
298
434
  }
299
435
  function matchesTerm(term, entry, haystack) {
300
436
  if (term.field === void 0) return haystack.includes(term.value);
437
+ if (term.values !== void 0) {
438
+ if (term.values.length === 0) return false;
439
+ const actual = fieldValue(entry, term.field).toLowerCase();
440
+ return term.values.some((value) => actual.includes(value));
441
+ }
301
442
  if (term.field === "score") {
302
443
  const wanted = Number(term.value);
303
444
  if (Number.isNaN(wanted)) return false;
@@ -307,11 +448,19 @@ function matchesTerm(term, entry, haystack) {
307
448
  }
308
449
  return fieldValue(entry, term.field).toLowerCase().includes(term.value);
309
450
  }
310
- function matchesQuery(terms, entry, haystack) {
311
- for (const term of terms) {
312
- if (matchesTerm(term, entry, haystack) === term.negated) return false;
451
+ function matches(filter, entry, haystack) {
452
+ switch (filter.kind) {
453
+ case "all":
454
+ return true;
455
+ case "term":
456
+ return matchesTerm(filter.term, entry, haystack) !== filter.term.negated;
457
+ case "not":
458
+ return !matches(filter.of, entry, haystack);
459
+ case "and":
460
+ return filter.parts.every((part) => matches(part, entry, haystack));
461
+ case "or":
462
+ return filter.parts.some((part) => matches(part, entry, haystack));
313
463
  }
314
- return true;
315
464
  }
316
465
  function matchesFilter(filter, entry) {
317
466
  switch (filter) {
@@ -333,7 +482,7 @@ function matchesFilter(filter, entry) {
333
482
  return true;
334
483
  }
335
484
  }
336
- var FIELDS, NUMERIC;
485
+ var FIELDS, NUMERIC, FIELD_NAMES, FIELD_VALUES, OPERATORS, MAX_GROUP_DEPTH, MAX_QUERY_CHARS;
337
486
  var init_query = __esm({
338
487
  "src/dashboard/client/query.ts"() {
339
488
  "use strict";
@@ -362,6 +511,19 @@ var init_query = __esm({
362
511
  certain: "certain"
363
512
  };
364
513
  NUMERIC = /* @__PURE__ */ new Set(["score"]);
514
+ FIELD_NAMES = Object.keys(FIELDS).sort();
515
+ FIELD_VALUES = {
516
+ verdict: ["confirmed-bot", "verified-bot", "suspected-bot", "human", "unknown"],
517
+ class: ["scanner", "scraper", "impersonator", "automation", "http-client", "declared-bot", "verified-bot", "human", "unknown"],
518
+ botclass: ["scanner", "scraper", "impersonator", "automation", "http-client", "declared-bot", "verified-bot", "human", "unknown"],
519
+ action: ["allow", "tag", "log", "challenge", "rate-limit", "delay", "block", "drop", "redirect"],
520
+ outcome: ["allow", "mitigate", "deny", "pending"],
521
+ certain: ["true", "false"],
522
+ method: ["GET", "POST", "HEAD", "PUT", "PATCH", "DELETE", "OPTIONS"]
523
+ };
524
+ OPERATORS = ["$and", "$or", "$not", "$in", "$notin"];
525
+ MAX_GROUP_DEPTH = 32;
526
+ MAX_QUERY_CHARS = 8192;
365
527
  }
366
528
  });
367
529
 
@@ -395,7 +557,12 @@ function clearFeed() {
395
557
  }
396
558
  function setSearch(value) {
397
559
  state.search = value;
398
- state.terms = parseQuery(value);
560
+ state.query = parseFilter(value);
561
+ resetPaging();
562
+ }
563
+ function setTimeframe(from, to) {
564
+ state.fromMs = from;
565
+ state.toMs = to;
399
566
  resetPaging();
400
567
  }
401
568
  function resetPaging() {
@@ -409,14 +576,16 @@ function textOf(row) {
409
576
  function sortRows() {
410
577
  state.rows.sort((a, b) => a.entry.at - b.entry.at);
411
578
  }
412
- function matches(row) {
413
- return matchesFilter(state.filter, row.entry) && matchesQuery(state.terms, row.entry, textOf(row));
579
+ function matches2(row) {
580
+ if (state.fromMs !== void 0 && row.entry.at < state.fromMs) return false;
581
+ if (state.toMs !== void 0 && row.entry.at > state.toMs) return false;
582
+ return matchesFilter(state.filter, row.entry) && matches(state.query, row.entry, textOf(row));
414
583
  }
415
584
  function matchingRows(limit = Number.POSITIVE_INFINITY) {
416
585
  const shown = [];
417
586
  for (let i = state.rows.length - 1; i >= 0 && shown.length < limit; i--) {
418
587
  const row = state.rows[i];
419
- if (row !== void 0 && matches(row)) shown.push(row);
588
+ if (row !== void 0 && matches2(row)) shown.push(row);
420
589
  }
421
590
  return shown;
422
591
  }
@@ -438,7 +607,7 @@ function goToFeedPage(page) {
438
607
  }
439
608
  function matchingCount() {
440
609
  let count = 0;
441
- for (const row of state.rows) if (matches(row)) count++;
610
+ for (const row of state.rows) if (matches2(row)) count++;
442
611
  return count;
443
612
  }
444
613
  function oldestAt() {
@@ -489,10 +658,11 @@ var init_store = __esm({
489
658
  policy: void 0,
490
659
  actors: [],
491
660
  actorsTracked: 0,
661
+ actorScope: "tracked",
492
662
  paused: false,
493
663
  filter: "all",
494
664
  search: "",
495
- terms: [],
665
+ query: { kind: "all" },
496
666
  tab: "live",
497
667
  open: /* @__PURE__ */ new Set(),
498
668
  actor: void 0,
@@ -509,6 +679,8 @@ var init_store = __esm({
509
679
  actorsPage: 0,
510
680
  feedPageSize: 50,
511
681
  actorsPageSize: 25,
682
+ fromMs: void 0,
683
+ toMs: void 0,
512
684
  caughtUp: 0
513
685
  };
514
686
  }
@@ -540,6 +712,49 @@ var init_api = __esm({
540
712
  }
541
713
  });
542
714
 
715
+ // src/dashboard/client/saved.ts
716
+ function read(key, fallback) {
717
+ if (isEmbedded()) return fallback;
718
+ try {
719
+ const raw = localStorage.getItem(key);
720
+ if (raw === null) return fallback;
721
+ const parsed = JSON.parse(raw);
722
+ return Array.isArray(parsed) ? parsed : fallback;
723
+ } catch {
724
+ return fallback;
725
+ }
726
+ }
727
+ function write(key, value) {
728
+ if (isEmbedded()) return;
729
+ try {
730
+ localStorage.setItem(key, JSON.stringify(value));
731
+ } catch {
732
+ }
733
+ }
734
+ function savedFilters() {
735
+ return read(FILTERS_KEY, []).filter((entry) => typeof entry?.name === "string");
736
+ }
737
+ function saveFilter(entry) {
738
+ const kept = savedFilters().filter((existing) => existing.name !== entry.name);
739
+ const next = [entry, ...kept].slice(0, MAX_SAVED);
740
+ write(FILTERS_KEY, next);
741
+ return next;
742
+ }
743
+ function deleteFilter(name) {
744
+ const next = savedFilters().filter((entry) => entry.name !== name);
745
+ write(FILTERS_KEY, next);
746
+ return next;
747
+ }
748
+ var FILTERS_KEY, MAX_SAVED;
749
+ var init_saved = __esm({
750
+ "src/dashboard/client/saved.ts"() {
751
+ "use strict";
752
+ init_dom();
753
+ FILTERS_KEY = "bothandler.filters";
754
+ MAX_SAVED = 50;
755
+ }
756
+ });
757
+
543
758
  // src/dashboard/client/format.ts
544
759
  function n(value) {
545
760
  return numbers.format(value ?? 0);
@@ -703,7 +918,7 @@ var init_replay = __esm({
703
918
  function isConfirming() {
704
919
  return armed > 0;
705
920
  }
706
- function actorActions(key, after) {
921
+ function actorActions(key, after, current) {
707
922
  if (!BOOT.allowActing) return [];
708
923
  const forget = el("button", null, "Forget");
709
924
  forget.title = "Discard this actor's history \u2014 the cure for a false positive that has stuck";
@@ -715,7 +930,76 @@ function actorActions(key, after) {
715
930
  clear2.addEventListener("click", () => {
716
931
  void act({ key, action: "clear", forMs: 60 * 6e4 }, `Cleared ${key}`, "Held as human for an hour, then reassessed.", after);
717
932
  });
718
- return [confirmingButton("Allowlist", `Allowlist ${key} \u2014 it stops being assessed at all`, () => allowlist(key, after)), forget, clear2];
933
+ const label2 = labelControl(key, current, after);
934
+ return [confirmingButton("Allowlist", `Allowlist ${key} \u2014 it stops being assessed at all`, () => allowlist(key, after)), forget, clear2, label2];
935
+ }
936
+ function labelControl(key, current, after) {
937
+ const host = el("span", "label-edit");
938
+ const button = el("button", null, current === void 0 ? "Label" : "Relabel");
939
+ button.title = "Give this actor a name, for whoever reads this next. It never changes a verdict.";
940
+ const commit = (value) => {
941
+ const trimmed = value.trim().slice(0, 120);
942
+ void act(
943
+ { key, action: "label", ...trimmed === "" ? {} : { label: trimmed } },
944
+ trimmed === "" ? `Cleared the label on ${key}` : `Labelled ${key}`,
945
+ trimmed === "" ? "It shows as its address again." : `Shown as "${trimmed}" wherever it appears.`,
946
+ after
947
+ );
948
+ };
949
+ button.addEventListener("click", () => {
950
+ const container2 = host.parentElement;
951
+ clear(host);
952
+ const input = el("input", "label-input");
953
+ input.type = "text";
954
+ input.value = current ?? "";
955
+ input.placeholder = "A name for this client";
956
+ input.setAttribute("aria-label", `Name for ${key}`);
957
+ input.maxLength = 120;
958
+ const save = el("button", "label-save", "Save");
959
+ save.title = "Save this name";
960
+ const cancel = el("button", null, "Cancel");
961
+ cancel.title = "Leave the name as it was";
962
+ input.title = "Enter to save, Escape to cancel";
963
+ armed++;
964
+ container2?.classList.add("editing");
965
+ let settled = false;
966
+ const finish = (accept) => {
967
+ if (settled) return;
968
+ settled = true;
969
+ armed--;
970
+ container2?.classList.remove("editing");
971
+ if (accept) commit(input.value);
972
+ else {
973
+ clear(host);
974
+ host.appendChild(button);
975
+ }
976
+ };
977
+ for (const control of [input, save, cancel]) {
978
+ control.addEventListener("keydown", (event) => {
979
+ const pressed = event.key;
980
+ if (pressed === "Escape") {
981
+ event.preventDefault();
982
+ finish(false);
983
+ } else if (pressed === "Enter" && control === input) {
984
+ event.preventDefault();
985
+ finish(true);
986
+ }
987
+ });
988
+ }
989
+ for (const control of [save, cancel]) control.addEventListener("mousedown", (event) => event.preventDefault());
990
+ save.addEventListener("click", () => finish(true));
991
+ cancel.addEventListener("click", () => finish(false));
992
+ host.addEventListener("focusout", (event) => {
993
+ const next = event.relatedTarget;
994
+ if (next !== null && host.contains(next)) return;
995
+ finish(false);
996
+ });
997
+ host.append(input, save, cancel);
998
+ input.focus();
999
+ input.select();
1000
+ });
1001
+ host.appendChild(button);
1002
+ return host;
719
1003
  }
720
1004
  function confirmingButton(label2, confirmation, run2) {
721
1005
  const button = el("button", "danger", label2);
@@ -1013,8 +1297,8 @@ function drawGuard() {
1013
1297
  preview.addEventListener("click", () => {
1014
1298
  void previewGuard();
1015
1299
  });
1016
- const apply = el("button", "primary", "Apply");
1017
- apply.addEventListener("click", () => {
1300
+ const apply2 = el("button", "primary", "Apply");
1301
+ apply2.addEventListener("click", () => {
1018
1302
  void applyGuard();
1019
1303
  });
1020
1304
  const revert = el("button", null, "Revert");
@@ -1024,7 +1308,7 @@ function drawGuard() {
1024
1308
  drawGuard();
1025
1309
  });
1026
1310
  bar.appendChild(preview);
1027
- bar.appendChild(apply);
1311
+ bar.appendChild(apply2);
1028
1312
  bar.appendChild(revert);
1029
1313
  panel.appendChild(bar);
1030
1314
  }
@@ -1948,7 +2232,11 @@ function initFeed() {
1948
2232
  setSearch(search.value.trim());
1949
2233
  app.syncUrl();
1950
2234
  app.drawNow();
2235
+ showSuggestions(search);
1951
2236
  });
2237
+ initSuggestions(search);
2238
+ initTimeframe();
2239
+ initSavedFilters(search);
1952
2240
  const exportShown = byId("feed-export");
1953
2241
  exportShown.hidden = !SECTIONS.evidence;
1954
2242
  exportShown.addEventListener("click", () => {
@@ -1972,15 +2260,17 @@ function reflectFilterButtons() {
1972
2260
  }
1973
2261
  async function loadSkipped() {
1974
2262
  const before = state.rows.length;
2263
+ let served;
1975
2264
  try {
1976
2265
  const body = await getJson("/api/feed");
1977
2266
  for (const entry of body.entries) ingest(entry);
2267
+ served = body.skipped;
1978
2268
  sortRows();
1979
2269
  } catch {
1980
2270
  toast("bad", "Could not load them", "The dashboard did not answer. The entries are still in the window; try again.");
1981
2271
  return;
1982
2272
  }
1983
- state.caughtUp = (state.snapshot?.skipped ?? 0) + state.laggedDrops;
2273
+ state.caughtUp = (served ?? state.snapshot?.skipped ?? 0) + state.laggedDrops;
1984
2274
  const added = state.rows.length - before;
1985
2275
  resetPaging();
1986
2276
  app.drawNow();
@@ -1990,6 +2280,160 @@ async function loadSkipped() {
1990
2280
  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."
1991
2281
  );
1992
2282
  }
2283
+ function suggestionList() {
2284
+ return $("search-suggest");
2285
+ }
2286
+ function closeSuggestions(input) {
2287
+ const list = suggestionList();
2288
+ list.hidden = true;
2289
+ clear(list);
2290
+ highlighted = -1;
2291
+ input.setAttribute("aria-expanded", "false");
2292
+ }
2293
+ function showSuggestions(input) {
2294
+ const caret = input.selectionStart ?? input.value.length;
2295
+ const { options, from, to } = suggestFor(input.value, caret);
2296
+ const list = suggestionList();
2297
+ if (options.length === 0 || input.value.slice(from, to) === options[0]) {
2298
+ closeSuggestions(input);
2299
+ return;
2300
+ }
2301
+ clear(list);
2302
+ highlighted = -1;
2303
+ options.slice(0, 12).forEach((option, index) => {
2304
+ const item = el("li", null, option);
2305
+ item.setAttribute("role", "option");
2306
+ item.setAttribute("aria-selected", "false");
2307
+ item.addEventListener("mousedown", (event) => {
2308
+ event.preventDefault();
2309
+ apply(input, option, from, to);
2310
+ });
2311
+ item.dataset["index"] = String(index);
2312
+ list.appendChild(item);
2313
+ });
2314
+ list.hidden = false;
2315
+ input.setAttribute("aria-expanded", "true");
2316
+ }
2317
+ function apply(input, option, from, to) {
2318
+ const before = input.value.slice(0, from);
2319
+ const after = input.value.slice(to);
2320
+ input.value = `${before}${option}${after}`;
2321
+ const caret = before.length + option.length;
2322
+ input.setSelectionRange(caret, caret);
2323
+ setSearch(input.value.trim());
2324
+ app.syncUrl();
2325
+ app.drawNow();
2326
+ closeSuggestions(input);
2327
+ showSuggestions(input);
2328
+ input.focus();
2329
+ }
2330
+ function initSuggestions(input) {
2331
+ input.addEventListener("keydown", (event) => {
2332
+ const list = suggestionList();
2333
+ const items = Array.from(list.querySelectorAll("li"));
2334
+ if (list.hidden || items.length === 0) return;
2335
+ if (event.key === "Escape") {
2336
+ closeSuggestions(input);
2337
+ return;
2338
+ }
2339
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
2340
+ event.preventDefault();
2341
+ highlighted = (highlighted + (event.key === "ArrowDown" ? 1 : items.length - 1) + (highlighted === -1 && event.key === "ArrowUp" ? 1 : 0)) % items.length;
2342
+ items.forEach((item, index) => item.setAttribute("aria-selected", String(index === highlighted)));
2343
+ return;
2344
+ }
2345
+ if ((event.key === "Enter" || event.key === "Tab") && highlighted >= 0) {
2346
+ const chosen = items[highlighted]?.textContent ?? "";
2347
+ const caret = input.selectionStart ?? input.value.length;
2348
+ const { from, to } = suggestFor(input.value, caret);
2349
+ event.preventDefault();
2350
+ apply(input, chosen, from, to);
2351
+ }
2352
+ });
2353
+ input.addEventListener("blur", () => {
2354
+ setTimeout(() => closeSuggestions(input), 120);
2355
+ });
2356
+ input.addEventListener("focus", () => showSuggestions(input));
2357
+ }
2358
+ function initSavedFilters(input) {
2359
+ const host = $("saved-filters");
2360
+ const redraw = () => {
2361
+ clear(host);
2362
+ const entries = savedFilters();
2363
+ const select2 = document.createElement("select");
2364
+ select2.setAttribute("aria-label", "Saved filters");
2365
+ const first = document.createElement("option");
2366
+ first.value = "";
2367
+ first.textContent = entries.length === 0 ? "No saved filters" : "Saved filters\u2026";
2368
+ select2.appendChild(first);
2369
+ for (const entry of entries) {
2370
+ const option = document.createElement("option");
2371
+ option.value = entry.name;
2372
+ option.textContent = entry.name;
2373
+ select2.appendChild(option);
2374
+ }
2375
+ select2.addEventListener("change", () => {
2376
+ const chosen = entries.find((entry) => entry.name === select2.value);
2377
+ if (chosen === void 0) return;
2378
+ input.value = chosen.query;
2379
+ setSearch(chosen.query);
2380
+ state.filter = chosen.filter;
2381
+ reflectFilterButtons();
2382
+ resetPaging();
2383
+ app.syncUrl();
2384
+ app.drawNow();
2385
+ });
2386
+ host.appendChild(select2);
2387
+ const save = el("button", null, "Save");
2388
+ save.type = "button";
2389
+ save.title = "Save this filter, in this browser, under a name";
2390
+ save.addEventListener("click", () => {
2391
+ const name = prompt("Save this filter as:")?.trim();
2392
+ if (name === void 0 || name === "") return;
2393
+ saveFilter({ name, query: input.value.trim(), filter: state.filter });
2394
+ redraw();
2395
+ toast("ok", "Filter saved", `"${name}" is in this browser. It is not shared with anybody else.`);
2396
+ });
2397
+ host.appendChild(save);
2398
+ if (select2.value !== "") {
2399
+ const remove = el("button", null, "Delete");
2400
+ remove.type = "button";
2401
+ remove.addEventListener("click", () => {
2402
+ deleteFilter(select2.value);
2403
+ redraw();
2404
+ });
2405
+ host.appendChild(remove);
2406
+ }
2407
+ };
2408
+ redraw();
2409
+ }
2410
+ function initTimeframe() {
2411
+ const from = byId("from-at");
2412
+ const to = byId("to-at");
2413
+ const clearButton = byId("timeframe-clear");
2414
+ const read2 = (input) => {
2415
+ if (input.value === "") return void 0;
2416
+ const parsed = new Date(input.value).getTime();
2417
+ return Number.isFinite(parsed) ? parsed : void 0;
2418
+ };
2419
+ const apply2 = () => {
2420
+ const start2 = read2(from);
2421
+ const end = read2(to);
2422
+ if (start2 !== void 0 && end !== void 0 && end < start2) {
2423
+ toast("warn", "That window runs backwards", "The end is before the start, so nothing can fall inside it.");
2424
+ }
2425
+ setTimeframe(start2, end);
2426
+ clearButton.hidden = start2 === void 0 && end === void 0;
2427
+ app.drawNow();
2428
+ };
2429
+ from.addEventListener("change", apply2);
2430
+ to.addEventListener("change", apply2);
2431
+ clearButton.addEventListener("click", () => {
2432
+ from.value = "";
2433
+ to.value = "";
2434
+ apply2();
2435
+ });
2436
+ }
1993
2437
  function drawPager(paged) {
1994
2438
  const size = state.feedPageSize;
1995
2439
  const hidden = paged.pages <= 1;
@@ -2158,12 +2602,13 @@ function buildDetail(entry) {
2158
2602
  } else {
2159
2603
  const list = el("div", "ev");
2160
2604
  for (const item of entry.evidence) {
2161
- const row = el("div", `ev-item${item.direction === "human" ? " human" : ""}`);
2605
+ const row = el("div", `ev-item${item.direction === "human" ? " human" : ""}${item.shadow === true ? " shadow" : ""}`);
2162
2606
  row.appendChild(el("div", `tier t-${item.certainty}`, item.certainty));
2163
2607
  const body = el("div");
2164
2608
  body.appendChild(el("div", null, item.summary));
2165
2609
  let meta = `${item.detector} \xB7 points to ${item.direction}`;
2166
2610
  if (item.family !== void 0) meta += ` \xB7 family \u201C${item.family}\u201D, counted once with its siblings`;
2611
+ if (item.shadow === true) meta += " \xB7 shadowed: counted, and part of no decision";
2167
2612
  body.appendChild(el("div", "ev-meta", meta));
2168
2613
  if (item.deterministicBasis !== void 0) body.appendChild(el("div", "basis", item.deterministicBasis));
2169
2614
  row.appendChild(body);
@@ -2171,6 +2616,17 @@ function buildDetail(entry) {
2171
2616
  }
2172
2617
  cell.appendChild(list);
2173
2618
  }
2619
+ if (entry.shadowVerdict !== void 0) {
2620
+ const would = entry.shadowVerdict;
2621
+ const changed = would.verdict !== entry.verdict;
2622
+ cell.appendChild(
2623
+ el(
2624
+ "div",
2625
+ `ev-meta shadow-verdict${changed ? " changed" : ""}`,
2626
+ 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}`}.`
2627
+ )
2628
+ );
2629
+ }
2174
2630
  for (const failure of entry.failures) {
2175
2631
  cell.appendChild(el("div", "ev-meta", `Detector ${failure.detector} ${failure.reason}: ${failure.message}`));
2176
2632
  }
@@ -2272,12 +2728,14 @@ function showText(text) {
2272
2728
  selection.addRange(range);
2273
2729
  }
2274
2730
  }
2275
- var FILTERS, rendered, FEED_PAGE_SIZES;
2731
+ var FILTERS, rendered, highlighted, FEED_PAGE_SIZES;
2276
2732
  var init_feed = __esm({
2277
2733
  "src/dashboard/client/feed.ts"() {
2278
2734
  "use strict";
2279
2735
  init_dom();
2280
2736
  init_store();
2737
+ init_saved();
2738
+ init_query();
2281
2739
  init_api();
2282
2740
  init_pager();
2283
2741
  init_boot();
@@ -2298,6 +2756,7 @@ var init_feed = __esm({
2298
2756
  ["allow", "Served"]
2299
2757
  ];
2300
2758
  rendered = /* @__PURE__ */ new Map();
2759
+ highlighted = -1;
2301
2760
  FEED_PAGE_SIZES = [25, 50, 100, 200];
2302
2761
  }
2303
2762
  });
@@ -2500,6 +2959,13 @@ function drawStatsPanels() {
2500
2959
  ["Actors tracked", n(metrics.actorsTracked)],
2501
2960
  ["Guard stops", n(metrics.downgrades)]
2502
2961
  ];
2962
+ const shadowed = pairs(metrics.shadowFirings);
2963
+ if (shadowed.length > 0) {
2964
+ const moved = Object.entries(metrics.shadowChanges).filter(([, count]) => count > 0);
2965
+ rows.push(["Shadowed findings", n(shadowed.reduce((total, [, count]) => total + count, 0))]);
2966
+ for (const [verdict, count] of moved) rows.push([`Would have become ${verdict}`, n(count)]);
2967
+ if (moved.length === 0) rows.push(["Verdicts they would have changed", "none"]);
2968
+ }
2503
2969
  const failures = pairs(metrics.detectorFailures);
2504
2970
  for (const [detector, count] of failures) rows.push([`Detector failures \u2014 ${detector}`, n(count)]);
2505
2971
  for (const [key, value] of rows) health.appendChild(statRow(key, value));
@@ -2516,14 +2982,14 @@ function drawStatsPanels() {
2516
2982
  clear(list);
2517
2983
  $("detector-count").textContent = `${snapshot.detectors.length} installed`;
2518
2984
  for (const detector of snapshot.detectors) {
2519
- const row = el("div", "det");
2985
+ const row = el("div", `det${detector.shadow === true ? " shadow" : ""}`);
2520
2986
  const left = el("div");
2521
2987
  left.appendChild(el("div", "mono", detector.id));
2522
2988
  left.appendChild(el("div", "d", detector.description));
2523
2989
  row.appendChild(left);
2524
- const fires = metrics?.detectorFirings[detector.id] ?? 0;
2990
+ const fires = (detector.shadow === true ? metrics?.shadowFirings[detector.id] : metrics?.detectorFirings[detector.id]) ?? 0;
2525
2991
  const timing = metrics?.detectorTimings[detector.id];
2526
- let right = `${n(fires)} \xB7 ${detector.cost} \xB7 ${detector.stage}`;
2992
+ let right = `${n(fires)}${detector.shadow === true ? " shadowed" : ""} \xB7 ${detector.cost} \xB7 ${detector.stage}`;
2527
2993
  if (timing !== void 0 && timing.count > 0) right += ` \xB7 ${ms(timing.totalMs / timing.count)} avg`;
2528
2994
  row.appendChild(el("div", "n", right));
2529
2995
  list.appendChild(row);
@@ -3047,6 +3513,7 @@ function trackActors() {
3047
3513
  void loadActors();
3048
3514
  timer = setInterval(() => {
3049
3515
  if (state.tab !== "actors" || state.paused || isConfirming()) return;
3516
+ if (state.actorScope === "feed") return;
3050
3517
  void loadActors();
3051
3518
  }, 4e3);
3052
3519
  }
@@ -3085,26 +3552,95 @@ function drawActorsPager(full) {
3085
3552
  else renderPager(host, model, { withSize });
3086
3553
  }
3087
3554
  }
3555
+ function feedActors() {
3556
+ const byKey = /* @__PURE__ */ new Map();
3557
+ for (const row of matchingRows()) {
3558
+ const entry = row.entry;
3559
+ let seen = byKey.get(entry.actor);
3560
+ if (seen === void 0) {
3561
+ seen = { rows: 0, paths: /* @__PURE__ */ new Set(), agents: /* @__PURE__ */ new Set(), first: entry.at, last: entry.at };
3562
+ byKey.set(entry.actor, seen);
3563
+ }
3564
+ seen.rows++;
3565
+ seen.paths.add(entry.path);
3566
+ seen.agents.add(entry.userAgent);
3567
+ if (entry.at < seen.first) seen.first = entry.at;
3568
+ if (entry.at > seen.last) seen.last = entry.at;
3569
+ seen.stats = entry.actorStats ?? seen.stats;
3570
+ }
3571
+ const labels = new Map(state.actors.filter((actor) => actor.label !== void 0).map((actor) => [actor.key, actor.label]));
3572
+ const out = [];
3573
+ for (const [key, seen] of byKey) {
3574
+ const label2 = labels.get(key);
3575
+ out.push({
3576
+ key,
3577
+ ...label2 === void 0 ? {} : { label: label2 },
3578
+ requests: seen.rows,
3579
+ recentRate: Number.NaN,
3580
+ distinctPaths: seen.paths.size,
3581
+ distinctUserAgents: seen.agents.size,
3582
+ cadenceCv: void 0,
3583
+ // A dash where the feed cannot know, like the three columns below it. A confident
3584
+ // zero under a heading that means "how many times has this client been proven a bot"
3585
+ // is worse than an admission, and it was the only column here still guessing.
3586
+ priorConfirmations: seen.stats?.priorConfirmations ?? Number.NaN,
3587
+ unsolvedChallenges: Number.NaN,
3588
+ cleared: seen.stats?.cleared ?? false,
3589
+ firstSeen: seen.stats?.firstSeen ?? seen.first,
3590
+ lastSeen: seen.last
3591
+ });
3592
+ }
3593
+ return out.sort((a, b) => b.requests - a.requests);
3594
+ }
3595
+ function applyActorScope(scope) {
3596
+ if (!SECTIONS.registry) return;
3597
+ state.actorScope = scope;
3598
+ for (const [id, on] of [
3599
+ ["actors-scope-tracked", scope === "tracked"],
3600
+ ["actors-scope-feed", scope === "feed"]
3601
+ ]) {
3602
+ byId(id).className = on ? "on" : "";
3603
+ byId(id).setAttribute("aria-pressed", String(on));
3604
+ }
3605
+ drawActors();
3606
+ }
3607
+ function initActorScope() {
3608
+ if (!SECTIONS.registry) return;
3609
+ const choose = (scope) => {
3610
+ applyActorScope(scope);
3611
+ app.syncUrl({ replace: false });
3612
+ };
3613
+ $("actors-scope-tracked").addEventListener("click", () => choose("tracked"));
3614
+ $("actors-scope-feed").addEventListener("click", () => choose("feed"));
3615
+ }
3088
3616
  function drawActors() {
3089
3617
  if (!SECTIONS.registry) return;
3090
3618
  if (isConfirming()) return;
3091
3619
  const body = byId("actor-rows");
3092
3620
  clear(body);
3093
- const actors = state.actors;
3094
- $("actors-count").textContent = `${n(actors.length)} shown \xB7 ${n(state.actorsTracked)} tracked`;
3095
- drawActorsPager(actors.length === state.actorsPageSize);
3621
+ const fromFeed = state.actorScope === "feed";
3622
+ const actors = fromFeed ? feedActors() : state.actors;
3623
+ $("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`;
3624
+ drawActorsPager(!fromFeed && actors.length === state.actorsPageSize);
3625
+ if (fromFeed) for (const id of ["actors-pager-top", "actors-pager"]) byId(id).hidden = true;
3096
3626
  byId("actors-empty").hidden = actors.length > 0;
3097
3627
  for (const actor of actors) {
3098
3628
  const row = el("tr");
3099
- row.appendChild(el("td", "who", actor.key));
3629
+ const who = el("td", "who");
3630
+ if (actor.label === void 0) who.textContent = actor.key;
3631
+ else {
3632
+ who.appendChild(el("div", "label", actor.label));
3633
+ who.appendChild(el("div", "sub", actor.key));
3634
+ }
3635
+ row.appendChild(who);
3100
3636
  row.appendChild(el("td", "num tnum", n(actor.requests)));
3101
- row.appendChild(el("td", "num tnum", n(actor.recentRate)));
3637
+ row.appendChild(el("td", "num tnum", Number.isNaN(actor.recentRate) ? "\u2014" : n(actor.recentRate)));
3102
3638
  row.appendChild(el("td", "num tnum", n(actor.distinctPaths)));
3103
3639
  const cadence = el("td", "num tnum", actor.cadenceCv === void 0 ? "\u2014" : actor.cadenceCv.toFixed(2));
3104
3640
  if (actor.cadenceCv !== void 0 && actor.cadenceCv < 0.15) cadence.className += " warn-text";
3105
3641
  row.appendChild(cadence);
3106
- row.appendChild(el("td", "num tnum", n(actor.priorConfirmations)));
3107
- const unsolved = el("td", "num tnum", n(actor.unsolvedChallenges));
3642
+ row.appendChild(el("td", "num tnum", Number.isNaN(actor.priorConfirmations) ? "\u2014" : n(actor.priorConfirmations)));
3643
+ const unsolved = el("td", "num tnum", Number.isNaN(actor.unsolvedChallenges) ? "\u2014" : n(actor.unsolvedChallenges));
3108
3644
  if (actor.unsolvedChallenges >= 3) unsolved.className += " warn-text";
3109
3645
  row.appendChild(unsolved);
3110
3646
  const stateCell = el("td");
@@ -3125,7 +3661,7 @@ function drawActors() {
3125
3661
  app.syncUrl();
3126
3662
  });
3127
3663
  actions.appendChild(inFeed);
3128
- for (const button of actorActions(actor.key, () => void loadActors())) actions.appendChild(button);
3664
+ for (const button of actorActions(actor.key, () => void loadActors(), actor.label)) actions.appendChild(button);
3129
3665
  row.appendChild(actions);
3130
3666
  body.appendChild(row);
3131
3667
  }
@@ -3158,13 +3694,13 @@ function initTester() {
3158
3694
  });
3159
3695
  }
3160
3696
  async function run() {
3161
- const raw2 = byId("test-input").value;
3162
- if (raw2.trim() === "") {
3697
+ const raw = byId("test-input").value;
3698
+ if (raw.trim() === "") {
3163
3699
  toast("warn", "Nothing to assess", "Paste a User-Agent, a curl command, or a header block.");
3164
3700
  return;
3165
3701
  }
3166
3702
  const result = await postJson("/api/test", {
3167
- raw: raw2,
3703
+ raw,
3168
3704
  ip: byId("test-ip").value.trim(),
3169
3705
  url: byId("test-url").value.trim()
3170
3706
  });
@@ -3304,12 +3840,12 @@ function initHeader() {
3304
3840
  }
3305
3841
  const header = rootNode().querySelector("header");
3306
3842
  if (header !== null) {
3307
- const apply = () => {
3843
+ const apply2 = () => {
3308
3844
  themeElement().style.setProperty("--header-h", `${header.getBoundingClientRect().height}px`);
3309
3845
  };
3310
- apply();
3311
- if (typeof ResizeObserver === "function") new ResizeObserver(apply).observe(header);
3312
- else addEventListener("resize", apply);
3846
+ apply2();
3847
+ if (typeof ResizeObserver === "function") new ResizeObserver(apply2).observe(header);
3848
+ else addEventListener("resize", apply2);
3313
3849
  }
3314
3850
  }
3315
3851
  function syncUrl(replace = true) {
@@ -3317,21 +3853,25 @@ function syncUrl(replace = true) {
3317
3853
  const params = new URLSearchParams();
3318
3854
  if (state.filter !== "all") params.set("f", state.filter);
3319
3855
  if (state.search !== "") params.set("q", state.search);
3856
+ if (state.actorScope !== "tracked") params.set("a", state.actorScope);
3320
3857
  const query = params.toString();
3321
3858
  const hash = `#${state.tab}${query === "" ? "" : `?${query}`}`;
3322
3859
  if (location.hash === hash) return;
3323
3860
  history[replace ? "replaceState" : "pushState"]({ tab: state.tab }, "", hash);
3324
3861
  }
3325
3862
  function readUrl() {
3326
- const raw2 = isEmbedded() ? "" : location.hash.slice(1);
3327
- const split = raw2.indexOf("?");
3328
- const name = split === -1 ? raw2 : raw2.slice(0, split);
3329
- const params = new URLSearchParams(split === -1 ? "" : raw2.slice(split + 1));
3863
+ const raw = isEmbedded() ? "" : location.hash.slice(1);
3864
+ const split = raw.indexOf("?");
3865
+ const name = split === -1 ? raw : raw.slice(0, split);
3866
+ const params = new URLSearchParams(split === -1 ? "" : raw.slice(split + 1));
3330
3867
  const filter = params.get("f") ?? "all";
3331
3868
  return {
3332
3869
  tab: isTab(name) ? name : FIRST,
3333
3870
  filter,
3334
- search: params.get("q") ?? ""
3871
+ search: params.get("q") ?? "",
3872
+ // Anything but the one alternative reads as the default rather than as an error:
3873
+ // a hand-edited URL should land somewhere, and this is the somewhere it lands.
3874
+ actorScope: params.get("a") === "feed" ? "feed" : "tracked"
3335
3875
  };
3336
3876
  }
3337
3877
  function showTab(name, options = {}) {
@@ -3375,6 +3915,7 @@ function initTabs() {
3375
3915
  state.filter = url.filter;
3376
3916
  setSearch(url.search);
3377
3917
  reflectFilterButtons();
3918
+ applyActorScope(url.actorScope);
3378
3919
  showTab(url.tab, { push: false });
3379
3920
  });
3380
3921
  }
@@ -3516,7 +4057,7 @@ function start() {
3516
4057
  app.draw = schedule;
3517
4058
  app.drawNow = drawNow;
3518
4059
  app.showTab = showTab;
3519
- app.syncUrl = () => syncUrl();
4060
+ app.syncUrl = (options) => syncUrl(options?.replace !== false);
3520
4061
  applySections();
3521
4062
  initHeader();
3522
4063
  drawPeers();
@@ -3526,6 +4067,7 @@ function start() {
3526
4067
  initRanges();
3527
4068
  if (SECTIONS.feed) initFeed();
3528
4069
  initActor();
4070
+ initActorScope();
3529
4071
  initTester();
3530
4072
  initPolicy();
3531
4073
  let resizeTimer;
@@ -3543,6 +4085,7 @@ function start() {
3543
4085
  state.filter = url.filter;
3544
4086
  setSearch(url.search);
3545
4087
  if (SECTIONS.feed) reflectFilterButtons();
4088
+ applyActorScope(url.actorScope);
3546
4089
  showTab(url.tab, { replace: true });
3547
4090
  suspendScrollAnchoring();
3548
4091
  void loadInitialSnapshot().finally(settleScrollAnchoring);
@@ -3640,12 +4183,12 @@ function resolveSections(fromServer, config) {
3640
4183
  }
3641
4184
  return { sections, warnings };
3642
4185
  }
3643
- function parseMount(raw2) {
3644
- const cut = raw2.search(/[?#]/);
3645
- if (cut === -1) return { base: raw2.replace(/\/$/, "") };
4186
+ function parseMount(raw) {
4187
+ const cut = raw.search(/[?#]/);
4188
+ if (cut === -1) return { base: raw.replace(/\/$/, "") };
3646
4189
  return {
3647
- base: raw2.slice(0, cut).replace(/\/$/, ""),
3648
- 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.`
4190
+ base: raw.slice(0, cut).replace(/\/$/, ""),
4191
+ 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.`
3649
4192
  };
3650
4193
  }
3651
4194
  function explainStatus(status, base) {
@@ -3894,6 +4437,33 @@ button.tile {
3894
4437
  cursor: pointer; appearance: none; transition: border-color .12s, box-shadow .12s;
3895
4438
  }
3896
4439
  button.tile:hover { border-color: var(--focus); }
4440
+ /* The suggestion list under the search box, positioned against the search wrapper. */
4441
+ .search { position: relative; }
4442
+ .suggest {
4443
+ position: absolute; top: calc(100% + 4px); left: 0; z-index: 30; margin: 0; padding: 4px;
4444
+ list-style: none; min-width: 220px; max-height: 260px; overflow-y: auto;
4445
+ background: var(--surface); border: 1px solid var(--line); border-radius: 9px; box-shadow: var(--shadow);
4446
+ }
4447
+ .suggest li { padding: 4px 9px; border-radius: 6px; cursor: pointer; font-size: 12px; }
4448
+ .suggest li[aria-selected="true"] { background: color-mix(in srgb, var(--focus) 18%, transparent); }
4449
+
4450
+ /* Saved filters: a list of small removable things. Quiet, because it is not what
4451
+ somebody came to the page to look at. */
4452
+ .saved { display: flex; align-items: center; gap: 6px; }
4453
+ /* Two open-ended bounds rather than a list of durations: "from the incident until now",
4454
+ "everything up to when it stopped" and "between these two moments" are the same control
4455
+ with one end left empty. */
4456
+ .timeframe { display: flex; align-items: center; gap: 8px; font-size: 11.5px; color: var(--muted); }
4457
+ .timeframe label { display: inline-flex; align-items: center; gap: 4px; }
4458
+ .timeframe input {
4459
+ font: inherit; font-size: 11.5px; padding: 2px 5px; border-radius: 6px;
4460
+ border: 1px solid var(--line); background: var(--surface); color: var(--ink);
4461
+ }
4462
+ .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; }
4463
+ .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; }
4464
+ .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; }
4465
+ .saved button:hover { border-color: var(--focus); }
4466
+
3897
4467
  /* The badge's companion: fetches the entries the stream skipped. Sits inline with the
3898
4468
  heading, so it is styled to read as part of the sentence rather than as a form control. */
3899
4469
  .load-skipped {
@@ -3915,6 +4485,10 @@ button.tile:hover { border-color: var(--focus); }
3915
4485
  .pager button:hover:not(:disabled) { border-color: var(--focus); }
3916
4486
  .pager button:disabled { opacity: .45; cursor: default; }
3917
4487
  .pager .where { font-variant-numeric: tabular-nums; }
4488
+ /* A labelled actor leads with its name and keeps the key underneath: whoever named it did
4489
+ so because the key was not the useful part, and the key is still what you search for. */
4490
+ td.who .label { font-weight: 560; }
4491
+ td.who .sub { color: var(--muted); font-size: 11px; }
3918
4492
  .pager.pager-top { padding: 2px 2px 9px; border-bottom: 1px solid var(--line); margin-bottom: 9px; }
3919
4493
  /* The feed's upper pager rides in the toolbar rather than owning a row of its own, which
3920
4494
  was thirty-six pixels of mostly empty rule above every screenful of requests. */
@@ -4035,7 +4609,29 @@ input[type="search"] {
4035
4609
  }
4036
4610
  input[type="search"]::placeholder { color: var(--muted); }
4037
4611
 
4038
- table { width: 100%; border-collapse: collapse; }
4612
+ /* separate with zero spacing rather than collapse, and the difference is the whole
4613
+ reason the column headers work in Safari.
4614
+
4615
+ Collapsed borders and sticky table cells are a long-standing sore point in WebKit: the
4616
+ CSSWG has an open issue on collapsed borders not following a cell when it sticks
4617
+ (csswg-drafts#3136), and Safari is widely reported to drop the stickiness of a th
4618
+ altogether under a collapsed table. Separating the borders is the standard remedy.
4619
+
4620
+ What was actually measured: the header sticks correctly in Chromium and in Firefox,
4621
+ both before and after this change, and it was reported adrift in Safari — which is what
4622
+ a sticky element that has stopped sticking looks like. WebKit could not be run on the
4623
+ machine this was written on, so the Safari half of it rests on that report and on the
4624
+ documented behaviour rather than on a measurement taken here.
4625
+
4626
+ The rendering is all but unchanged. Every border in these tables is a bottom border on
4627
+ the cell itself, plus the per-cell left accent on td.edge; no border is shared between
4628
+ two cells, so there is nothing for collapsing to merge and nothing for separating to
4629
+ double, and zero spacing keeps the cells touching. The one measurable difference is the
4630
+ accent column, which moves two pixels: collapsing centres that 3px border on the cell
4631
+ edge and leaves half of it outside the box, while separating puts all of it inside.
4632
+ Measured rather than assumed, and the leftmost column starting two pixels earlier is
4633
+ both imperceptible and the more correct of the two. */
4634
+ table { width: 100%; border-collapse: separate; border-spacing: 0; }
4039
4635
  thead th {
4040
4636
  /* Measured at runtime — see trackHeaderHeight(). The literal is the fallback for
4041
4637
  the instant before the first measurement, and for the tab strip wrapping. */
@@ -4327,9 +4923,44 @@ input::placeholder { color: color-mix(in srgb, var(--muted) 80%, transparent); }
4327
4923
  #actor-rows td { font-size: 12.5px; }
4328
4924
  #actor-rows td.who { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
4329
4925
  #actor-rows td.acts { text-align: right; white-space: nowrap; }
4926
+ /* The tracked/shown toggle above the actors table. A segmented pair rather than a
4927
+ dropdown: there are two answers and both are worth reading at a glance. */
4928
+ .scope { display: flex; gap: 6px; padding: 0 14px 10px; }
4929
+ .scope button { font-size: 11.5px; padding: 4px 10px; }
4930
+ .scope button.on { background: var(--accent); color: var(--on-accent, #fff); border-color: var(--accent); }
4931
+
4330
4932
  #actor-rows td.acts button { font-size: 11px; padding: 3px 8px; margin-left: 4px; }
4933
+ /* The Label control, which becomes a text box with a Save and a Cancel in place.
4934
+
4935
+ The cell does not wrap, so an editor that sat beside the row's other four buttons put
4936
+ Save off the right edge of the panel, where it could be seen and not clicked. While
4937
+ the editor is open it stands in for those buttons instead — which is also the right
4938
+ thing on its own, since Allowlist and Forget are not what somebody naming a client is
4939
+ reaching for. */
4940
+ .acts.editing > :not(.label-edit), .bar-actions.editing > :not(.label-edit) { display: none; }
4941
+ /* inline-flex rather than inline-block: the row is three fixed-size controls and a flex
4942
+ line is the layout that cannot spill them past its own edge. */
4943
+ .label-edit { display: inline-flex; align-items: center; gap: 4px; }
4944
+ .label-edit button { flex: 0 0 auto; }
4945
+ #actor-rows td.acts .label-save, #actor-actions .label-save { border-color: var(--accent); color: var(--accent); }
4946
+ /* Qualified with the element name on purpose: input[type="text"] { width: 100% } above
4947
+ outranks a bare class, so the width here was quietly ignored and the box grew to fill
4948
+ whatever it was in — which is what put Save and Cancel outside the panel. */
4949
+ input.label-input {
4950
+ font: inherit; font-size: 11px; padding: 3px 8px; width: 15ch; flex: 0 0 auto; box-sizing: border-box;
4951
+ color: var(--ink); background: var(--surface); border: 1px solid var(--accent); border-radius: 6px;
4952
+ }
4953
+ input.label-input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
4954
+
4331
4955
  /* A tag, not a warning: a cleared actor is a decision somebody made, and a metronomic
4332
4956
  one is a measurement. Neither is a verdict, so neither gets a verdict's colour. */
4957
+ /* A shadowed finding: shown at full detail, and visibly not part of the decision. Dimmed
4958
+ and set behind a rule rather than coloured, because every colour on this page already
4959
+ means something about a verdict and this one took no part in a verdict. */
4960
+ .det.shadow { opacity: 0.72; }
4961
+ .ev-item.shadow { opacity: 0.72; border-left: 2px dashed var(--line); padding-left: 8px; }
4962
+ .shadow-verdict { margin-top: 8px; font-style: italic; }
4963
+ .shadow-verdict.changed { color: var(--ink-2); font-style: normal; }
4333
4964
  .tagline { font-size: 11px; color: var(--muted); }
4334
4965
  .tagline b { color: var(--ink-2); font-weight: 600; }
4335
4966
 
@@ -4427,8 +5058,16 @@ var DASHBOARD_MARKUP = String.raw`
4427
5058
  <div class="toolbar">
4428
5059
  <div class="filters" id="filters"></div>
4429
5060
  <div class="search">
4430
- <input type="search" id="search" placeholder="path:/api actor:203.0.113.4 -rule:allow-crawlers" spellcheck="false" autocomplete="off">
5061
+ <input type="search" id="search" placeholder="path:/api actor:203.0.113.4 -rule:allow-crawlers" spellcheck="false" autocomplete="off"
5062
+ role="combobox" aria-expanded="false" aria-controls="search-suggest" aria-autocomplete="list">
4431
5063
  <kbd aria-hidden="true">/</kbd>
5064
+ <ul class="suggest" id="search-suggest" role="listbox" aria-label="Filter suggestions" hidden></ul>
5065
+ </div>
5066
+ <div class="saved" id="saved-filters"></div>
5067
+ <div class="timeframe" id="timeframe">
5068
+ <label>From <input type="datetime-local" id="from-at" step="1"></label>
5069
+ <label>To <input type="datetime-local" id="to-at" step="1"></label>
5070
+ <button type="button" id="timeframe-clear" hidden>Clear</button>
4432
5071
  </div>
4433
5072
  <button id="feed-export" title="Download every request matching this filter as replay JSONL">Export</button>
4434
5073
  <div class="pager pager-inline" id="feed-pager-top" hidden></div>
@@ -4492,6 +5131,12 @@ var DASHBOARD_MARKUP = String.raw`
4492
5131
  <div class="note" id="actors-note">Everyone the engine is currently remembering, busiest first — a far larger
4493
5132
  population than the feed's ring, which holds requests rather than clients. This is
4494
5133
  what <code>cadence</code>, <code>crawl-breadth</code> and <code>rate-anomaly</code> are reading.</div>
5134
+ <div class="scope" role="group" aria-label="Which actors to list">
5135
+ <button id="actors-scope-tracked" class="on" aria-pressed="true"
5136
+ title="Every client the engine is remembering, busiest first">Tracked</button>
5137
+ <button id="actors-scope-feed" aria-pressed="false"
5138
+ title="Only the clients that appear in the feed you are looking at, after its filter">Shown in the feed</button>
5139
+ </div>
4495
5140
  <div class="pager pager-top" id="actors-pager-top" hidden></div>
4496
5141
  <div class="feed-scroll">
4497
5142
  <table>
@@ -4936,6 +5581,8 @@ var BotDashboardElement = class extends ElementBase {
4936
5581
  const dom = await Promise.resolve().then(() => (init_dom(), dom_exports));
4937
5582
  dom.setRoot(frame, this);
4938
5583
  globalThis.__BOOTSTRAP__ = boot;
5584
+ const bootModule = await Promise.resolve().then(() => (init_boot(), boot_exports));
5585
+ bootModule.applyBoot(boot);
4939
5586
  await Promise.resolve().then(() => (init_client(), client_exports));
4940
5587
  this.relabelTabs(shadow);
4941
5588
  this.renderPanels(frame);