@osqd/bothandlerjs 0.3.0 → 0.5.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.
- package/CHANGELOG.md +157 -1
- package/README.md +1 -1
- package/dist/challenge/index.d.ts +18 -0
- package/dist/challenge/interaction.d.ts +215 -0
- package/dist/challenge/page.d.ts +18 -0
- package/dist/cli.cjs +829 -71
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +829 -71
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +10 -0
- package/dist/core.d.ts +15 -0
- package/dist/corpus/index.cjs +20 -0
- package/dist/corpus/index.cjs.map +1 -1
- package/dist/corpus/index.js +20 -0
- package/dist/corpus/index.js.map +1 -1
- package/dist/dashboard/client/actions.d.ts +17 -0
- package/dist/dashboard/client/actor.d.ts +5 -0
- package/dist/dashboard/client/api.d.ts +15 -0
- package/dist/dashboard/client/app.d.ts +37 -0
- package/dist/dashboard/client/bars.d.ts +4 -0
- package/dist/dashboard/client/boot.d.ts +5 -0
- package/dist/dashboard/client/charts.d.ts +25 -0
- package/dist/dashboard/client/css.d.ts +11 -0
- package/dist/dashboard/client/dom.d.ts +55 -0
- package/dist/dashboard/client/draft.d.ts +46 -0
- package/dist/dashboard/client/feed.d.ts +22 -0
- package/dist/dashboard/client/format.d.ts +37 -0
- package/dist/dashboard/client/guard.d.ts +3 -0
- package/dist/dashboard/client/index.d.ts +1 -0
- package/dist/dashboard/client/outcome.d.ts +33 -0
- package/dist/dashboard/client/pager.d.ts +32 -0
- package/dist/dashboard/client/panels.d.ts +40 -0
- package/dist/dashboard/client/policy.d.ts +25 -0
- package/dist/dashboard/client/query.d.ts +42 -0
- package/dist/dashboard/client/ranges.d.ts +2 -0
- package/dist/dashboard/client/registry.d.ts +4 -0
- package/dist/dashboard/client/replay.d.ts +14 -0
- package/dist/dashboard/client/result.d.ts +10 -0
- package/dist/dashboard/client/store.d.ts +128 -0
- package/dist/dashboard/client/stream.d.ts +14 -0
- package/dist/dashboard/client/tester.d.ts +1 -0
- package/dist/dashboard/client/types.d.ts +69 -0
- package/dist/dashboard/client.generated.d.ts +1 -1
- package/dist/dashboard/page.d.ts +25 -0
- package/dist/dashboard/sections.d.ts +92 -0
- package/dist/dashboard/types.d.ts +2 -82
- package/dist/detectors/crawler-verification.d.ts +34 -1
- package/dist/detectors/index.d.ts +4 -1
- package/dist/detectors/known-bots.d.ts +49 -1
- package/dist/element/config.d.ts +135 -0
- package/dist/element/index.cjs +5150 -0
- package/dist/element/index.cjs.map +1 -0
- package/dist/element/index.d.ts +137 -0
- package/dist/element/index.js +5131 -0
- package/dist/element/index.js.map +1 -0
- package/dist/index.cjs +820 -71
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +811 -71
- package/dist/index.js.map +1 -1
- package/dist/metrics.d.ts +27 -0
- package/dist/policy/index.d.ts +1 -1
- package/dist/policy/presets.d.ts +47 -0
- package/dist/state.d.ts +1 -1
- package/docs/challenge/index.md +1 -0
- package/docs/challenge/interaction.md +325 -0
- package/docs/course/11-the-challenge.md +36 -1
- package/docs/course/13-operating-it.md +20 -2
- package/docs/detection/signatures.md +36 -0
- package/docs/index.md +2 -0
- package/docs/operations/dashboard.md +28 -0
- package/docs/operations/embedding.md +348 -0
- package/docs/operations/index.md +1 -0
- package/docs/policy/presets.md +53 -0
- package/docs/start/choosing-a-policy.md +1 -0
- package/package.json +8 -3
package/dist/cli.cjs
CHANGED
|
@@ -366,7 +366,7 @@ var init_state = __esm({
|
|
|
366
366
|
* A read, and only a read: it neither records a request against an actor nor moves
|
|
367
367
|
* one up the LRU, so watching the list cannot change what it lists.
|
|
368
368
|
*/
|
|
369
|
-
top(limit, now) {
|
|
369
|
+
top(limit, now, offset = 0) {
|
|
370
370
|
const summaries = this.actors.values().map((state) => {
|
|
371
371
|
const cadence = state.intervalStats();
|
|
372
372
|
return {
|
|
@@ -380,7 +380,8 @@ var init_state = __esm({
|
|
|
380
380
|
};
|
|
381
381
|
});
|
|
382
382
|
summaries.sort((a, b) => b.requests - a.requests);
|
|
383
|
-
|
|
383
|
+
const from = Math.max(0, offset);
|
|
384
|
+
return summaries.slice(from, from + Math.max(0, limit));
|
|
384
385
|
}
|
|
385
386
|
forget(key) {
|
|
386
387
|
this.actors.delete(key);
|
|
@@ -448,9 +449,209 @@ function renderChallengePage(options) {
|
|
|
448
449
|
const config = jsonForScript({
|
|
449
450
|
challenge: options.challenge,
|
|
450
451
|
difficulty: options.difficulty,
|
|
451
|
-
verifyPath: options.verifyPath
|
|
452
|
+
verifyPath: options.verifyPath,
|
|
453
|
+
interaction: options.interaction === true
|
|
452
454
|
});
|
|
453
455
|
const contact = options.contactHtml ?? "";
|
|
456
|
+
const probe = options.probe ?? { boxes: 4, height: 7 };
|
|
457
|
+
const interactionBlock = options.interaction ? `<div class="check">
|
|
458
|
+
<input type="checkbox" id="confirm" autocomplete="off" aria-describedby="confirm-hint">
|
|
459
|
+
<div>
|
|
460
|
+
<label for="confirm">I am a person</label>
|
|
461
|
+
<!-- Associated with the input rather than left floating beside it: without
|
|
462
|
+
aria-describedby a screen reader announces "I am a person, checkbox" and never
|
|
463
|
+
reads the instruction. And the instruction says the space bar rather than "Tab,
|
|
464
|
+
then Space", because the page moves focus here as soon as the puzzle finishes \u2014
|
|
465
|
+
so Tab would move it away again. -->
|
|
466
|
+
<span class="hint" id="confirm-hint">Tick the box to continue, or press the space bar.</span>
|
|
467
|
+
</div>
|
|
468
|
+
</div>
|
|
469
|
+
<span id="probe-css" aria-hidden="true"></span>
|
|
470
|
+
<span id="probe-boxes" aria-hidden="true">${"<i></i>".repeat(probe.boxes)}</span>
|
|
471
|
+
<span id="probe-hidden" aria-hidden="true"></span>
|
|
472
|
+
<span class="probe-text" id="probe-a" aria-hidden="true">MMMMMMMMMM</span>
|
|
473
|
+
<span class="probe-text" id="probe-b" aria-hidden="true" style="font-family:monospace">MMMMMMMMMM</span>` : "";
|
|
474
|
+
const interactionStyles = options.interaction ? ` /* Tinted with the ink rather than mixed toward white. Mixing toward white lightens
|
|
475
|
+
the surface in *both* themes, which in dark mode put muted text on a mid grey and
|
|
476
|
+
failed contrast at 3.6:1. Tinting with --fg moves the surface the right way in each
|
|
477
|
+
theme and leaves the hint readable in both. */
|
|
478
|
+
.check { display: flex; align-items: center; gap: 12px; margin-top: 18px; padding: 14px 16px;
|
|
479
|
+
border: 1px solid var(--line); border-radius: 10px; background: color-mix(in srgb, var(--fg) 4%, transparent); }
|
|
480
|
+
/* Load-bearing: the display rule above outranks the user agent's [hidden] style, so
|
|
481
|
+
setting .hidden on this block does nothing without it \u2014 the control stays on screen
|
|
482
|
+
after the page has already given up. */
|
|
483
|
+
.check[hidden] { display: none; }
|
|
484
|
+
.check input { width: 20px; height: 20px; flex: none; accent-color: var(--accent); cursor: pointer; }
|
|
485
|
+
.check label { cursor: pointer; }
|
|
486
|
+
.check .hint { display: block; font-size: 0.85rem; color: var(--muted); margin-top: 2px; }
|
|
487
|
+
/* Read back by the first probe. A client that parsed the HTML but never built a CSSOM
|
|
488
|
+
cannot report this value, because nothing ever computed it. */
|
|
489
|
+
#probe-css { letter-spacing: 3px; }
|
|
490
|
+
#probe-hidden { display: none; }
|
|
491
|
+
/* The nonce-bound probe. Its height is the answer the server is asking for, so it is
|
|
492
|
+
stated once here and read from src/challenge/interaction.ts on the other side. */
|
|
493
|
+
#probe-boxes { position: absolute; left: -10000px; top: auto; width: 1px; }
|
|
494
|
+
#probe-boxes i { display: block; height: ${probe.height}px; }
|
|
495
|
+
.probe-text { position: absolute; left: -10000px; top: auto; white-space: pre; font-size: 32px; }` : "";
|
|
496
|
+
const interactionScript = options.interaction ? String.raw` // ---- the interaction challenge -------------------------------------------------
|
|
497
|
+
//
|
|
498
|
+
// Two jobs: watch what the pointer does on the way to the control, and find out what
|
|
499
|
+
// this browser can actually do. Everything gathered here is a *claim* — the server
|
|
500
|
+
// decides what any of it is worth, because a page that scored itself would just be
|
|
501
|
+
// asked to report a good score. See src/challenge/interaction.ts.
|
|
502
|
+
|
|
503
|
+
var box = document.getElementById("confirm");
|
|
504
|
+
var path = [];
|
|
505
|
+
var lastMove = null;
|
|
506
|
+
var lastPointerType = "";
|
|
507
|
+
var capabilities = {};
|
|
508
|
+
var layoutHeight;
|
|
509
|
+
|
|
510
|
+
// One clock for everything in here.
|
|
511
|
+
//
|
|
512
|
+
// This was two, and the bug that caused made the whole movement analysis dead code.
|
|
513
|
+
// Pointer timestamps are DOMHighResTimeStamps measured from the time origin — about
|
|
514
|
+
// 1500 by the time somebody clicks — and they were compared against Date.now(), which
|
|
515
|
+
// is about 1.75e12. The difference is never under two seconds, so every activation
|
|
516
|
+
// classified as a keyboard, the pointer branch never ran once, and a real 27-sample
|
|
517
|
+
// mouse path was collected, posted and thrown away.
|
|
518
|
+
function nowMs() {
|
|
519
|
+
return window.performance && performance.now ? performance.now() : Date.now();
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function probe() {
|
|
523
|
+
// Did a CSSOM parse the stylesheet and run the cascade? An HTTP client that read the
|
|
524
|
+
// HTML has no answer to this, because nothing computed a value to read back.
|
|
525
|
+
try {
|
|
526
|
+
var el = document.getElementById("probe-css");
|
|
527
|
+
capabilities.cssApplied = !!el && getComputedStyle(el).letterSpacing === "3px";
|
|
528
|
+
} catch (error) { capabilities.cssApplied = false; }
|
|
529
|
+
|
|
530
|
+
// Did layout run?
|
|
531
|
+
try {
|
|
532
|
+
var main = document.querySelector("main");
|
|
533
|
+
capabilities.layout = !!main && main.getBoundingClientRect().width > 0;
|
|
534
|
+
} catch (error) { capabilities.layout = false; }
|
|
535
|
+
|
|
536
|
+
// Is display:none honoured, rather than merely parsed?
|
|
537
|
+
try {
|
|
538
|
+
var hidden = document.getElementById("probe-hidden");
|
|
539
|
+
capabilities.hiddenIsHidden = !!hidden && hidden.getBoundingClientRect().width === 0;
|
|
540
|
+
} catch (error) { capabilities.hiddenIsHidden = false; }
|
|
541
|
+
|
|
542
|
+
// The nonce-bound probe: lay out a number of boxes derived from this challenge's
|
|
543
|
+
// nonce and measure the result. Every other answer in this report is the same from
|
|
544
|
+
// one challenge to the next and can therefore be captured once and replayed for
|
|
545
|
+
// ever; this one cannot, because the question changes.
|
|
546
|
+
try {
|
|
547
|
+
var boxes = document.getElementById("probe-boxes");
|
|
548
|
+
if (boxes) layoutHeight = Math.round(boxes.getBoundingClientRect().height * 100) / 100;
|
|
549
|
+
} catch (error) { layoutHeight = undefined; }
|
|
550
|
+
|
|
551
|
+
// Is there a font engine? Two identical strings in different families measure
|
|
552
|
+
// differently only if something actually shaped the text.
|
|
553
|
+
try {
|
|
554
|
+
var a = document.getElementById("probe-a");
|
|
555
|
+
var b = document.getElementById("probe-b");
|
|
556
|
+
var wa = a ? a.getBoundingClientRect().width : 0;
|
|
557
|
+
var wb = b ? b.getBoundingClientRect().width : 0;
|
|
558
|
+
capabilities.fontMetrics = wa > 0 && wb > 0 && Math.abs(wa - wb) > 0.5;
|
|
559
|
+
} catch (error) { capabilities.fontMetrics = false; }
|
|
560
|
+
|
|
561
|
+
// Does a media query evaluate against a real viewport?
|
|
562
|
+
try {
|
|
563
|
+
capabilities.mediaQuery = !!window.matchMedia && window.matchMedia("(min-width: 1px)").matches === true;
|
|
564
|
+
} catch (error) { capabilities.mediaQuery = false; }
|
|
565
|
+
|
|
566
|
+
// Is there a frame loop? Two frames, a plausible gap apart.
|
|
567
|
+
//
|
|
568
|
+
// Deferred while the tab is hidden, because a hidden tab does not paint and
|
|
569
|
+
// requestAnimationFrame does not fire in one. Probing anyway would report false for
|
|
570
|
+
// somebody who opened the page in a background tab and come back to bite them when
|
|
571
|
+
// they switched to it — and they cannot tick the box without switching to it, so
|
|
572
|
+
// waiting for that moment costs nothing and removes the false negative.
|
|
573
|
+
capabilities.animationFrame = false;
|
|
574
|
+
function probeFrames() {
|
|
575
|
+
try {
|
|
576
|
+
if (!window.requestAnimationFrame) return;
|
|
577
|
+
requestAnimationFrame(function (first) {
|
|
578
|
+
requestAnimationFrame(function (second) {
|
|
579
|
+
capabilities.animationFrame = second > first && second - first < 1000;
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
} catch (error) { capabilities.animationFrame = false; }
|
|
583
|
+
}
|
|
584
|
+
if (document.hidden) {
|
|
585
|
+
document.addEventListener("visibilitychange", function once() {
|
|
586
|
+
if (document.hidden) return;
|
|
587
|
+
document.removeEventListener("visibilitychange", once);
|
|
588
|
+
probeFrames();
|
|
589
|
+
});
|
|
590
|
+
} else {
|
|
591
|
+
probeFrames();
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function watch() {
|
|
596
|
+
// Passive listeners: this must never delay a scroll or a tap.
|
|
597
|
+
window.addEventListener("pointermove", function (event) {
|
|
598
|
+
if (!event.isTrusted) return;
|
|
599
|
+
lastPointerType = event.pointerType || "";
|
|
600
|
+
var now = nowMs();
|
|
601
|
+
if (lastMove !== null) {
|
|
602
|
+
// A rolling window of the *most recent* samples, which is the opposite of what
|
|
603
|
+
// this did first. Capping the array by refusing to push once full kept the first
|
|
604
|
+
// 128 samples and threw away everything after — so for anyone who moved the
|
|
605
|
+
// mouse while reading, the analysis measured their idle wandering and never saw
|
|
606
|
+
// the approach to the control, which is the ballistic-then-corrective movement
|
|
607
|
+
// the whole thing is built to recognise. It was exploitable in the obvious
|
|
608
|
+
// direction too: emit plausible noise on load, then move however you like.
|
|
609
|
+
if (path.length >= 128) path.shift();
|
|
610
|
+
// Rounded to two places: enough to keep the sub-pixel deltas that pointer
|
|
611
|
+
// acceleration and touch produce, without shipping a precise cursor trace.
|
|
612
|
+
path.push([
|
|
613
|
+
Math.round((event.clientX - lastMove.x) * 100) / 100,
|
|
614
|
+
Math.round((event.clientY - lastMove.y) * 100) / 100,
|
|
615
|
+
Math.max(0, Math.round(now - lastMove.t)),
|
|
616
|
+
]);
|
|
617
|
+
}
|
|
618
|
+
lastMove = { x: event.clientX, y: event.clientY, t: now };
|
|
619
|
+
}, { passive: true });
|
|
620
|
+
|
|
621
|
+
// The click event's detail property is how the platform itself distinguishes the
|
|
622
|
+
// two: it is the click count for a pointer, and exactly 0 when a control is
|
|
623
|
+
// activated from the keyboard.
|
|
624
|
+
// A time-based guess was used here first and was wrong in both directions — a stray
|
|
625
|
+
// pointermove at load made a keypress look like a mouse, and a tap, which emits
|
|
626
|
+
// almost no pointermove, looked like one too.
|
|
627
|
+
var activationVia = "keyboard";
|
|
628
|
+
box.addEventListener("click", function (event) {
|
|
629
|
+
if (event.detail === 0) { activationVia = "keyboard"; return; }
|
|
630
|
+
activationVia = lastPointerType === "touch" || lastPointerType === "pen" ? "touch" : "pointer";
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
box.addEventListener("pointerdown", function (event) {
|
|
634
|
+
if (event.isTrusted) lastPointerType = event.pointerType || "";
|
|
635
|
+
}, { passive: true });
|
|
636
|
+
|
|
637
|
+
box.addEventListener("change", function (event) {
|
|
638
|
+
if (!box.checked) { activation = null; say("Tick the box to continue."); return; }
|
|
639
|
+
activation = {
|
|
640
|
+
trusted: event.isTrusted === true,
|
|
641
|
+
// Set by the click handler above. A path is expected from a mouse and from
|
|
642
|
+
// nothing else: touch produces almost no pointermove — one sample, frequently
|
|
643
|
+
// none — so reporting a tap as a mouse would score zero for movement and quietly
|
|
644
|
+
// grade every phone down to the weaker clearance.
|
|
645
|
+
via: activationVia,
|
|
646
|
+
msToActivate: Date.now() - started,
|
|
647
|
+
path: path.slice(),
|
|
648
|
+
capabilities: capabilities,
|
|
649
|
+
layoutHeight: layoutHeight,
|
|
650
|
+
};
|
|
651
|
+
say(solved === null ? "Thank you. Finishing the check…" : "Thank you. Almost done…");
|
|
652
|
+
if (solved !== null) submit(solved);
|
|
653
|
+
});
|
|
654
|
+
}` : "";
|
|
454
655
|
const html = `<!doctype html>
|
|
455
656
|
<html lang="${lang}">
|
|
456
657
|
<head>
|
|
@@ -477,12 +678,14 @@ function renderChallengePage(options) {
|
|
|
477
678
|
@media (prefers-reduced-motion: reduce) { .spinner { animation-duration: 3s; } }
|
|
478
679
|
.fallback { margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--line); font-size: 0.9rem; color: var(--muted); }
|
|
479
680
|
a { color: var(--accent); }
|
|
681
|
+
${interactionStyles}
|
|
480
682
|
</style>
|
|
481
683
|
</head>
|
|
482
684
|
<body>
|
|
483
685
|
<main>
|
|
484
686
|
<h1>${title}</h1>
|
|
485
687
|
<p>${message}</p>
|
|
688
|
+
${interactionBlock}
|
|
486
689
|
<div class="status">
|
|
487
690
|
<div class="spinner" id="spin" aria-hidden="true"></div>
|
|
488
691
|
<span id="status" role="status" aria-live="polite">Starting\u2026</span>
|
|
@@ -511,6 +714,12 @@ function renderChallengePage(options) {
|
|
|
511
714
|
say(text);
|
|
512
715
|
spinner.hidden = true;
|
|
513
716
|
if (help) help.hidden = false;
|
|
717
|
+
// Take the gesture away with it. Whatever went wrong, the check cannot be completed
|
|
718
|
+
// now, and leaving a live checkbox on screen offers a way out that does not exist \u2014
|
|
719
|
+
// worst of all to somebody using a screen reader, who finds a control, activates it,
|
|
720
|
+
// and is told nothing at all. Hiding it removes it from the accessibility tree too.
|
|
721
|
+
var gesture = document.querySelector(".check");
|
|
722
|
+
if (gesture) gesture.hidden = true;
|
|
514
723
|
}
|
|
515
724
|
|
|
516
725
|
if (!window.crypto || !window.crypto.subtle || !window.TextEncoder) {
|
|
@@ -555,13 +764,27 @@ function renderChallengePage(options) {
|
|
|
555
764
|
step();
|
|
556
765
|
}
|
|
557
766
|
|
|
767
|
+
${interactionScript}
|
|
768
|
+
|
|
769
|
+
var solved = null;
|
|
770
|
+
var activation = null;
|
|
771
|
+
|
|
558
772
|
function submit(solution) {
|
|
773
|
+
// Both halves have to be in. The proof of work usually finishes first, so this is
|
|
774
|
+
// normally the page waiting for the person rather than the other way round.
|
|
775
|
+
if (config.interaction && activation === null) {
|
|
776
|
+
solved = solution;
|
|
777
|
+
say("Ready. Tick the box below to continue.");
|
|
778
|
+
spinner.hidden = true;
|
|
779
|
+
if (box) box.focus();
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
559
782
|
say("Almost done\u2026");
|
|
560
783
|
fetch(config.verifyPath, {
|
|
561
784
|
method: "POST",
|
|
562
785
|
headers: { "content-type": "application/json" },
|
|
563
786
|
credentials: "same-origin",
|
|
564
|
-
body: JSON.stringify({ challenge: config.challenge, solution: solution, ms: Date.now() - started })
|
|
787
|
+
body: JSON.stringify({ challenge: config.challenge, solution: solution, ms: Date.now() - started, interaction: activation })
|
|
565
788
|
}).then(function (response) {
|
|
566
789
|
if (!response.ok) { stop("The check could not be completed. Please reload the page to try again."); return; }
|
|
567
790
|
say("Done. Loading the page\u2026");
|
|
@@ -571,6 +794,11 @@ function renderChallengePage(options) {
|
|
|
571
794
|
});
|
|
572
795
|
}
|
|
573
796
|
|
|
797
|
+
if (config.interaction && box) {
|
|
798
|
+
probe();
|
|
799
|
+
watch();
|
|
800
|
+
}
|
|
801
|
+
|
|
574
802
|
slice();
|
|
575
803
|
})();
|
|
576
804
|
</script>
|
|
@@ -745,6 +973,204 @@ var init_token = __esm({
|
|
|
745
973
|
}
|
|
746
974
|
});
|
|
747
975
|
|
|
976
|
+
// src/challenge/interaction.ts
|
|
977
|
+
function probeShapeFor(nonce, secret) {
|
|
978
|
+
const digest = shortHash(`probe:${nonce}`, secret);
|
|
979
|
+
let a = 0;
|
|
980
|
+
let b = 0;
|
|
981
|
+
for (let i = 0; i < digest.length; i++) {
|
|
982
|
+
if (i % 2 === 0) a += digest.charCodeAt(i);
|
|
983
|
+
else b += digest.charCodeAt(i);
|
|
984
|
+
}
|
|
985
|
+
return { boxes: 4 + a % 21, height: 3 + b % 14 };
|
|
986
|
+
}
|
|
987
|
+
function analyseMovement(path) {
|
|
988
|
+
const samples = path.slice(0, MAX_SAMPLES);
|
|
989
|
+
if (samples.length < 2) {
|
|
990
|
+
return { samples: samples.length, distanceVariation: 0, speedVariation: 0, timingVariation: 0, accelerationChanges: 0, straightness: 1, fractionalShare: 0, totalTurning: 0 };
|
|
991
|
+
}
|
|
992
|
+
const speeds = [];
|
|
993
|
+
const distances = [];
|
|
994
|
+
const gaps = [];
|
|
995
|
+
const angles = [];
|
|
996
|
+
let pathLength = 0;
|
|
997
|
+
let netX = 0;
|
|
998
|
+
let netY = 0;
|
|
999
|
+
let fractional = 0;
|
|
1000
|
+
for (const sample of samples) {
|
|
1001
|
+
if (sample.dt > CONTINUOUS_GAP_MS) continue;
|
|
1002
|
+
const distance = Math.hypot(sample.dx, sample.dy);
|
|
1003
|
+
const gap = Math.max(sample.dt, 1);
|
|
1004
|
+
speeds.push(distance / gap);
|
|
1005
|
+
distances.push(distance);
|
|
1006
|
+
gaps.push(gap);
|
|
1007
|
+
pathLength += distance;
|
|
1008
|
+
netX += sample.dx;
|
|
1009
|
+
netY += sample.dy;
|
|
1010
|
+
if (!Number.isInteger(sample.dx) || !Number.isInteger(sample.dy)) fractional++;
|
|
1011
|
+
if (distance > 0) angles.push(Math.atan2(sample.dy, sample.dx));
|
|
1012
|
+
}
|
|
1013
|
+
let accelerationChanges = 0;
|
|
1014
|
+
for (let i = 2; i < speeds.length; i++) {
|
|
1015
|
+
const previous = speeds[i - 1] - speeds[i - 2];
|
|
1016
|
+
const current = speeds[i] - speeds[i - 1];
|
|
1017
|
+
if (previous !== 0 && current !== 0 && Math.sign(previous) !== Math.sign(current)) accelerationChanges++;
|
|
1018
|
+
}
|
|
1019
|
+
let totalTurning = 0;
|
|
1020
|
+
for (let i = 1; i < angles.length; i++) {
|
|
1021
|
+
let turn = angles[i] - angles[i - 1];
|
|
1022
|
+
while (turn > Math.PI) turn -= 2 * Math.PI;
|
|
1023
|
+
while (turn < -Math.PI) turn += 2 * Math.PI;
|
|
1024
|
+
totalTurning += Math.abs(turn);
|
|
1025
|
+
}
|
|
1026
|
+
return {
|
|
1027
|
+
samples: distances.length,
|
|
1028
|
+
distanceVariation: coefficientOfVariation(distances),
|
|
1029
|
+
speedVariation: coefficientOfVariation(speeds),
|
|
1030
|
+
timingVariation: coefficientOfVariation(gaps),
|
|
1031
|
+
accelerationChanges,
|
|
1032
|
+
straightness: pathLength === 0 ? 1 : Math.min(1, Math.hypot(netX, netY) / pathLength),
|
|
1033
|
+
fractionalShare: fractional / samples.length,
|
|
1034
|
+
totalTurning
|
|
1035
|
+
};
|
|
1036
|
+
}
|
|
1037
|
+
function coefficientOfVariation(values) {
|
|
1038
|
+
if (values.length === 0) return 0;
|
|
1039
|
+
const mean = values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
1040
|
+
if (mean === 0) return 0;
|
|
1041
|
+
const variance = values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / values.length;
|
|
1042
|
+
return Math.sqrt(variance) / mean;
|
|
1043
|
+
}
|
|
1044
|
+
function scoreMovement(analysis) {
|
|
1045
|
+
if (analysis.samples < 4) return 0;
|
|
1046
|
+
let score = 0;
|
|
1047
|
+
score += clamp01(analysis.distanceVariation / 0.55) * 0.3;
|
|
1048
|
+
score += clamp01((1 - analysis.straightness) / 0.2) * 0.25;
|
|
1049
|
+
score += clamp01(analysis.speedVariation / 0.6) * 0.15;
|
|
1050
|
+
score += clamp01(analysis.timingVariation / 0.35) * 0.1;
|
|
1051
|
+
score += clamp01(analysis.accelerationChanges / 6) * 0.1;
|
|
1052
|
+
score += clamp01(analysis.fractionalShare / 0.3) * 0.1;
|
|
1053
|
+
return clamp01(score);
|
|
1054
|
+
}
|
|
1055
|
+
function scoreCapabilities(capabilities) {
|
|
1056
|
+
let earned = 0;
|
|
1057
|
+
let available = 0;
|
|
1058
|
+
for (const [name, weight] of Object.entries(CAPABILITY_WEIGHTS)) {
|
|
1059
|
+
available += weight;
|
|
1060
|
+
if (capabilities[name] === true) earned += weight;
|
|
1061
|
+
}
|
|
1062
|
+
return available === 0 ? 0 : earned / available;
|
|
1063
|
+
}
|
|
1064
|
+
function clamp01(value) {
|
|
1065
|
+
if (!Number.isFinite(value)) return 0;
|
|
1066
|
+
return Math.min(1, Math.max(0, value));
|
|
1067
|
+
}
|
|
1068
|
+
function parseInteractionReport(value) {
|
|
1069
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
1070
|
+
const raw = value;
|
|
1071
|
+
const via = raw["via"];
|
|
1072
|
+
const capabilities = {};
|
|
1073
|
+
if (typeof raw["capabilities"] === "object" && raw["capabilities"] !== null) {
|
|
1074
|
+
const supplied = raw["capabilities"];
|
|
1075
|
+
for (const name of Object.keys(CAPABILITY_WEIGHTS)) {
|
|
1076
|
+
if (supplied[name] === true) capabilities[name] = true;
|
|
1077
|
+
else if (supplied[name] === false) capabilities[name] = false;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
const path = [];
|
|
1081
|
+
if (Array.isArray(raw["path"])) {
|
|
1082
|
+
for (const entry of raw["path"].slice(0, MAX_SAMPLES)) {
|
|
1083
|
+
if (!Array.isArray(entry) || entry.length < 3) continue;
|
|
1084
|
+
const [dx, dy, dt] = entry;
|
|
1085
|
+
if (typeof dx !== "number" || typeof dy !== "number" || typeof dt !== "number") continue;
|
|
1086
|
+
if (!Number.isFinite(dx) || !Number.isFinite(dy) || !Number.isFinite(dt)) continue;
|
|
1087
|
+
if (Math.abs(dx) > 1e4 || Math.abs(dy) > 1e4 || dt < 0 || dt > 6e4) continue;
|
|
1088
|
+
path.push({ dx, dy, dt });
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
const layoutHeight = raw["layoutHeight"];
|
|
1092
|
+
return {
|
|
1093
|
+
trusted: raw["trusted"] === true,
|
|
1094
|
+
...typeof layoutHeight === "number" && Number.isFinite(layoutHeight) && layoutHeight >= 0 && layoutHeight < 1e5 ? { layoutHeight } : {},
|
|
1095
|
+
via: via === "pointer" || via === "touch" || via === "keyboard" ? via : "other",
|
|
1096
|
+
msToActivate: typeof raw["msToActivate"] === "number" && Number.isFinite(raw["msToActivate"]) ? raw["msToActivate"] : 0,
|
|
1097
|
+
path,
|
|
1098
|
+
capabilities
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
function verifyInteraction(report2, elapsedMs, settings = DEFAULT_INTERACTION_SETTINGS, expected) {
|
|
1102
|
+
if (report2 === void 0) return { ok: false, reason: "no interaction was reported" };
|
|
1103
|
+
if (!report2.trusted) return { ok: false, reason: "the activation was not a trusted event" };
|
|
1104
|
+
if (elapsedMs < settings.minElapsedMs) {
|
|
1105
|
+
return { ok: false, reason: `answered in ${Math.round(elapsedMs)}ms, sooner than a person reads and acts` };
|
|
1106
|
+
}
|
|
1107
|
+
if (report2.msToActivate > elapsedMs + 6e4) {
|
|
1108
|
+
return { ok: false, reason: "the client claims to have taken longer than the challenge has existed" };
|
|
1109
|
+
}
|
|
1110
|
+
if (expected !== void 0) {
|
|
1111
|
+
const wanted = expected.boxes * expected.height;
|
|
1112
|
+
if (report2.layoutHeight === void 0) {
|
|
1113
|
+
return { ok: false, reason: "the layout probe went unanswered" };
|
|
1114
|
+
}
|
|
1115
|
+
if (Math.abs(report2.layoutHeight - wanted) > 1) {
|
|
1116
|
+
return { ok: false, reason: `the layout probe answered ${report2.layoutHeight}px where this challenge asked for ${wanted}px` };
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
const claimedMovementMs = report2.path.reduce((total, sample) => total + sample.dt, 0);
|
|
1120
|
+
if (claimedMovementMs > elapsedMs + 2e3) {
|
|
1121
|
+
return { ok: false, reason: `the path describes ${Math.round(claimedMovementMs)}ms of movement inside a ${Math.round(elapsedMs)}ms challenge` };
|
|
1122
|
+
}
|
|
1123
|
+
const notes = [];
|
|
1124
|
+
const capabilityScore = scoreCapabilities(report2.capabilities);
|
|
1125
|
+
const failed = Object.keys(CAPABILITY_WEIGHTS).filter((name) => report2.capabilities[name] !== true);
|
|
1126
|
+
notes.push(failed.length === 0 ? "capabilities 100%" : `capabilities ${(capabilityScore * 100).toFixed(0)}% (missing: ${failed.join(", ")})`);
|
|
1127
|
+
let score = capabilityScore * 0.6;
|
|
1128
|
+
const measurable = report2.via === "pointer" && report2.path.length >= 4;
|
|
1129
|
+
if (measurable) {
|
|
1130
|
+
const movement = scoreMovement(analyseMovement(report2.path));
|
|
1131
|
+
notes.push(`movement ${(movement * 100).toFixed(0)}%`);
|
|
1132
|
+
score += movement * 0.4;
|
|
1133
|
+
} else {
|
|
1134
|
+
notes.push(report2.path.length > 0 ? `too little movement to judge (${report2.via})` : `no pointer path (${report2.via})`);
|
|
1135
|
+
score += capabilityScore * 0.4;
|
|
1136
|
+
}
|
|
1137
|
+
if (score < settings.refuseBelow) {
|
|
1138
|
+
return { ok: false, reason: `the browser did not behave like one (${notes.join(", ")})`, score, notes };
|
|
1139
|
+
}
|
|
1140
|
+
return { ok: true, level: score >= settings.interactionAt ? "interaction" : "pow", score, notes };
|
|
1141
|
+
}
|
|
1142
|
+
var DEFAULT_INTERACTION_SETTINGS, MAX_SAMPLES, CONTINUOUS_GAP_MS, CAPABILITY_WEIGHTS;
|
|
1143
|
+
var init_interaction = __esm({
|
|
1144
|
+
"src/challenge/interaction.ts"() {
|
|
1145
|
+
"use strict";
|
|
1146
|
+
init_crypto();
|
|
1147
|
+
DEFAULT_INTERACTION_SETTINGS = {
|
|
1148
|
+
minElapsedMs: 1e3,
|
|
1149
|
+
// Set from measurement rather than taste: a real browser driven with a
|
|
1150
|
+
// constant-velocity path scores 0.60, and a person scores above 0.95. The bar sits
|
|
1151
|
+
// between them, so synthesising movement badly earns `pow` and not `interaction`.
|
|
1152
|
+
interactionAt: 0.75,
|
|
1153
|
+
refuseBelow: 0.2
|
|
1154
|
+
};
|
|
1155
|
+
MAX_SAMPLES = 128;
|
|
1156
|
+
CONTINUOUS_GAP_MS = 250;
|
|
1157
|
+
CAPABILITY_WEIGHTS = {
|
|
1158
|
+
/** A computed style that requires the cascade to have run. */
|
|
1159
|
+
cssApplied: 0.3,
|
|
1160
|
+
/** Layout produced a non-zero box for a laid-out element. */
|
|
1161
|
+
layout: 0.2,
|
|
1162
|
+
/** Text measurement differs between two fonts — there is a font engine. */
|
|
1163
|
+
fontMetrics: 0.15,
|
|
1164
|
+
/** `requestAnimationFrame` fired at a plausible interval — there is a frame loop. */
|
|
1165
|
+
animationFrame: 0.15,
|
|
1166
|
+
/** A media query evaluated, so the viewport is real. */
|
|
1167
|
+
mediaQuery: 0.1,
|
|
1168
|
+
/** An element hidden by CSS reports a zero box, so `display` was honoured. */
|
|
1169
|
+
hiddenIsHidden: 0.1
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
});
|
|
1173
|
+
|
|
748
1174
|
// src/internal/http.ts
|
|
749
1175
|
function parseCookies(header) {
|
|
750
1176
|
const cookies = /* @__PURE__ */ Object.create(null);
|
|
@@ -808,6 +1234,7 @@ var init_challenge = __esm({
|
|
|
808
1234
|
init_language();
|
|
809
1235
|
init_pow();
|
|
810
1236
|
init_token();
|
|
1237
|
+
init_interaction();
|
|
811
1238
|
init_http();
|
|
812
1239
|
init_crypto();
|
|
813
1240
|
init_clock();
|
|
@@ -820,12 +1247,14 @@ var init_challenge = __esm({
|
|
|
820
1247
|
}
|
|
821
1248
|
this.secrets = options.secrets;
|
|
822
1249
|
this.difficulty = clampDifficulty(options.difficulty ?? DEFAULT_DIFFICULTY);
|
|
823
|
-
|
|
1250
|
+
const wantsGesture = options.interaction !== void 0 && options.interaction !== false;
|
|
1251
|
+
this.challengeTtlMs = options.challengeTtlMs ?? (wantsGesture ? 6e5 : 12e4);
|
|
824
1252
|
this.clearanceTtlMs = options.clearanceTtlMs ?? 36e5;
|
|
825
1253
|
this.verifyPath = options.verifyPath ?? "/__bothandler/verify";
|
|
826
1254
|
this.cookieName = options.cookieName ?? "__bh_clearance";
|
|
827
1255
|
this.clock = options.clock ?? systemClock;
|
|
828
1256
|
this.store = options.store;
|
|
1257
|
+
this.interaction = !wantsGesture ? void 0 : { ...DEFAULT_INTERACTION_SETTINGS, ...options.interaction === true ? {} : options.interaction };
|
|
829
1258
|
}
|
|
830
1259
|
options;
|
|
831
1260
|
secrets;
|
|
@@ -833,6 +1262,8 @@ var init_challenge = __esm({
|
|
|
833
1262
|
challengeTtlMs;
|
|
834
1263
|
clock;
|
|
835
1264
|
store;
|
|
1265
|
+
/** Resolved interaction settings, or `undefined` when the gesture is not asked for. */
|
|
1266
|
+
interaction;
|
|
836
1267
|
verifyPath;
|
|
837
1268
|
cookieName;
|
|
838
1269
|
/**
|
|
@@ -841,6 +1272,10 @@ var init_challenge = __esm({
|
|
|
841
1272
|
* the cookie hands a solved actor a window in which it is never re-challenged.
|
|
842
1273
|
*/
|
|
843
1274
|
clearanceTtlMs;
|
|
1275
|
+
/** Whether this service asks for a gesture as well as the puzzle. */
|
|
1276
|
+
get wantsInteraction() {
|
|
1277
|
+
return this.interaction !== void 0;
|
|
1278
|
+
}
|
|
844
1279
|
/**
|
|
845
1280
|
* Derives the subject a token is bound to.
|
|
846
1281
|
*
|
|
@@ -892,7 +1327,8 @@ var init_challenge = __esm({
|
|
|
892
1327
|
...title !== void 0 ? { title } : {},
|
|
893
1328
|
...message !== void 0 ? { message } : {},
|
|
894
1329
|
...contactHtml !== void 0 ? { contactHtml } : {},
|
|
895
|
-
...lang !== void 0 ? { lang } : {}
|
|
1330
|
+
...lang !== void 0 ? { lang } : {},
|
|
1331
|
+
...this.interaction !== void 0 ? { interaction: true, probe: probeShapeFor(claims.nonce, this.secrets[0]) } : {}
|
|
896
1332
|
});
|
|
897
1333
|
return {
|
|
898
1334
|
// 429 rather than 403: this is "slow down and prove something", and it is
|
|
@@ -935,6 +1371,20 @@ var init_challenge = __esm({
|
|
|
935
1371
|
if (!verifyProofOfWork(verified.payload.nonce, solution, verified.payload.diff)) {
|
|
936
1372
|
return { ok: false, status: 400, reason: "solution does not satisfy the challenge" };
|
|
937
1373
|
}
|
|
1374
|
+
let level = "pow";
|
|
1375
|
+
let interactionScore;
|
|
1376
|
+
let notes;
|
|
1377
|
+
if (this.interaction !== void 0) {
|
|
1378
|
+
const report2 = parseInteractionReport(payload.interaction);
|
|
1379
|
+
const elapsedMs = this.clock.now() - verified.payload.iat;
|
|
1380
|
+
const outcome = verifyInteraction(report2, elapsedMs, this.interaction, probeShapeFor(verified.payload.nonce, this.secrets[0]));
|
|
1381
|
+
if (!outcome.ok) {
|
|
1382
|
+
return { ok: false, status: 400, reason: outcome.reason, ...outcome.score === void 0 ? {} : { interactionScore: outcome.score } };
|
|
1383
|
+
}
|
|
1384
|
+
level = outcome.level;
|
|
1385
|
+
interactionScore = outcome.score;
|
|
1386
|
+
notes = outcome.notes;
|
|
1387
|
+
}
|
|
938
1388
|
if (this.store) {
|
|
939
1389
|
let claimed;
|
|
940
1390
|
try {
|
|
@@ -944,7 +1394,13 @@ var init_challenge = __esm({
|
|
|
944
1394
|
}
|
|
945
1395
|
if (!claimed) return { ok: false, status: 409, reason: "challenge already solved" };
|
|
946
1396
|
}
|
|
947
|
-
return {
|
|
1397
|
+
return {
|
|
1398
|
+
ok: true,
|
|
1399
|
+
level,
|
|
1400
|
+
setCookie: this.grant(actorKey, level),
|
|
1401
|
+
...interactionScore === void 0 ? {} : { interactionScore },
|
|
1402
|
+
...notes === void 0 ? {} : { notes }
|
|
1403
|
+
};
|
|
948
1404
|
}
|
|
949
1405
|
/**
|
|
950
1406
|
* Mints a clearance cookie directly, bypassing the puzzle.
|
|
@@ -1149,6 +1605,13 @@ function toPrometheus(snapshot, options = {}) {
|
|
|
1149
1605
|
counter("detector_duration_ms_count", "Detector invocations timed.", timings.map(([detector, timing]) => [`{detector="${escapeLabel(detector)}"}`, timing.count]));
|
|
1150
1606
|
}
|
|
1151
1607
|
counter("challenges_total", "Challenge lifecycle events.", Object.entries(snapshot.challenges).map(([event, value]) => [`{event="${event}"}`, value]));
|
|
1608
|
+
counter("clearances_total", "Clearance granted, by level.", Object.entries(snapshot.clearances).map(([level, value]) => [`{level="${level}"}`, value]));
|
|
1609
|
+
counter("challenge_rejections_total", "Challenges turned down, by cause.", Object.entries(snapshot.challengeRejections).map(([cause, value]) => [`{cause="${cause}"}`, value]));
|
|
1610
|
+
counter(
|
|
1611
|
+
"interaction_score_bucket",
|
|
1612
|
+
"Interaction scores, in tenths. The distribution the interactionAt threshold sits in.",
|
|
1613
|
+
snapshot.interactionScores.map((value, index) => [`{le="${((index + 1) / 10).toFixed(1)}"}`, value])
|
|
1614
|
+
);
|
|
1152
1615
|
lines.push(
|
|
1153
1616
|
`# HELP ${prefix}_score Distribution of probabilistic scores. Proven assessments carry no score and are counted by ${prefix}_proven_total.`,
|
|
1154
1617
|
`# TYPE ${prefix}_score histogram`
|
|
@@ -1205,6 +1668,9 @@ var init_metrics = __esm({
|
|
|
1205
1668
|
challengesIssued = 0;
|
|
1206
1669
|
challengesSolved = 0;
|
|
1207
1670
|
challengesRejected = 0;
|
|
1671
|
+
clearances = /* @__PURE__ */ new Map();
|
|
1672
|
+
challengeRejections = /* @__PURE__ */ new Map();
|
|
1673
|
+
interactionScores = new Array(10).fill(0);
|
|
1208
1674
|
scoreCount = 0;
|
|
1209
1675
|
scoreTotal = 0;
|
|
1210
1676
|
scoreBuckets = new Float64Array(SCORE_BUCKETS.length);
|
|
@@ -1266,6 +1732,26 @@ var init_metrics = __esm({
|
|
|
1266
1732
|
else if (event === "solved") this.challengesSolved++;
|
|
1267
1733
|
else this.challengesRejected++;
|
|
1268
1734
|
}
|
|
1735
|
+
/** Which clearance a solve earned. */
|
|
1736
|
+
recordClearance(level) {
|
|
1737
|
+
this.clearances.set(level, (this.clearances.get(level) ?? 0) + 1);
|
|
1738
|
+
}
|
|
1739
|
+
/**
|
|
1740
|
+
* Why a challenge was turned down, bucketed by cause.
|
|
1741
|
+
*
|
|
1742
|
+
* Bucketed rather than recorded verbatim: the reason string carries measured numbers
|
|
1743
|
+
* ("answered in 120ms"), and a counter keyed on those would grow without bound on a
|
|
1744
|
+
* label an attacker controls the shape of.
|
|
1745
|
+
*/
|
|
1746
|
+
recordChallengeRejection(reason) {
|
|
1747
|
+
const bucket = /not a trusted event/.test(reason) ? "untrusted-event" : /sooner than a person/.test(reason) ? "too-fast" : /longer than the challenge/.test(reason) ? "impossible-timing" : /did not behave like one/.test(reason) ? "not-a-browser" : /no interaction/.test(reason) ? "no-interaction" : /already solved/.test(reason) ? "replay" : "other";
|
|
1748
|
+
this.challengeRejections.set(bucket, (this.challengeRejections.get(bucket) ?? 0) + 1);
|
|
1749
|
+
}
|
|
1750
|
+
/** The interaction score a solve was graded on, into ten buckets of 0.1. */
|
|
1751
|
+
recordInteractionScore(score) {
|
|
1752
|
+
const index = Math.min(9, Math.max(0, Math.floor(score * 10)));
|
|
1753
|
+
this.interactionScores[index] = (this.interactionScores[index] ?? 0) + 1;
|
|
1754
|
+
}
|
|
1269
1755
|
snapshot(actorsTracked) {
|
|
1270
1756
|
return {
|
|
1271
1757
|
requests: this.requests,
|
|
@@ -1279,6 +1765,9 @@ var init_metrics = __esm({
|
|
|
1279
1765
|
detectorFailures: Object.fromEntries(this.detectorFailures),
|
|
1280
1766
|
detectorTimings: Object.fromEntries([...this.detectorTimings].map(([id, timing]) => [id, { ...timing }])),
|
|
1281
1767
|
challenges: { issued: this.challengesIssued, solved: this.challengesSolved, rejected: this.challengesRejected },
|
|
1768
|
+
clearances: Object.fromEntries(this.clearances),
|
|
1769
|
+
challengeRejections: Object.fromEntries(this.challengeRejections),
|
|
1770
|
+
interactionScores: [...this.interactionScores],
|
|
1282
1771
|
scores: { count: this.scoreCount, totalScore: this.scoreTotal, buckets: cumulate(this.scoreBuckets) },
|
|
1283
1772
|
duration: { count: this.durationCount, totalMs: this.durationTotal, maxMs: this.durationMax, buckets: cumulate(this.durationBuckets) },
|
|
1284
1773
|
actorsTracked
|
|
@@ -2885,12 +3374,29 @@ function compileSignatures(signatures = BOT_SIGNATURES) {
|
|
|
2885
3374
|
}
|
|
2886
3375
|
return new MultiPatternMatcher(entries);
|
|
2887
3376
|
}
|
|
2888
|
-
var BOT_CATEGORIES, SEARCH, AI, SEO, SOCIAL, MONITORING, ARCHIVE, FEED, SECURITY, LIBRARY, HEADLESS, EMBEDDED, ADVERTISING, BOT_SIGNATURES;
|
|
3377
|
+
var BOT_CATEGORIES, SEARCH, AI, SEO, SOCIAL, MONITORING, ARCHIVE, FEED, SECURITY, LIBRARY, HEADLESS, EMBEDDED, ADVERTISING, COMMERCE, ACADEMIC, ACCESSIBILITY, BOT_SIGNATURES;
|
|
2889
3378
|
var init_known_bots = __esm({
|
|
2890
3379
|
"src/detectors/known-bots.ts"() {
|
|
2891
3380
|
"use strict";
|
|
2892
3381
|
init_matcher();
|
|
2893
|
-
BOT_CATEGORIES = [
|
|
3382
|
+
BOT_CATEGORIES = [
|
|
3383
|
+
"search",
|
|
3384
|
+
"ai",
|
|
3385
|
+
"seo",
|
|
3386
|
+
"social",
|
|
3387
|
+
"monitoring",
|
|
3388
|
+
"archive",
|
|
3389
|
+
"feed",
|
|
3390
|
+
"security",
|
|
3391
|
+
"advertising",
|
|
3392
|
+
"library",
|
|
3393
|
+
"headless",
|
|
3394
|
+
"embedded",
|
|
3395
|
+
"commerce",
|
|
3396
|
+
"accessibility",
|
|
3397
|
+
"academic",
|
|
3398
|
+
"other"
|
|
3399
|
+
];
|
|
2894
3400
|
SEARCH = [
|
|
2895
3401
|
{ id: "googlebot", name: "Googlebot", tokens: ["googlebot", "google-inspectiontool", "storebot-google", "googleother", "google favicon", "google-read-aloud", "google-shopping-quality", "google-site-verification", "googleproducersearch", "google-safety"], category: "search", benign: true, robotsAgent: "Googlebot", verification: { kind: "fcrdns", domains: ["googlebot.com", "google.com", "googleusercontent.com"] }, docs: "https://developers.google.com/search/docs/crawling-indexing/verifying-googlebot" },
|
|
2896
3402
|
{ id: "bingbot", name: "Bingbot", tokens: ["bingbot", "adidxbot", "msnbot", "bingpreview"], category: "search", benign: true, robotsAgent: "bingbot", verification: { kind: "fcrdns", domains: ["search.msn.com"] }, docs: "https://www.bing.com/webmasters/help/how-to-verify-bingbot-3905dc26" },
|
|
@@ -3016,10 +3522,17 @@ var init_known_bots = __esm({
|
|
|
3016
3522
|
{ id: "exporters", name: "Metrics and availability exporters", tokens: ["blackbox_exporter", "prometheus/", "zabbix", "check_http", "nagios", "icinga"], category: "monitoring", benign: true, verification: { kind: "none" } }
|
|
3017
3523
|
];
|
|
3018
3524
|
ARCHIVE = [
|
|
3525
|
+
{ id: "archive-today", name: "archive.today", tokens: ["archive.today"], category: "archive", benign: true, verification: { kind: "none" } },
|
|
3526
|
+
{ id: "perma-cc", name: "Perma.cc", tokens: ["perma.cc"], category: "archive", benign: true, verification: { kind: "none" } },
|
|
3527
|
+
{ id: "webrecorder", name: "Webrecorder", tokens: ["webrecorder"], category: "archive", benign: true, verification: { kind: "none" } },
|
|
3019
3528
|
{ id: "ia-archiver", name: "Internet Archive", tokens: ["ia_archiver", "archive.org_bot", "wayback"], category: "archive", benign: true, verification: { kind: "none" } },
|
|
3020
3529
|
{ id: "heritrix", name: "Heritrix", tokens: ["heritrix"], category: "archive", benign: true, verification: { kind: "none" } }
|
|
3021
3530
|
];
|
|
3022
3531
|
FEED = [
|
|
3532
|
+
{ id: "newsblur", name: "NewsBlur", tokens: ["newsblur"], category: "feed", benign: true, verification: { kind: "none" } },
|
|
3533
|
+
{ id: "smartnews", name: "SmartNews", tokens: ["smartnewsbot"], category: "feed", benign: true, verification: { kind: "none" } },
|
|
3534
|
+
{ id: "flipboard", name: "Flipboard", tokens: ["flipboardproxy"], category: "feed", benign: true, verification: { kind: "none" } },
|
|
3535
|
+
{ id: "podcast-index", name: "Podcast Index", tokens: ["podcastindexbot"], category: "feed", benign: true, verification: { kind: "none" } },
|
|
3023
3536
|
{ id: "freshrss", name: "FreshRSS", tokens: ["freshrss"], category: "feed", benign: true, verification: { kind: "none" } },
|
|
3024
3537
|
{ id: "netnewswire", name: "NetNewsWire", tokens: ["netnewswire"], category: "feed", benign: true, verification: { kind: "none" } },
|
|
3025
3538
|
{ id: "overcast", name: "Overcast", tokens: ["overcast/"], category: "feed", benign: true, verification: { kind: "none" } },
|
|
@@ -3148,6 +3661,23 @@ var init_known_bots = __esm({
|
|
|
3148
3661
|
{ id: "adsbot-google", name: "AdsBot-Google", tokens: ["adsbot-google", "mediapartners-google", "adsbot"], category: "advertising", benign: true, verification: { kind: "fcrdns", domains: ["googlebot.com", "google.com"] } },
|
|
3149
3662
|
{ id: "criteo", name: "Criteo", tokens: ["criteobot"], category: "advertising", benign: true, verification: { kind: "none" } }
|
|
3150
3663
|
];
|
|
3664
|
+
COMMERCE = [
|
|
3665
|
+
{ id: "idealo", name: "idealo", tokens: ["idealo-bot"], category: "commerce", benign: false, verification: { kind: "none" } },
|
|
3666
|
+
{ id: "kelkoo", name: "Kelkoo", tokens: ["kelkoobot"], category: "commerce", benign: false, verification: { kind: "none" } },
|
|
3667
|
+
{ id: "pricerunner", name: "PriceRunner", tokens: ["pricerunnerbot"], category: "commerce", benign: false, verification: { kind: "none" } },
|
|
3668
|
+
{ id: "trivago", name: "Trivago", tokens: ["trivagobot"], category: "commerce", benign: false, verification: { kind: "none" } },
|
|
3669
|
+
{ id: "skyscanner", name: "Skyscanner", tokens: ["skyscannerbot"], category: "commerce", benign: false, verification: { kind: "none" } },
|
|
3670
|
+
{ id: "indeedbot", name: "Indeedbot", tokens: ["indeedbot"], category: "commerce", benign: true, robotsAgent: "Indeedbot", verification: { kind: "none" }, docs: "http://www.indeed.com/indeedbot.html" },
|
|
3671
|
+
{ id: "adzuna", name: "Adzuna", tokens: ["adzunabot"], category: "commerce", benign: true, verification: { kind: "none" } }
|
|
3672
|
+
];
|
|
3673
|
+
ACADEMIC = [
|
|
3674
|
+
{ id: "crossref", name: "Crossref", tokens: ["crossrefbot"], category: "academic", benign: true, verification: { kind: "none" } },
|
|
3675
|
+
{ id: "openalex", name: "OpenAlex", tokens: ["openalexbot"], category: "academic", benign: true, verification: { kind: "none" } },
|
|
3676
|
+
{ id: "webis", name: "Webis research crawler", tokens: ["webisbot"], category: "academic", benign: true, verification: { kind: "none" } }
|
|
3677
|
+
];
|
|
3678
|
+
ACCESSIBILITY = [
|
|
3679
|
+
{ id: "siteimprove", name: "Siteimprove", tokens: ["siteimprovebot"], category: "accessibility", benign: true, robotsAgent: "SiteimproveBot", verification: { kind: "none" } }
|
|
3680
|
+
];
|
|
3151
3681
|
BOT_SIGNATURES = Object.freeze([
|
|
3152
3682
|
...SEARCH,
|
|
3153
3683
|
...AI,
|
|
@@ -3160,7 +3690,10 @@ var init_known_bots = __esm({
|
|
|
3160
3690
|
...ADVERTISING,
|
|
3161
3691
|
...LIBRARY,
|
|
3162
3692
|
...HEADLESS,
|
|
3163
|
-
...EMBEDDED
|
|
3693
|
+
...EMBEDDED,
|
|
3694
|
+
...COMMERCE,
|
|
3695
|
+
...ACADEMIC,
|
|
3696
|
+
...ACCESSIBILITY
|
|
3164
3697
|
]);
|
|
3165
3698
|
}
|
|
3166
3699
|
});
|
|
@@ -3646,23 +4179,23 @@ function cadenceDetector(options = {}) {
|
|
|
3646
4179
|
cost: "cheap",
|
|
3647
4180
|
stage: "always",
|
|
3648
4181
|
inspect(ctx) {
|
|
3649
|
-
const { count, mean, coefficientOfVariation } = ctx.state.intervalStats();
|
|
4182
|
+
const { count, mean, coefficientOfVariation: coefficientOfVariation2 } = ctx.state.intervalStats();
|
|
3650
4183
|
if (count < minSamples) return void 0;
|
|
3651
4184
|
if (mean <= 1 || mean > maxMeanIntervalMs) return void 0;
|
|
3652
|
-
if (
|
|
4185
|
+
if (coefficientOfVariation2 > regularityThreshold) return void 0;
|
|
3653
4186
|
return {
|
|
3654
4187
|
detector: "cadence",
|
|
3655
|
-
summary: `Arrivals are machine-regular: ${count} gaps averaging ${Math.round(mean)}ms with a coefficient of variation of ${
|
|
4188
|
+
summary: `Arrivals are machine-regular: ${count} gaps averaging ${Math.round(mean)}ms with a coefficient of variation of ${coefficientOfVariation2.toFixed(3)}`,
|
|
3656
4189
|
direction: "bot",
|
|
3657
4190
|
certainty: "moderate",
|
|
3658
4191
|
// Tighter rhythm, more weight — but capped, because a polling frontend is a
|
|
3659
4192
|
// perfectly ordinary explanation.
|
|
3660
|
-
weight:
|
|
4193
|
+
weight: coefficientOfVariation2 < regularityThreshold / 2 ? 0.45 : 0.3,
|
|
3661
4194
|
botClass: "scraper",
|
|
3662
4195
|
metadata: {
|
|
3663
4196
|
samples: count,
|
|
3664
4197
|
meanIntervalMs: Math.round(mean),
|
|
3665
|
-
coefficientOfVariation: Number(
|
|
4198
|
+
coefficientOfVariation: Number(coefficientOfVariation2.toFixed(4))
|
|
3666
4199
|
}
|
|
3667
4200
|
};
|
|
3668
4201
|
}
|
|
@@ -3916,6 +4449,7 @@ var init_crawl_breadth = __esm({
|
|
|
3916
4449
|
function crawlerVerificationDetector(options = {}) {
|
|
3917
4450
|
const missingPtrIsForgery = options.treatMissingPtrAsForgery ?? true;
|
|
3918
4451
|
const useRanges = options.useConfiguredRanges ?? true;
|
|
4452
|
+
const verifiers = options.verifiers ?? {};
|
|
3919
4453
|
return {
|
|
3920
4454
|
id: "crawler-verification",
|
|
3921
4455
|
description: "Confirms or refutes a claimed crawler identity via forward-confirmed reverse DNS or published IP ranges",
|
|
@@ -3924,10 +4458,15 @@ function crawlerVerificationDetector(options = {}) {
|
|
|
3924
4458
|
// crawler gives us nothing to verify, and resolving DNS for it would be pure cost.
|
|
3925
4459
|
stage: "confirming",
|
|
3926
4460
|
async inspect(ctx) {
|
|
3927
|
-
const claims = ctx.signatureMatches.filter((signature) => signature.verification.kind !== "none");
|
|
4461
|
+
const claims = ctx.signatureMatches.filter((signature) => signature.verification.kind !== "none" || verifiers[signature.id] !== void 0);
|
|
3928
4462
|
if (claims.length === 0) return void 0;
|
|
3929
4463
|
const results = [];
|
|
3930
4464
|
for (const claim of claims) {
|
|
4465
|
+
const own = await runVerifier(verifiers[claim.id], ctx, claim);
|
|
4466
|
+
if (own !== void 0) {
|
|
4467
|
+
results.push(own);
|
|
4468
|
+
continue;
|
|
4469
|
+
}
|
|
3931
4470
|
const outcome = await verifyClaim(ctx, claim, { missingPtrIsForgery, useRanges });
|
|
3932
4471
|
if (outcome) results.push(outcome);
|
|
3933
4472
|
}
|
|
@@ -3935,6 +4474,39 @@ function crawlerVerificationDetector(options = {}) {
|
|
|
3935
4474
|
}
|
|
3936
4475
|
};
|
|
3937
4476
|
}
|
|
4477
|
+
async function runVerifier(verifier, ctx, signature) {
|
|
4478
|
+
if (verifier === void 0) return void 0;
|
|
4479
|
+
let outcome;
|
|
4480
|
+
try {
|
|
4481
|
+
outcome = await verifier(ctx, signature);
|
|
4482
|
+
} catch {
|
|
4483
|
+
return void 0;
|
|
4484
|
+
}
|
|
4485
|
+
if (outcome === "unknown") return void 0;
|
|
4486
|
+
const via = signature.verification.kind === "proof" ? signature.verification.via : "a check you supplied";
|
|
4487
|
+
if (outcome === "verified") {
|
|
4488
|
+
return {
|
|
4489
|
+
detector: "crawler-verification",
|
|
4490
|
+
summary: `${signature.name} confirmed by your own verifier`,
|
|
4491
|
+
direction: "bot",
|
|
4492
|
+
certainty: "certain",
|
|
4493
|
+
botClass: "verified-bot",
|
|
4494
|
+
identity: signature.id,
|
|
4495
|
+
deterministicBasis: `Your application confirmed this identity through ${via} \u2014 a proof it holds and this library cannot see. It is your assertion about your own infrastructure, and it is treated the way the operator's word is treated everywhere else here.`,
|
|
4496
|
+
metadata: { signatureId: signature.id, method: "operator-verifier" }
|
|
4497
|
+
};
|
|
4498
|
+
}
|
|
4499
|
+
return {
|
|
4500
|
+
detector: "crawler-verification",
|
|
4501
|
+
summary: `Client claims to be ${signature.name}, and your own verifier refutes it`,
|
|
4502
|
+
direction: "bot",
|
|
4503
|
+
certainty: "certain",
|
|
4504
|
+
botClass: "impersonator",
|
|
4505
|
+
identity: signature.id,
|
|
4506
|
+
deterministicBasis: `The client named itself ${signature.name}, and the check you supplied for that identity \u2014 ${via} \u2014 returned a definite no. The refutation is yours; this library only reports it.`,
|
|
4507
|
+
metadata: { signatureId: signature.id, method: "operator-verifier" }
|
|
4508
|
+
};
|
|
4509
|
+
}
|
|
3938
4510
|
async function verifyClaim(ctx, signature, flags) {
|
|
3939
4511
|
if (signature.verification.kind === "ip-ranges") {
|
|
3940
4512
|
if (!flags.useRanges) return void 0;
|
|
@@ -5036,7 +5608,7 @@ var init_ua_coherence = __esm({
|
|
|
5036
5608
|
});
|
|
5037
5609
|
|
|
5038
5610
|
// src/detectors/index.ts
|
|
5039
|
-
function defaultDetectors() {
|
|
5611
|
+
function defaultDetectors(options = {}) {
|
|
5040
5612
|
return [
|
|
5041
5613
|
// Identity first: a self-declaration or a verified crawler settles the question
|
|
5042
5614
|
// outright, and the engine can then skip everything that would only add nuance.
|
|
@@ -5059,7 +5631,7 @@ function defaultDetectors() {
|
|
|
5059
5631
|
// The other side of the argument: what a real browsing session looks like.
|
|
5060
5632
|
browsingCoherenceDetector(),
|
|
5061
5633
|
// Confirming stage: only runs when an identity was claimed.
|
|
5062
|
-
crawlerVerificationDetector()
|
|
5634
|
+
crawlerVerificationDetector(options.crawlerVerification ?? {})
|
|
5063
5635
|
];
|
|
5064
5636
|
}
|
|
5065
5637
|
var init_detectors = __esm({
|
|
@@ -5198,6 +5770,65 @@ function protectApi() {
|
|
|
5198
5770
|
{ id: "tag-everything", match: {}, action: "tag", reason: "Every request carries its verdict to your handlers, where the API key and the plan are. That is where an API decides things." }
|
|
5199
5771
|
];
|
|
5200
5772
|
}
|
|
5773
|
+
function indexersOnly() {
|
|
5774
|
+
return [
|
|
5775
|
+
{ id: "cleared-human-allow", match: { verdict: "human", certain: true }, action: "allow", reason: "Your application asserted this is a person. Nothing below should second-guess that." },
|
|
5776
|
+
{
|
|
5777
|
+
id: "verified-indexer-allow",
|
|
5778
|
+
// `verified-bot` is only ever reached through forward-confirmed reverse DNS or a
|
|
5779
|
+
// published range, so no claim can reach this rule — which is the whole basis on
|
|
5780
|
+
// which this policy is willing to serve a bot at all.
|
|
5781
|
+
match: { verdict: "verified-bot", category: ["search", "social"] },
|
|
5782
|
+
action: "allow",
|
|
5783
|
+
reason: "Identity confirmed against the operator's DNS or published ranges, doing the one job this site serves bots for: making it findable."
|
|
5784
|
+
},
|
|
5785
|
+
{ id: "impersonator-block", match: { botClass: "impersonator", certain: true }, action: "block", params: { status: 403 }, reason: "Forged a verifiable third-party crawler identity. Proven by DNS, not inferred." },
|
|
5786
|
+
{ id: "scanner-block", match: { botClass: "scanner", certain: true }, action: "block", params: { status: 403 }, reason: "Self-identified security scanner." },
|
|
5787
|
+
{ id: "trap-block", match: { detector: "trap", certain: true }, action: "block", params: { status: 403 }, reason: "Followed a link no person can reach." },
|
|
5788
|
+
{
|
|
5789
|
+
id: "non-indexing-crawler-block",
|
|
5790
|
+
match: { verdict: "verified-bot" },
|
|
5791
|
+
action: "block",
|
|
5792
|
+
params: { status: 403, body: DECLINED },
|
|
5793
|
+
reason: "A confirmed crawler doing something other than indexing. Declined by policy rather than by suspicion \u2014 and said plainly, so its operator can act on it."
|
|
5794
|
+
},
|
|
5795
|
+
{
|
|
5796
|
+
id: "unverifiable-indexer-block",
|
|
5797
|
+
// The rule to reach for first when this preset costs you something you wanted.
|
|
5798
|
+
// `rate-limit` here serves the link unfurlers at a ceiling; the reason a forged
|
|
5799
|
+
// Slackbot is cheap to send is exactly the reason a ceiling is the right answer.
|
|
5800
|
+
match: { verdict: "confirmed-bot", category: ["search", "social"] },
|
|
5801
|
+
action: "block",
|
|
5802
|
+
params: { status: 403, body: DECLINED },
|
|
5803
|
+
reason: "Says it indexes, and publishes nothing anyone could check that against. This policy serves crawlers it can confirm, and this claim cannot be confirmed."
|
|
5804
|
+
},
|
|
5805
|
+
{
|
|
5806
|
+
id: "proven-automation-block",
|
|
5807
|
+
match: { verdict: "confirmed-bot", certain: true },
|
|
5808
|
+
action: "block",
|
|
5809
|
+
params: { status: 403, body: DECLINED },
|
|
5810
|
+
reason: "Proven automation that is not a confirmed indexer. A declaration, a contradiction or a trap \u2014 never a score."
|
|
5811
|
+
},
|
|
5812
|
+
{
|
|
5813
|
+
id: "persistent-refuser-ratelimit",
|
|
5814
|
+
// Ahead of the challenge rule on purpose: a fourth challenge to something that
|
|
5815
|
+
// answered none of the first three achieves nothing but latency.
|
|
5816
|
+
match: { minUnsolvedChallenges: 3 },
|
|
5817
|
+
action: "rate-limit",
|
|
5818
|
+
params: { limit: { max: 10, windowMs: 6e4 } },
|
|
5819
|
+
reason: "Three challenges issued and none answered. Held to a rate rather than asked again; solving one clears the count."
|
|
5820
|
+
},
|
|
5821
|
+
{ id: "suspected-challenge", match: { verdict: "suspected-bot" }, action: "challenge", reason: "Suspicion, at this site's threshold. A challenge is as far as unproven evidence may go, and the client can pass it on its own." },
|
|
5822
|
+
{
|
|
5823
|
+
id: "weak-suspicion-ratelimit",
|
|
5824
|
+
match: { verdict: "unknown", minScore: 40 },
|
|
5825
|
+
action: "rate-limit",
|
|
5826
|
+
params: { limit: { max: 60, windowMs: 6e4 } },
|
|
5827
|
+
reason: "Some signal, below the bar for calling it a bot at all. A ceiling rather than a challenge: it costs a person nothing and costs bulk automation everything."
|
|
5828
|
+
},
|
|
5829
|
+
{ id: "everything-else-tag", match: {}, action: "tag", reason: "Nothing withheld, and the verdict travels to your handlers so they can decide for themselves." }
|
|
5830
|
+
];
|
|
5831
|
+
}
|
|
5201
5832
|
function underAttack() {
|
|
5202
5833
|
return [
|
|
5203
5834
|
{ id: "cleared-human-allow", match: { verdict: "human", certain: true }, action: "allow", reason: "Your application vouched for this person. Even now, that comes first." },
|
|
@@ -5221,10 +5852,11 @@ function underAttack() {
|
|
|
5221
5852
|
}
|
|
5222
5853
|
];
|
|
5223
5854
|
}
|
|
5224
|
-
var PRESETS;
|
|
5855
|
+
var DECLINED, PRESETS;
|
|
5225
5856
|
var init_presets = __esm({
|
|
5226
5857
|
"src/policy/presets.ts"() {
|
|
5227
5858
|
"use strict";
|
|
5859
|
+
DECLINED = "This site serves search and social crawlers whose identity it can confirm. Other automated requests are declined.\n";
|
|
5228
5860
|
PRESETS = {
|
|
5229
5861
|
"monitor-only": monitorOnly,
|
|
5230
5862
|
"allow-crawlers": allowCrawlers,
|
|
@@ -5233,6 +5865,7 @@ var init_presets = __esm({
|
|
|
5233
5865
|
"protect-data": protectData,
|
|
5234
5866
|
"protect-api": protectApi,
|
|
5235
5867
|
"protect-auth": protectAuth,
|
|
5868
|
+
"indexers-only": indexersOnly,
|
|
5236
5869
|
"under-attack": underAttack
|
|
5237
5870
|
};
|
|
5238
5871
|
}
|
|
@@ -5262,7 +5895,7 @@ function validateRules(rules) {
|
|
|
5262
5895
|
}
|
|
5263
5896
|
function resolveConfig(config = {}) {
|
|
5264
5897
|
const warnings = [];
|
|
5265
|
-
const detectors2 = config.detectors ? [...config.detectors] : [...defaultDetectors(), ...config.extraDetectors ?? []];
|
|
5898
|
+
const detectors2 = config.detectors ? [...config.detectors] : [...defaultDetectors(config.crawlerVerification === void 0 ? {} : { crawlerVerification: config.crawlerVerification }), ...config.extraDetectors ?? []];
|
|
5266
5899
|
const seen = /* @__PURE__ */ new Set();
|
|
5267
5900
|
for (const detector of detectors2) {
|
|
5268
5901
|
if (seen.has(detector.id)) {
|
|
@@ -5802,7 +6435,7 @@ function previewPolicy(entries, live, candidate, warnings = []) {
|
|
|
5802
6435
|
if (next.action !== previous.action) {
|
|
5803
6436
|
changed++;
|
|
5804
6437
|
if (isDenial(next.action) && !isDenial(previous.action)) newDenials++;
|
|
5805
|
-
if (samples.length <
|
|
6438
|
+
if (samples.length < MAX_SAMPLES2) {
|
|
5806
6439
|
samples.push({
|
|
5807
6440
|
path: entry.path,
|
|
5808
6441
|
verdict: entry.verdict,
|
|
@@ -5894,12 +6527,12 @@ function candidatePolicy(rules, live, guard = {}) {
|
|
|
5894
6527
|
candidate.replaceGuard(guard);
|
|
5895
6528
|
return candidate;
|
|
5896
6529
|
}
|
|
5897
|
-
var
|
|
6530
|
+
var MAX_SAMPLES2;
|
|
5898
6531
|
var init_preview = __esm({
|
|
5899
6532
|
"src/dashboard/preview.ts"() {
|
|
5900
6533
|
"use strict";
|
|
5901
6534
|
init_policy();
|
|
5902
|
-
|
|
6535
|
+
MAX_SAMPLES2 = 25;
|
|
5903
6536
|
}
|
|
5904
6537
|
});
|
|
5905
6538
|
|
|
@@ -5908,27 +6541,26 @@ var CLIENT_SCRIPT;
|
|
|
5908
6541
|
var init_client_generated = __esm({
|
|
5909
6542
|
"src/dashboard/client.generated.ts"() {
|
|
5910
6543
|
"use strict";
|
|
5911
|
-
CLIENT_SCRIPT = '"use strict";\n(() => {\n // src/dashboard/client/dom.ts\n function el(tag, className, value) {\n const node = document.createElement(tag);\n if (className !== void 0 && className !== null && className !== "") node.className = className;\n if (value !== void 0 && value !== null) node.textContent = String(value);\n return node;\n }\n function svgEl(name, attributes = {}) {\n const node = document.createElementNS("http://www.w3.org/2000/svg", name);\n for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, String(value));\n return node;\n }\n function svgText(attributes, text) {\n const node = svgEl("text", attributes);\n node.textContent = String(text);\n return node;\n }\n function clear(node) {\n while (node.firstChild) node.removeChild(node.firstChild);\n }\n function cssEscape(value) {\n return typeof CSS !== "undefined" && typeof CSS.escape === "function" ? CSS.escape(value) : String(value).replace(/[^\\w-]/g, "\\\\$&");\n }\n function $(id) {\n const node = document.getElementById(id);\n if (node === null) throw new Error(`dashboard: no element #${id}`);\n return node;\n }\n function byId(id) {\n return $(id);\n }\n function css(name) {\n return getComputedStyle(document.documentElement).getPropertyValue(name).trim();\n }\n var sequence = 0;\n function label(text, control) {\n const node = document.createElement("label");\n node.textContent = text;\n const single = Array.isArray(control) ? control.length === 1 ? control[0] : void 0 : control;\n if (single !== void 0 && /^(input|select|textarea)$/i.test(single.tagName)) {\n if (single.id === "") single.id = `field-${++sequence}`;\n node.htmlFor = single.id;\n } else {\n const group = Array.isArray(control) ? control : [control];\n for (const node_ of group) if (!node_.hasAttribute("aria-label")) node_.setAttribute("aria-label", text);\n }\n return node;\n }\n\n // src/dashboard/client/boot.ts\n var raw = globalThis.__BOOTSTRAP__;\n var BOOT = raw ?? {\n base: "",\n title: "bothandlerjs",\n allowReset: false,\n allowEdit: false,\n allowGuardEdit: false,\n allowActing: false,\n peers: [],\n sections: { feed: true, evidence: true, actors: true, registry: true, tester: true, statistics: true, audit: true, notices: true, changes: true, policy: true, guard: true, robots: true, ranges: true },\n links: []\n };\n var API = BOOT.base;\n var SECTIONS = BOOT.sections;\n\n // src/dashboard/client/app.ts\n var app = {\n draw: () => {\n },\n drawNow: () => {\n },\n showTab: () => {\n },\n syncUrl: () => {\n }\n };\n function toast(kind, title, detail = "") {\n const node = el("div", `toast ${kind}`);\n node.appendChild(el("b", null, title));\n if (detail !== "") node.appendChild(el("span", null, detail));\n const host = document.getElementById("toasts");\n if (host === null) return;\n host.appendChild(node);\n setTimeout(() => node.remove(), 6e3);\n }\n function download(text, filename, type) {\n const blob = new Blob([text], { type });\n const url = URL.createObjectURL(blob);\n const anchor = el("a");\n anchor.href = url;\n anchor.download = filename;\n anchor.click();\n setTimeout(() => URL.revokeObjectURL(url), 1e3);\n }\n function today() {\n return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);\n }\n\n // src/dashboard/client/outcome.ts\n var DENY = /* @__PURE__ */ new Set(["block", "drop", "redirect"]);\n var MITIGATE = /* @__PURE__ */ new Set(["challenge", "rate-limit", "delay"]);\n function outcome(entry) {\n return actionKind(entry.action);\n }\n function actionKind(action) {\n if (action === void 0) return "pending";\n if (DENY.has(action)) return "deny";\n if (MITIGATE.has(action)) return "mitigate";\n return "allow";\n }\n function verdictBadge(entry) {\n if (entry.verdict === "verified-bot" || entry.verdict === "confirmed-bot") return ["b-proven", entry.verdict];\n if (entry.verdict === "suspected-bot") return ["b-suspected", "suspected-bot"];\n if (entry.verdict === "human") return ["b-human", "human"];\n return ["b-unknown", entry.bypass !== void 0 ? `skipped \xB7 ${entry.bypass}` : "unknown"];\n }\n\n // src/dashboard/client/query.ts\n var FIELDS = {\n path: "path",\n url: "path",\n actor: "actor",\n ip: "actor",\n ua: "userAgent",\n useragent: "userAgent",\n agent: "userAgent",\n verdict: "verdict",\n action: "action",\n rule: "rule",\n detector: "detector",\n identity: "identity",\n method: "method",\n class: "botClass",\n botclass: "botClass",\n id: "requestId",\n request: "requestId",\n bypass: "bypass",\n score: "score",\n outcome: "outcome",\n certain: "certain"\n };\n var NUMERIC = /* @__PURE__ */ new Set(["score"]);\n function tokenize(input) {\n const tokens = [];\n let current = "";\n let quoted = false;\n for (const character of input) {\n if (character === \'"\') {\n quoted = !quoted;\n continue;\n }\n if (!quoted && /\\s/.test(character)) {\n if (current !== "") tokens.push(current);\n current = "";\n continue;\n }\n current += character;\n }\n if (current !== "") tokens.push(current);\n return tokens;\n }\n function parseQuery(input) {\n const terms = [];\n for (const token of tokenize(input.trim())) {\n const negated = token.startsWith("-") || token.startsWith("!");\n const body = negated ? token.slice(1) : token;\n if (body === "") continue;\n const colon = body.indexOf(":");\n const name = colon === -1 ? "" : body.slice(0, colon).toLowerCase();\n const field2 = FIELDS[name];\n if (colon === -1 || field2 === void 0) {\n terms.push({ field: void 0, value: body.toLowerCase(), negated });\n continue;\n }\n let value = body.slice(colon + 1).toLowerCase();\n let compare;\n if (NUMERIC.has(field2)) {\n compare = value.startsWith(">") ? ">" : value.startsWith("<") ? "<" : "=";\n if (compare !== "=") value = value.slice(1);\n }\n if (value === "") continue;\n terms.push({ field: field2, value, negated, compare });\n }\n return terms;\n }\n function searchableText(entry) {\n const parts = [\n entry.method,\n entry.path,\n entry.actor,\n entry.userAgent,\n entry.verdict,\n entry.botClass,\n entry.identity ?? "",\n entry.action ?? "",\n entry.rule ?? "",\n entry.requestId\n ];\n for (const item of entry.evidence) parts.push(item.detector, item.summary);\n return parts.join(" ").toLowerCase();\n }\n function fieldValue(entry, field2) {\n switch (field2) {\n case "path":\n return entry.path;\n case "actor":\n return entry.actor;\n case "userAgent":\n return entry.userAgent;\n case "verdict":\n return entry.verdict;\n case "action":\n return entry.action ?? "";\n case "rule":\n return entry.rule ?? "";\n case "identity":\n return entry.identity ?? "";\n case "method":\n return entry.method;\n case "botClass":\n return entry.botClass;\n case "requestId":\n return entry.requestId;\n case "bypass":\n return entry.bypass ?? "";\n case "outcome":\n return outcome(entry);\n case "certain":\n return String(entry.certain);\n case "detector":\n return entry.evidence.map((item) => item.detector).join(" ");\n default:\n return "";\n }\n }\n function matchesTerm(term, entry, haystack) {\n if (term.field === void 0) return haystack.includes(term.value);\n if (term.field === "score") {\n const wanted = Number(term.value);\n if (Number.isNaN(wanted)) return false;\n if (term.compare === ">") return entry.score > wanted;\n if (term.compare === "<") return entry.score < wanted;\n return entry.score === wanted;\n }\n return fieldValue(entry, term.field).toLowerCase().includes(term.value);\n }\n function matchesQuery(terms, entry, haystack) {\n for (const term of terms) {\n if (matchesTerm(term, entry, haystack) === term.negated) return false;\n }\n return true;\n }\n function matchesFilter(filter, entry) {\n switch (filter) {\n case "proven":\n return entry.certain;\n case "suspected":\n return entry.verdict === "suspected-bot";\n case "human":\n return entry.verdict === "human";\n case "guard":\n return entry.downgradedFrom !== void 0;\n case "deny":\n return outcome(entry) === "deny";\n case "mitigate":\n return outcome(entry) === "mitigate";\n case "allow":\n return outcome(entry) === "allow";\n default:\n return true;\n }\n }\n\n // src/dashboard/client/store.ts\n var MAX_ROWS = 1e3;\n var FEED_LIMIT = 300;\n var state = {\n rows: [],\n byId: /* @__PURE__ */ new Map(),\n snapshot: void 0,\n policy: void 0,\n actors: [],\n actorsTracked: 0,\n paused: false,\n filter: "all",\n search: "",\n terms: [],\n tab: "live",\n open: /* @__PURE__ */ new Set(),\n actor: void 0,\n rangeMs: 3e5,\n scoreScope: "run",\n editorRules: [],\n editorDirty: false,\n editorMode: "gui",\n guardDirty: false,\n bufferedWhilePaused: 0,\n laggedDrops: 0\n };\n function ingest(entry) {\n const existing = state.byId.get(entry.requestId);\n if (existing !== void 0) {\n existing.entry = entry;\n existing.rev++;\n existing.text = void 0;\n return;\n }\n const row = { entry, rev: 0 };\n state.byId.set(entry.requestId, row);\n state.rows.push(row);\n if (state.rows.length > MAX_ROWS) {\n for (const dropped of state.rows.splice(0, state.rows.length - MAX_ROWS)) {\n state.byId.delete(dropped.entry.requestId);\n state.open.delete(dropped.entry.requestId);\n }\n }\n }\n function clearFeed() {\n state.rows = [];\n state.byId = /* @__PURE__ */ new Map();\n state.open.clear();\n state.actor = void 0;\n state.bufferedWhilePaused = 0;\n }\n function setSearch(value) {\n state.search = value;\n state.terms = parseQuery(value);\n }\n function textOf(row) {\n if (row.text === void 0) row.text = searchableText(row.entry);\n return row.text;\n }\n function matches(row) {\n return matchesFilter(state.filter, row.entry) && matchesQuery(state.terms, row.entry, textOf(row));\n }\n function visibleRows(limit = FEED_LIMIT) {\n const shown = [];\n for (let i = state.rows.length - 1; i >= 0 && shown.length < limit; i--) {\n const row = state.rows[i];\n if (row !== void 0 && matches(row)) shown.push(row);\n }\n return shown;\n }\n function matchingCount() {\n let count = 0;\n for (const row of state.rows) if (matches(row)) count++;\n return count;\n }\n function oldestAt() {\n return state.rows[0]?.entry.at;\n }\n function bump(counter, key) {\n counter.set(key, (counter.get(key) ?? 0) + 1);\n }\n function aggregate(rows) {\n const totals = {\n detectors: /* @__PURE__ */ new Map(),\n actors: /* @__PURE__ */ new Map(),\n identities: /* @__PURE__ */ new Map(),\n paths: /* @__PURE__ */ new Map(),\n deniedPaths: /* @__PURE__ */ new Map(),\n guardStops: /* @__PURE__ */ new Map(),\n ruleHits: /* @__PURE__ */ new Map(),\n bypassed: /* @__PURE__ */ new Map()\n };\n for (const { entry } of rows) {\n for (const item of entry.evidence) bump(totals.detectors, item.detector);\n bump(totals.actors, entry.actor);\n if (entry.bypass !== void 0) {\n bump(totals.bypassed, `${entry.path} (${entry.bypass})`);\n continue;\n }\n bump(totals.paths, entry.path);\n if (entry.identity !== void 0 && entry.identity !== "") {\n bump(totals.identities, `${entry.identity} \xB7 ${entry.verdict === "verified-bot" ? "verified" : "claimed"}`);\n }\n if (outcome(entry) === "deny") bump(totals.deniedPaths, entry.path);\n if (entry.downgradedFrom !== void 0 && entry.rule !== void 0) bump(totals.guardStops, `${entry.rule} \u2192 ${entry.downgradedFrom}`);\n if (entry.rule !== void 0) bump(totals.ruleHits, entry.rule);\n }\n return totals;\n }\n\n // src/dashboard/client/api.ts\n async function getJson(path) {\n const response = await fetch(API + path);\n if (!response.ok) throw new Error(await errorFrom(response));\n return await response.json();\n }\n async function postJson(path, body) {\n const response = await fetch(API + path, {\n method: "POST",\n headers: { "content-type": "application/json" },\n body: JSON.stringify(body)\n });\n const data = await response.json().catch(() => ({}));\n return response.ok ? { ok: true, data } : { ok: false, data, error: String(data.error ?? "The server refused this.") };\n }\n async function errorFrom(response) {\n const body = await response.json().catch(() => ({}));\n return body.error ?? `${response.status} ${response.statusText}`;\n }\n\n // src/dashboard/client/actions.ts\n var armed = 0;\n function isConfirming() {\n return armed > 0;\n }\n function actorActions(key, after) {\n if (!BOOT.allowActing) return [];\n const forget = el("button", null, "Forget");\n forget.title = "Discard this actor\'s history \u2014 the cure for a false positive that has stuck";\n forget.addEventListener("click", () => {\n void act({ key, action: "forget" }, `Forgot ${key}`, "Its next request is assessed as a first request.", after);\n });\n const clear2 = el("button", null, "Clear as human");\n clear2.title = "Grant this actor human clearance for an hour, as though it had solved a challenge";\n clear2.addEventListener("click", () => {\n void act({ key, action: "clear", forMs: 60 * 6e4 }, `Cleared ${key}`, "Held as human for an hour, then reassessed.", after);\n });\n return [confirmingButton("Allowlist", `Allowlist ${key} \u2014 it stops being assessed at all`, () => allowlist(key, after)), forget, clear2];\n }\n function confirmingButton(label2, confirmation, run2) {\n const button = el("button", "danger", label2);\n let pending2 = false;\n let timer2;\n const disarm = () => {\n if (!pending2) return;\n pending2 = false;\n armed--;\n button.textContent = label2;\n button.className = "danger";\n };\n button.addEventListener("click", () => {\n if (pending2) {\n if (timer2 !== void 0) clearTimeout(timer2);\n disarm();\n run2();\n return;\n }\n pending2 = true;\n armed++;\n button.textContent = confirmation;\n button.className = "danger primary";\n timer2 = setTimeout(disarm, 5e3);\n });\n return button;\n }\n async function allowlist(key, after) {\n const result = await postJson("/api/ranges", { name: "allowlist", add: [key] });\n if (!result.ok) {\n toast("bad", "Not allowlisted", result.error ?? "");\n return;\n }\n toast("warn", `Allowlisted ${key}`, "Requests from it are no longer assessed at all.");\n after();\n }\n async function act(body, title, detail, after) {\n const result = await postJson("/api/actor", body);\n if (!result.ok) {\n toast("bad", "Refused", result.error ?? "");\n return;\n }\n toast("ok", title, detail);\n after();\n }\n\n // src/dashboard/client/format.ts\n var numbers = new Intl.NumberFormat();\n function n(value) {\n return numbers.format(value ?? 0);\n }\n function pct(part, whole) {\n return whole > 0 ? `${Math.round(part / whole * 100)}%` : "\u2014";\n }\n function ms(value) {\n return value >= 10 ? `${value.toFixed(1)}ms` : `${value.toFixed(2)}ms`;\n }\n function uptime(milliseconds) {\n const seconds = Math.max(0, Math.round(milliseconds / 1e3));\n if (seconds < 90) return `${seconds}s`;\n const minutes = Math.round(seconds / 60);\n if (minutes < 90) return `${minutes}m`;\n return `${Math.round(minutes / 60)}h`;\n }\n function rangeLabel(milliseconds) {\n const seconds = Math.round(milliseconds / 1e3);\n if (seconds < 90) return `${seconds}s`;\n const minutes = Math.round(seconds / 60);\n return minutes < 90 ? `${minutes} min` : `${Math.round(minutes / 60)}h`;\n }\n function clockTime(at) {\n return new Date(at).toLocaleTimeString();\n }\n function windowLabel(count, oldestAt2, now) {\n if (count === 0) return "this window \xB7 empty";\n const span = oldestAt2 === void 0 ? 0 : Math.max(0, now - oldestAt2);\n return `last ${n(count)} requests \xB7 ${rangeLabel(span)}`;\n }\n\n // src/dashboard/client/bars.ts\n function drawBars(target, rows, emptyText) {\n clear(target);\n const filtered = [...rows].filter(([, value]) => value > 0);\n if (filtered.length === 0) {\n target.appendChild(el("div", "note", emptyText));\n return;\n }\n filtered.sort((a, b) => b[1] - a[1]);\n const max = filtered[0]?.[1] ?? 1;\n for (const [label2, value] of filtered.slice(0, 14)) {\n const bar = el("div", "bar");\n const track = el("div", "track");\n const fill = el("div", "fill");\n fill.style.width = `${Math.max(2, Math.round(value / max * 100))}%`;\n track.appendChild(fill);\n track.appendChild(el("div", "lbl", label2));\n bar.appendChild(track);\n bar.appendChild(el("div", "v tnum", n(value)));\n target.appendChild(bar);\n }\n }\n function pairs(record) {\n return Object.entries(record ?? {});\n }\n\n // src/dashboard/client/actor.ts\n function initActor() {\n if (!SECTIONS.actors) return;\n $("actor-close").addEventListener("click", closeActor);\n }\n function openActor(key) {\n state.actor = key;\n app.drawNow();\n $("actor-panel").scrollIntoView({ block: "nearest" });\n }\n function closeActor() {\n state.actor = void 0;\n app.drawNow();\n }\n function drawActor() {\n if (!SECTIONS.actors) return;\n const panel = $("actor-panel");\n if (state.actor === void 0) {\n panel.hidden = true;\n return;\n }\n panel.hidden = false;\n $("actor-key").textContent = state.actor;\n const mine = state.rows.filter((row) => row.entry.actor === state.actor).map((row) => row.entry);\n const latest = mine[mine.length - 1];\n const stats = latest?.actorStats;\n const gaps = [];\n for (let i = 1; i < mine.length; i++) gaps.push(mine[i].at - mine[i - 1].at);\n const meanGap = gaps.length > 0 ? gaps.reduce((a, b) => a + b, 0) / gaps.length : 0;\n const box = $("actor-stats");\n clear(box);\n const rows = [\n ["In this window", `${n(mine.length)} requests`],\n ["Engine sees", stats !== void 0 ? `${n(stats.requests)} requests, ${n(stats.distinctPaths)} distinct paths` : "\u2014"],\n ["Prior confirmations", stats !== void 0 ? n(stats.priorConfirmations) : "\u2014"],\n ["Holds clearance", stats !== void 0 ? stats.cleared ? "yes" : "no" : "\u2014"],\n ["First seen", stats !== void 0 ? clockTime(stats.firstSeen) : "\u2014"],\n ["Mean gap", gaps.length > 0 ? `${Math.round(meanGap)}ms over ${n(gaps.length)} gaps` : "one request only"]\n ];\n for (const [key, value] of rows) {\n box.appendChild(el("dt", null, key));\n box.appendChild(el("dd", null, value));\n }\n const verdicts = /* @__PURE__ */ new Map();\n const actions = /* @__PURE__ */ new Map();\n for (const entry of mine) {\n verdicts.set(entry.verdict, (verdicts.get(entry.verdict) ?? 0) + 1);\n if (entry.action !== void 0) actions.set(entry.action, (actions.get(entry.action) ?? 0) + 1);\n }\n drawBars($("actor-mix"), [...verdicts, ...actions], "Nothing yet.");\n const bar = $("actor-actions");\n clear(bar);\n const buttons = actorActions(state.actor, () => app.drawNow());\n bar.hidden = buttons.length === 0;\n for (const button of buttons) bar.appendChild(button);\n }\n\n // src/dashboard/client/replay.ts\n function replayLine(entry) {\n const headers = {};\n for (const [name, value] of entry.headers ?? []) headers[name] = value;\n const query = Object.keys(entry.query).map((name) => `${encodeURIComponent(name)}=${encodeURIComponent(entry.query[name] ?? "")}`).join("&");\n return JSON.stringify({\n method: entry.method,\n url: entry.path + (query === "" ? "" : `?${query}`),\n headers,\n ip: entry.actor,\n timestamp: new Date(entry.at).toISOString(),\n protocol: entry.protocol ?? "https",\n httpVersion: entry.httpVersion ?? "1.1"\n });\n }\n function replayFile(entries) {\n return entries.map(replayLine).join("\\n");\n }\n function corpusCase(entry) {\n const headers = (entry.headers ?? []).map((pair) => ` [${JSON.stringify(pair[0])}, ${JSON.stringify(pair[1])}]`).join(",\\n");\n return [\n "bot({",\n ` id: ${JSON.stringify(`case-${entry.requestId.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`)},`,\n ` title: ${JSON.stringify(`${entry.method} ${entry.path} from ${entry.userAgent.slice(0, 60)}`)},`,\n \' audience: "unwanted-bot", // human | benign-bot | declared-bot | unwanted-bot | hostile | infrastructure\',\n \' category: "observed",\',\n ` provenance: "Captured from the live dashboard on ${new Date(entry.at).toISOString().slice(0, 10)}",`,\n " requests: [",\n " {",\n " headers: [",\n headers,\n " ],",\n ` protocol: ${JSON.stringify(entry.protocol ?? "https")},`,\n ` httpVersion: ${JSON.stringify(entry.httpVersion ?? "1.1")},`,\n ` path: ${JSON.stringify(entry.path)},`,\n " },",\n " ],",\n ` expect: { verdict: ${JSON.stringify(entry.verdict)}, certain: ${String(entry.certain)} },`,\n "}),"\n ].join("\\n");\n }\n\n // src/dashboard/client/result.ts\n function showResult(kind, build) {\n const box = $("policy-result");\n box.hidden = false;\n box.className = `result ${kind}`;\n clear(box);\n build(box);\n }\n function renderPreview(preview, notes = []) {\n showResult(preview.newDenials > 0 ? "warn" : "ok", (box) => {\n const head = el("div");\n head.appendChild(el("b", null, `${n(preview.changed)} of ${n(preview.evaluated)} requests would be treated differently.`));\n box.appendChild(head);\n for (const note of notes) box.appendChild(el("div", "ev-meta", note));\n if (preview.newDenials > 0) {\n box.appendChild(el("div", null, `${n(preview.newDenials)} request(s) that are served today would be denied. Read the samples before applying this.`));\n }\n for (const warning of preview.warnings) box.appendChild(el("div", "ev-meta", warning));\n const dead = preview.ruleHits.filter((row) => row.hits === 0 && row.rule !== "default");\n if (dead.length > 0) box.appendChild(el("div", "ev-meta", `Never matched in this window: ${dead.map((row) => row.rule).join(", ")}`));\n if (preview.samples.length > 0) {\n const table = el("table", "diff");\n const head2 = el("tr");\n for (const label2 of ["Request", "Now", "Would be"]) head2.appendChild(el("th", null, label2));\n table.appendChild(head2);\n for (const sample of preview.samples) {\n const row = el("tr");\n const what = el("td");\n what.appendChild(el("div", "mono", sample.path));\n what.appendChild(el("div", "ev-meta", `${sample.verdict} \xB7 ${sample.userAgent.slice(0, 48)}`));\n row.appendChild(what);\n const from = el("td", "from");\n from.appendChild(el("div", null, sample.from));\n from.appendChild(el("div", "ev-meta", sample.fromRule));\n row.appendChild(from);\n const kind = sample.to === "block" || sample.to === "drop" || sample.to === "redirect" ? "deny" : sample.to === "allow" ? "allow" : "";\n const to = el("td", `to ${kind}`);\n to.appendChild(el("div", null, sample.to));\n to.appendChild(el("div", "ev-meta", sample.toRule));\n row.appendChild(to);\n table.appendChild(row);\n }\n box.appendChild(table);\n } else if (preview.evaluated === 0) {\n box.appendChild(el("div", "ev-meta", "No traffic in the window to preview against \u2014 send some requests first."));\n }\n });\n }\n\n // src/dashboard/client/guard.ts\n var draft;\n var MODE_NOTES = {\n strict: "A terminal action survives only on proven evidence. Nothing is ever denied on a guess. This is the default, and it is the claim this library makes about itself.",\n balanced: "A terminal action also survives on a probabilistic verdict that clears the score threshold with at least two independent strong signals. Real people do trip two signals \u2014 a hardened browser behind a corporate proxy is the usual pair \u2014 so this setting will eventually deny somebody who should have been served.",\n aggressive: "The guard is off. Every rule does exactly what it says, on proof or on suspicion alike, and the people it turns away first are the ones with the most unusual and most legitimate setups."\n };\n function drawGuard() {\n if (!SECTIONS.guard) return;\n const document_ = state.policy;\n const snapshot = state.snapshot;\n if (snapshot === void 0) return;\n const editable = document_?.guardEditable === true;\n const panel = $("stat-policy");\n clear(panel);\n if (!editable || document_ === void 0) {\n const rows = [\n ["False-positive policy", snapshot.policy.falsePositivePolicy],\n ["Fallback when the guard stops a rule", snapshot.policy.fallbackAction],\n ["Terminal score threshold", String(snapshot.policy.terminalScoreThreshold)],\n ["Suspect threshold", String(snapshot.policy.suspectThreshold)],\n ["Action when no rule matches", snapshot.policy.defaultAction],\n ["Challenge configured", snapshot.policy.challengeEnabled ? "yes" : "no"],\n ["Range sets", snapshot.ranges.length === 0 ? "none" : snapshot.ranges.map((range) => `${range.name} (${range.size})`).join(", ")]\n ];\n for (const [key, value] of rows) {\n const line = el("div", "stat-row");\n line.appendChild(el("span", "k", key));\n line.appendChild(el("span", "v", value));\n panel.appendChild(line);\n }\n $("guard-mode").textContent = "not editable here";\n $("guard-note").textContent = "These are fixed at construction on this dashboard. It can change which rules exist; it cannot change how far a rule is allowed to go, because relaxing that is the one edit that can start denying people. Enable it deliberately with controls: { editGuard: true }.";\n return;\n }\n if (draft === void 0) draft = { ...document_.guard };\n const vocabulary = document_.vocabulary;\n $("guard-mode").textContent = state.guardDirty ? "unsaved changes" : "editable";\n $("guard-note").textContent = "Changing these changes what every rule is allowed to do, on the next request. Preview it against the traffic in the window first \u2014 that is the only view you get of who it would start turning away.";\n panel.appendChild(\n guardField(\n // "Mode" rather than "Guard": the panel is already called that, and a field\n // repeating its own panel\'s name reads as a heading rather than as a control.\n "Mode",\n segmented(\n vocabulary.falsePositivePolicies.map((mode) => [mode, mode]),\n draft.falsePositivePolicy,\n (value) => {\n setDraft({ falsePositivePolicy: value });\n drawGuard();\n }\n )\n )\n );\n panel.appendChild(el("div", "note guard-explains", MODE_NOTES[draft.falsePositivePolicy] ?? ""));\n panel.appendChild(\n guardField(\n "Fallback",\n select(vocabulary.fallbackActions, draft.fallbackAction, (value) => setDraft({ fallbackAction: value })),\n "what a stopped rule becomes \u2014 the terminal actions are absent because a terminal fallback would deny the request the guard just protected"\n )\n );\n panel.appendChild(\n guardField(\n "Default action",\n select(vocabulary.actions, draft.defaultAction, (value) => setDraft({ defaultAction: value })),\n "when no rule matches"\n )\n );\n panel.appendChild(\n guardField(\n "Terminal score",\n number(draft.terminalScoreThreshold, (value) => setDraft({ terminalScoreThreshold: value })),\n "balanced mode only: the score a probabilistic verdict must clear"\n )\n );\n panel.appendChild(\n guardField(\n "Suspect at",\n number(draft.suspectThreshold, (value) => setDraft({ suspectThreshold: value })),\n "the score at which a request becomes suspected-bot"\n )\n );\n const bar = el("div", "bar-actions");\n const preview = el("button", null, "Preview");\n preview.addEventListener("click", () => {\n void previewGuard();\n });\n const apply = el("button", "primary", "Apply");\n apply.addEventListener("click", () => {\n void applyGuard();\n });\n const revert = el("button", null, "Revert");\n revert.addEventListener("click", () => {\n draft = { ...document_.guard };\n state.guardDirty = false;\n drawGuard();\n });\n bar.appendChild(preview);\n bar.appendChild(apply);\n bar.appendChild(revert);\n panel.appendChild(bar);\n }\n function setDraft(change) {\n if (draft === void 0) return;\n draft = { ...draft, ...change };\n state.guardDirty = true;\n $("guard-mode").textContent = "unsaved changes";\n }\n function resetGuardDraft() {\n draft = void 0;\n state.guardDirty = false;\n }\n function liveRules() {\n return (state.policy?.rules ?? []).filter((row) => row.editable && row.rule !== void 0).map((row) => row.rule);\n }\n async function previewGuard() {\n if (draft === void 0) return;\n const result = await postJson("/api/policy/preview", { rules: liveRules(), guard: draft });\n if (!result.ok) {\n showResult("bad", (box) => box.appendChild(el("div", null, result.error ?? "")));\n toast("bad", "Refused", result.error ?? "");\n return;\n }\n renderPreview(result.data, state.editorDirty ? ["Previewed against the rules currently in force, not the unsaved edits in the editor."] : []);\n app.showTab("policy");\n }\n async function applyGuard() {\n if (draft === void 0) return;\n const result = await postJson("/api/guard", draft);\n if (!result.ok) {\n showResult("bad", (box) => box.appendChild(el("div", null, result.error ?? "")));\n toast("bad", "Guard unchanged", result.error ?? "");\n return;\n }\n state.guardDirty = false;\n draft = { ...result.data.guard };\n if (state.policy !== void 0) state.policy.guard = { ...result.data.guard };\n toast("ok", "Guard changed", `${result.data.guard.falsePositivePolicy}, falling back to ${result.data.guard.fallbackAction}.`);\n showResult("warn", (box) => {\n box.appendChild(el("div", null, "The guard changed. It applies from the next request, and it is in the notices panel and in your logs."));\n if (result.data.guard.falsePositivePolicy !== "strict") {\n box.appendChild(\n el(\n "div",\n "ev-meta",\n "Requests can now be denied without proof. The guard-stop count is the series to watch: every stop that no longer happens is a request that used to be recoverable and is not any more."\n )\n );\n }\n });\n app.draw();\n }\n function guardField(name, control, hint) {\n const row = el("div", "field");\n row.appendChild(label(name, control));\n const right = el("div", "field-row");\n right.appendChild(control);\n if (hint !== void 0) right.appendChild(el("span", "hint", hint));\n row.appendChild(right);\n return row;\n }\n function segmented(options, value, onChange) {\n const box = el("div", "seg");\n for (const [label2, option] of options) {\n const button = el("button", null, label2);\n button.type = "button";\n button.setAttribute("aria-pressed", String(option === value));\n button.addEventListener("click", () => onChange(option));\n box.appendChild(button);\n }\n return box;\n }\n function select(options, value, onChange) {\n const node = el("select");\n for (const option of options) {\n const item = el("option", null, option);\n item.value = option;\n if (option === value) item.selected = true;\n node.appendChild(item);\n }\n node.addEventListener("change", () => onChange(node.value));\n return node;\n }\n function number(value, onChange) {\n const input = el("input");\n input.type = "number";\n input.min = "1";\n input.max = "100";\n input.value = String(value);\n input.addEventListener("input", () => {\n if (input.value !== "") onChange(Number(input.value));\n });\n return input;\n }\n\n // src/dashboard/client/ranges.ts\n var sets = [];\n async function loadRanges() {\n if (!SECTIONS.ranges) return;\n try {\n const body = await getJson("/api/ranges");\n sets = body.ranges;\n drawRanges();\n } catch {\n }\n }\n function drawRanges() {\n if (!SECTIONS.ranges) return;\n const body = $("ranges-body");\n clear(body);\n $("ranges-mode").textContent = BOOT.allowActing ? "editable" : "read-only";\n $("ranges-note").textContent = BOOT.allowActing ? "An address on the allowlist is not judged leniently \u2014 it is not judged at all. Detection does not run on it, no evidence is produced, and no rule sees it." : "These come from the code that constructed the handler. Enable controls.editRanges to add an address from here.";\n if (sets.length === 0) {\n body.appendChild(el("div", "note", "No range sets are configured. Add an address to the allowlist and one appears."));\n }\n for (const set of sets) {\n const block = el("div", "rangeset");\n const heading = el("h3");\n heading.appendChild(document.createTextNode(set.name));\n heading.appendChild(el("span", null, `${n(set.size)} entr${set.size === 1 ? "y" : "ies"}`));\n block.appendChild(heading);\n const list = el("div", "cidrs");\n for (const entry of set.entries) {\n const chip = el("span", BOOT.allowActing ? "cidr" : "cidr readonly");\n chip.appendChild(document.createTextNode(entry));\n if (BOOT.allowActing) {\n const remove = el("button", null, "\xD7");\n remove.title = `Remove ${entry} from ${set.name}`;\n remove.setAttribute("aria-label", `Remove ${entry} from ${set.name}`);\n remove.addEventListener("click", () => void update(set.name, { remove: [entry] }));\n chip.appendChild(remove);\n }\n list.appendChild(chip);\n }\n if (set.entries.length === 0) list.appendChild(el("span", "hint", "empty"));\n block.appendChild(list);\n body.appendChild(block);\n }\n if (!BOOT.allowActing) return;\n const form = el("div", "rangeset");\n const row = el("div", "field-row");\n const name = el("input", "mono-input");\n name.type = "text";\n name.value = "allowlist";\n name.setAttribute("aria-label", "Range set");\n name.style.maxWidth = "150px";\n const value = el("input", "mono-input");\n value.type = "text";\n value.placeholder = "203.0.113.0/24";\n value.setAttribute("aria-label", "Address or CIDR to add");\n const add = el("button", null, "Add");\n const submit = () => {\n const entry = value.value.trim();\n if (entry === "") return;\n value.value = "";\n void update(name.value.trim(), { add: [entry] });\n };\n add.addEventListener("click", submit);\n value.addEventListener("keydown", (event) => {\n if (event.key === "Enter") submit();\n });\n row.appendChild(name);\n row.appendChild(value);\n row.appendChild(add);\n form.appendChild(row);\n body.appendChild(form);\n }\n async function update(name, change) {\n const result = await postJson("/api/ranges", { name, ...change });\n if (!result.ok) {\n toast("bad", "Ranges unchanged", result.error ?? "");\n return;\n }\n toast(name === "allowlist" && change.add !== void 0 ? "warn" : "ok", `\u201C${name}\u201D updated`, `${n(result.data.entries?.length ?? 0)} entr${result.data.entries?.length === 1 ? "y" : "ies"} now.`);\n await loadRanges();\n }\n\n // src/dashboard/client/draft.ts\n function draftRule(entry, existingIds = []) {\n const match = {};\n let because;\n let stem;\n const proven = entry.evidence.filter((item) => item.certainty === "certain" && item.direction !== "human");\n const detectors = [...new Set((proven.length > 0 ? proven : entry.evidence.filter((item) => item.direction !== "human")).map((item) => item.detector))];\n if (entry.identity !== void 0 && entry.identity !== "") {\n match["identity"] = [entry.identity];\n if (entry.certain) match["certain"] = true;\n stem = entry.identity;\n because = entry.certain ? `Matched on the identity \u201C${entry.identity}\u201D, and on proof \u2014 so a client merely claiming that name does not match.` : `Matched on the claimed identity \u201C${entry.identity}\u201D. Nothing has verified it, so this matches anything that says so.`;\n } else if (proven.length > 0) {\n match["detector"] = detectors;\n match["certain"] = true;\n stem = detectors[0] ?? "proven";\n because = `Matched on proof from ${detectors.join(", ")}. Only requests that carry the same proof match.`;\n } else if (detectors.length > 0) {\n match["verdict"] = [entry.verdict];\n match["detector"] = detectors;\n match["minScore"] = Math.max(0, Math.floor(entry.score / 10) * 10);\n stem = detectors[0] ?? entry.verdict;\n because = `Matched on ${entry.verdict} at score ${String(match["minScore"])} or more, from ${detectors.join(", ")}. Every one of those is probabilistic, so the guard will not let this rule deny anybody.`;\n } else {\n match["verdict"] = [entry.verdict];\n stem = entry.verdict;\n because = `Nothing fired on this request, so there is nothing sharper to match on than the verdict itself. Narrow it before you use it.`;\n }\n return {\n rule: {\n id: uniqueId(`from-${slug(stem)}`, existingIds),\n match,\n action: "tag",\n reason: "Drafted from a request on the dashboard.",\n _open: true\n },\n because\n };\n }\n function slug(value) {\n const cleaned = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");\n return cleaned === "" ? "request" : cleaned.slice(0, 40);\n }\n function uniqueId(wanted, taken) {\n if (!taken.includes(wanted)) return wanted;\n for (let suffix = 2; suffix < 1e3; suffix++) {\n const candidate = `${wanted}-${suffix}`;\n if (!taken.includes(candidate)) return candidate;\n }\n return `${wanted}-${Date.now()}`;\n }\n\n // src/dashboard/client/policy.ts\n async function loadPolicy() {\n if (!SECTIONS.policy) return;\n try {\n const document_ = await getJson("/api/policy");\n state.policy = document_;\n if (!state.editorDirty) {\n setEditorRules(document_.rules.filter((row) => row.editable).map((row) => row.rule));\n }\n if (!state.guardDirty) resetGuardDraft();\n drawPolicyTab();\n } catch {\n }\n }\n function setEditorRules(rules) {\n state.editorRules = JSON.parse(JSON.stringify(rules ?? []));\n renderEditor();\n }\n function markDirty() {\n state.editorDirty = true;\n $("policy-dirty").hidden = false;\n }\n function editorRules() {\n if (state.editorMode === "json") {\n try {\n const parsed = JSON.parse(byId("policy-json").value);\n if (!Array.isArray(parsed)) return { error: "The JSON must be an array of rules." };\n return { rules: parsed };\n } catch (error) {\n return { error: `The editor does not contain valid JSON: ${String(error)}` };\n }\n }\n return { rules: state.editorRules };\n }\n function cleanRule(rule) {\n const match = {};\n for (const [key, value] of Object.entries(rule.match ?? {})) {\n if (value === void 0 || value === null || value === "") continue;\n if (Array.isArray(value) && value.length === 0) continue;\n match[key] = value;\n }\n const params = {};\n for (const [key, value] of Object.entries(rule.params ?? {})) {\n if (value === void 0 || value === null || value === "") continue;\n if (key === "limit") {\n const limit = value;\n if (limit.max === void 0 || limit.windowMs === void 0) continue;\n }\n params[key] = value;\n }\n const out = { id: rule.id, match, action: rule.action };\n if (Object.keys(params).length > 0) out["params"] = params;\n if (rule.reason !== void 0 && rule.reason !== "") out["reason"] = rule.reason;\n return out;\n }\n function cleanRules(rules) {\n return rules.map(cleanRule);\n }\n function field(name, control, hint) {\n const row = el("div", "field");\n row.appendChild(label(name, control));\n const right = el("div", "field-row");\n for (const node of Array.isArray(control) ? control : [control]) right.appendChild(node);\n if (hint !== void 0) right.appendChild(el("span", "hint", hint));\n row.appendChild(right);\n return row;\n }\n function chipSelect(options, selected, onChange) {\n const box = el("div", "chips-select");\n const chosen = Array.isArray(selected) ? [...selected] : selected === void 0 ? [] : [String(selected)];\n for (const option of options) {\n const button = el("button", null, option);\n button.type = "button";\n button.setAttribute("aria-pressed", String(chosen.includes(option)));\n button.addEventListener("click", () => {\n const at = chosen.indexOf(option);\n if (at === -1) chosen.push(option);\n else chosen.splice(at, 1);\n button.setAttribute("aria-pressed", String(at === -1));\n onChange(chosen.length === 0 ? void 0 : [...chosen]);\n });\n box.appendChild(button);\n }\n return box;\n }\n function segmented2(options, value, onChange) {\n const box = el("div", "seg");\n for (const [label2, option] of options) {\n const button = el("button", null, label2);\n button.type = "button";\n button.setAttribute("aria-pressed", String(option === value));\n button.addEventListener("click", () => {\n for (const other of Array.from(box.children)) other.setAttribute("aria-pressed", "false");\n button.setAttribute("aria-pressed", "true");\n onChange(option);\n });\n box.appendChild(button);\n }\n return box;\n }\n function textInput(value, placeholder, onChange, mono = false) {\n const input = el("input", mono ? "mono-input" : null);\n input.type = "text";\n input.value = value === void 0 || value === null ? "" : String(value);\n input.placeholder = placeholder;\n input.addEventListener("input", () => onChange(input.value.trim() === "" ? void 0 : input.value));\n return input;\n }\n function listInput(value, placeholder, onChange) {\n const current = value === void 0 ? "" : Array.isArray(value) ? value.join(", ") : String(value);\n const input = textInput(current, placeholder, () => {\n }, true);\n input.addEventListener("input", () => {\n const parts = input.value.split(",").map((part) => part.trim()).filter(Boolean);\n onChange(parts.length === 0 ? void 0 : parts.length === 1 ? parts[0] : parts);\n });\n return input;\n }\n function numberInput(value, placeholder, onChange) {\n const input = el("input");\n input.type = "number";\n input.value = value === void 0 || value === null ? "" : String(value);\n input.placeholder = placeholder;\n input.addEventListener("input", () => onChange(input.value === "" ? void 0 : Number(input.value)));\n return input;\n }\n function matchSummary(rule) {\n const box = el("div", "rule-summary");\n const match = rule.match ?? {};\n const parts = [];\n for (const key of ["verdict", "botClass", "category", "identity", "detector", "method", "path"]) {\n const value = match[key];\n if (value === void 0) continue;\n parts.push([key, Array.isArray(value) ? value.join(", ") : String(value)]);\n }\n if (match["certain"] !== void 0) parts.push(["certain", String(match["certain"])]);\n if (match["minScore"] !== void 0 || match["maxScore"] !== void 0) {\n parts.push(["score", `${String(match["minScore"] ?? 0)}\u2013${String(match["maxScore"] ?? 99)}`]);\n }\n if (match["minPriorConfirmations"] !== void 0) parts.push(["prior", String(match["minPriorConfirmations"])]);\n if (match["minUnsolvedChallenges"] !== void 0) parts.push(["unsolved", String(match["minUnsolvedChallenges"])]);\n if (parts.length === 0) {\n box.appendChild(el("span", "none", "matches everything"));\n return box;\n }\n for (const [key, value] of parts.slice(0, 4)) box.appendChild(el("span", "t", `${key}: ${value}`));\n if (parts.length > 4) box.appendChild(el("span", "k", `+${parts.length - 4} more`));\n return box;\n }\n function ruleCard(rule, index) {\n const vocabulary = state.policy?.vocabulary;\n const open = rule._open === true;\n const card = el("div", `rule${open ? "" : " collapsed"}`);\n const head = el("div", "rule-head");\n const chevron = el("button", "chev", open ? "\u25BE" : "\u25B8");\n chevron.title = open ? "Collapse" : "Expand";\n chevron.setAttribute("aria-expanded", String(open));\n chevron.addEventListener("click", () => {\n rule._open = !open;\n renderEditor();\n });\n head.appendChild(chevron);\n head.appendChild(el("span", "ord", index + 1));\n const id = textInput(rule.id, "rule-id", (value) => {\n rule.id = value ?? "";\n markDirty();\n }, true);\n id.setAttribute("aria-label", "Rule id");\n head.appendChild(id);\n if (open) {\n const action = el("select");\n action.setAttribute("aria-label", "Action");\n for (const name of vocabulary?.actions ?? []) {\n const option = el("option", null, name);\n option.value = name;\n if (name === rule.action) option.selected = true;\n action.appendChild(option);\n }\n action.addEventListener("change", () => {\n rule.action = action.value;\n rule.params = {};\n markDirty();\n renderEditor();\n });\n head.appendChild(action);\n } else {\n head.appendChild(matchSummary(rule));\n head.appendChild(el("span", `act-pill ${actionKind(rule.action)}`, rule.action));\n }\n const up = el("button", "icon", "\u2191");\n up.title = "Move earlier \u2014 the first matching rule wins";\n up.addEventListener("click", () => moveRule(index, -1));\n const down = el("button", "icon", "\u2193");\n down.title = "Move later";\n down.addEventListener("click", () => moveRule(index, 1));\n const remove = el("button", "icon danger", "Remove");\n remove.addEventListener("click", () => {\n state.editorRules.splice(index, 1);\n markDirty();\n renderEditor();\n });\n head.appendChild(up);\n head.appendChild(down);\n head.appendChild(remove);\n card.appendChild(head);\n if (!open) return card;\n const body = el("div", "rule-body");\n rule.match = rule.match ?? {};\n const match = rule.match;\n body.appendChild(field("Verdict", chipSelect(vocabulary?.verdicts ?? [], match["verdict"], (value) => {\n match["verdict"] = value;\n markDirty();\n })));\n body.appendChild(field("Bot class", chipSelect(vocabulary?.botClasses ?? [], match["botClass"], (value) => {\n match["botClass"] = value;\n markDirty();\n })));\n body.appendChild(field("Category", chipSelect(vocabulary?.categories ?? [], match["category"], (value) => {\n match["category"] = value;\n markDirty();\n })));\n body.appendChild(field("Detector", chipSelect(vocabulary?.detectors ?? [], match["detector"], (value) => {\n match["detector"] = value;\n markDirty();\n })));\n body.appendChild(field("Method", chipSelect(vocabulary?.methods ?? [], match["method"], (value) => {\n match["method"] = value;\n markDirty();\n })));\n body.appendChild(\n field(\n "Evidence",\n segmented2(\n [\n ["any", void 0],\n ["proven", true],\n ["unproven", false]\n ],\n match["certain"],\n (value) => {\n match["certain"] = value;\n markDirty();\n }\n ),\n "proven means at least one piece of certain evidence \u2014 including a proven human"\n )\n );\n body.appendChild(\n field("Score", [\n numberInput(match["minScore"], "min", (value) => {\n match["minScore"] = value;\n markDirty();\n }),\n el("span", "hint", "to"),\n numberInput(match["maxScore"], "max", (value) => {\n match["maxScore"] = value;\n markDirty();\n })\n ])\n );\n body.appendChild(field("Identity", listInput(match["identity"], "googlebot, gptbot", (value) => {\n match["identity"] = value;\n markDirty();\n })));\n body.appendChild(field("Path", listInput(match["path"], "/api/, /search", (value) => {\n match["path"] = value;\n markDirty();\n }), "a string matches as a prefix"));\n body.appendChild(\n field(\n "Prior bots",\n numberInput(match["minPriorConfirmations"], "0", (value) => {\n match["minPriorConfirmations"] = value;\n markDirty();\n }),\n "times this actor was already proven a bot"\n )\n );\n body.appendChild(\n field(\n "Unsolved",\n numberInput(match["minUnsolvedChallenges"], "0", (value) => {\n match["minUnsolvedChallenges"] = value;\n markDirty();\n }),\n "challenges issued to this actor that were never answered \u2014 solving one clears the count"\n )\n );\n rule.params = rule.params ?? {};\n const params = rule.params;\n for (const node of paramFields(rule.action, params)) body.appendChild(node);\n body.appendChild(field("Reason", textInput(rule.reason, "shown in the decision and in your logs", (value) => {\n rule.reason = value ?? "";\n markDirty();\n })));\n card.appendChild(body);\n return card;\n }\n function paramFields(action, params) {\n switch (action) {\n case "block":\n return [\n field("Status", numberInput(params["status"], "403", (value) => {\n params["status"] = value;\n markDirty();\n })),\n field("Body", textInput(params["body"], "Automated traffic is not served here.", (value) => {\n params["body"] = value;\n markDirty();\n }))\n ];\n case "redirect":\n return [field("Location", textInput(params["location"], "/too-fast", (value) => {\n params["location"] = value;\n markDirty();\n }, true))];\n case "delay":\n return [field("Delay", numberInput(params["delayMs"], "250", (value) => {\n params["delayMs"] = value;\n markDirty();\n }), "milliseconds")];\n case "rate-limit": {\n const limit = params["limit"] ?? {};\n params["limit"] = limit;\n return [\n field("Limit", [\n numberInput(limit["max"], "60", (value) => {\n limit["max"] = value;\n markDirty();\n }),\n el("span", "hint", "requests per"),\n numberInput(limit["windowMs"], "60000", (value) => {\n limit["windowMs"] = value;\n markDirty();\n }),\n el("span", "hint", "ms")\n ])\n ];\n }\n case "custom":\n return [field("Handler", textInput(params["handler"], "handler-id", (value) => {\n params["handler"] = value;\n markDirty();\n }, true), "id of a handler you registered")];\n default:\n return [];\n }\n }\n function lockedCard(row) {\n const card = el("div", "rule locked");\n const head = el("div", "rule-head");\n head.appendChild(el("span", "ord", `#${row.index + 1}`));\n head.appendChild(el("span", "mono", row.id));\n head.appendChild(el("span", "grow"));\n head.appendChild(el("span", "pill", "predicate \u2014 locked"));\n card.appendChild(head);\n card.appendChild(\n el("div", "rule-body", "This rule matches with a function, which cannot be represented here or sent over HTTP. It stays exactly as it is, at this position, whatever else you change.")\n );\n return card;\n }\n function moveRule(index, delta) {\n const target = index + delta;\n if (target < 0 || target >= state.editorRules.length) return;\n const moved = state.editorRules.splice(index, 1)[0];\n if (moved === void 0) return;\n state.editorRules.splice(target, 0, moved);\n markDirty();\n renderEditor();\n }\n function renderEditor() {\n if (!SECTIONS.policy) return;\n const list = $("rulelist");\n clear(list);\n const locked = (state.policy?.rules ?? []).filter((row) => !row.editable);\n if (state.editorRules.length === 0 && locked.length === 0) {\n list.appendChild(el("div", "note", "No rules. Every request takes the default action \u2014 add one, or import a set."));\n }\n const rendered2 = state.editorRules.map((rule, index) => ruleCard(rule, index));\n for (const row of locked) rendered2.splice(Math.min(row.index, rendered2.length), 0, lockedCard(row));\n for (const node of rendered2) list.appendChild(node);\n if (state.editorMode === "json") byId("policy-json").value = JSON.stringify(cleanRules(state.editorRules), null, 2);\n }\n function drawPolicyTab() {\n const document_ = state.policy;\n if (document_ === void 0) return;\n const editable = document_.editable;\n $("policy-apply").hidden = !editable;\n byId("policy-json").readOnly = !editable;\n $("policy-mode").textContent = editable ? "editable" : "read-only";\n $("rule-add").hidden = !editable;\n $("policy-import").hidden = !editable;\n const preserved = document_.rules.filter((row) => !row.editable);\n let note = editable ? "First match wins \u2014 order matters." : "Read-only. Enable controls.editPolicy to change these.";\n if (preserved.length > 0) note += ` ${preserved.length} rule(s) use a predicate function and are locked.`;\n $("policy-note").textContent = note;\n renderPresetButtons();\n drawGuard();\n drawRanges();\n if (SECTIONS.robots) {\n $("robots-preview").textContent = document_.robots === "" ? "(this policy declines no crawler by name)" : document_.robots;\n const notes = $("robots-notes");\n clear(notes);\n for (const note_ of document_.robotsNotes) notes.appendChild(el("div", "ev-meta", `${note_.rule}: ${note_.reason}`));\n }\n const rules = $("stat-rules");\n clear(rules);\n const installed = state.snapshot?.rules ?? [];\n if (installed.length === 0) rules.appendChild(el("div", "note", "No rules configured \u2014 every request takes the default action."));\n else installed.forEach((rule, index) => rules.appendChild(el("span", "chip", `${index + 1}. ${rule}`)));\n }\n async function draftIntoEditor(entry) {\n if (state.policy === void 0) await loadPolicy();\n const drafted = draftRule(entry, state.editorRules.map((rule) => rule.id));\n state.editorRules.push(drafted.rule);\n markDirty();\n if (state.editorMode === "json") byId("policy-json").value = JSON.stringify(cleanRules(state.editorRules), null, 2);\n renderEditor();\n app.showTab("policy");\n const notes = [\n `Drafted \u201C${drafted.rule.id}\u201D, tagging only. Nothing is applied \u2014 read it, choose the action, then apply.`,\n drafted.because,\n "It was added last, which is the only position that cannot change what an existing rule does. Move it up with \u2191 if it needs to win."\n ];\n showResult("warn", (box) => {\n for (const note of notes) box.appendChild(el("div", note === notes[0] ? null : "ev-meta", note));\n });\n toast("ok", "Rule drafted", "In the editor, tagging only, not applied.");\n await runPreview(notes);\n }\n function switchToGui() {\n if (state.editorMode === "gui") return;\n const parsed = editorRules();\n if (parsed.error !== void 0) {\n toast("bad", "That JSON will not parse", parsed.error);\n return;\n }\n state.editorRules = parsed.rules ?? [];\n state.editorMode = "gui";\n $("mode-gui").setAttribute("aria-pressed", "true");\n $("mode-json").setAttribute("aria-pressed", "false");\n $("editor-gui").hidden = false;\n $("editor-json").hidden = true;\n renderEditor();\n }\n function switchToJson() {\n state.editorMode = "json";\n $("mode-gui").setAttribute("aria-pressed", "false");\n $("mode-json").setAttribute("aria-pressed", "true");\n $("editor-gui").hidden = true;\n $("editor-json").hidden = false;\n byId("policy-json").value = JSON.stringify(cleanRules(state.editorRules), null, 2);\n }\n async function exportSettings() {\n try {\n const response = await fetch(`${API}/api/settings`);\n const text = await response.text();\n if (!response.ok) throw new Error(text);\n download(text, `bothandler-settings-${today()}.json`, "application/json");\n toast("ok", "Settings exported", "Rules, plus a record of the configuration around them.");\n } catch (error) {\n toast("bad", "Export failed", String(error));\n }\n }\n function readSettingsFile(file) {\n const reader = new FileReader();\n reader.onload = () => {\n try {\n applyImported(JSON.parse(String(reader.result)), file.name);\n } catch (error) {\n toast("bad", "That file is not JSON", String(error));\n }\n };\n reader.onerror = () => toast("bad", "Could not read that file", "");\n reader.readAsText(file);\n }\n function applyImported(document_, name) {\n const rules = Array.isArray(document_) ? document_ : Array.isArray(document_.rules) ? document_.rules : void 0;\n if (rules === void 0) {\n toast("bad", "Nothing to import", "Expected an array of rules, or a settings file with a rules array.");\n return;\n }\n setEditorRules(rules);\n markDirty();\n switchToGui();\n const ignored = [];\n const readOnly = document_.readOnly;\n if (readOnly !== void 0) {\n ignored.push("the guard, the detectors, the ranges and the audit \u2014 those come from the code that built the handler, not from a file");\n if (Array.isArray(readOnly.lockedRules) && readOnly.lockedRules.length > 0) {\n ignored.push(`${readOnly.lockedRules.length} predicate rule(s), which stay as they are`);\n }\n }\n showResult("warn", (box) => {\n box.appendChild(el("div", null, `Loaded ${rules.length} rule(s) from ${name}. Nothing has been applied yet \u2014 preview it first.`));\n for (const line of ignored) box.appendChild(el("div", "ev-meta", `Ignored: ${line}`));\n });\n toast("ok", `Imported ${rules.length} rule(s)`, "Review, preview, then apply.");\n }\n function collectForSubmit() {\n const parsed = editorRules();\n if (parsed.error !== void 0) {\n showResult("bad", (box) => box.appendChild(el("div", null, parsed.error ?? "")));\n toast("bad", "That JSON will not parse", parsed.error);\n return void 0;\n }\n const cleaned = cleanRules(parsed.rules ?? []);\n const blank = cleaned.filter((rule) => rule["id"] === void 0 || rule["id"] === "").length;\n if (blank > 0) {\n toast("bad", "Every rule needs an id", "It is what every decision and log line names.");\n return void 0;\n }\n return cleaned;\n }\n async function runPreview(notes = []) {\n const rules = collectForSubmit();\n if (rules === void 0) return;\n const result = await postJson("/api/policy/preview", { rules });\n if (!result.ok) {\n showResult("bad", (box) => box.appendChild(el("div", null, result.error ?? "")));\n toast("bad", "Refused", result.error ?? "");\n return;\n }\n renderPreview(result.data, notes);\n }\n async function applyPolicy() {\n const rules = collectForSubmit();\n if (rules === void 0) return;\n const result = await postJson("/api/policy/apply", { rules });\n if (!result.ok) {\n showResult("bad", (box) => box.appendChild(el("div", null, result.error ?? "")));\n toast("bad", "Refused", result.error ?? "");\n return;\n }\n state.editorDirty = false;\n $("policy-dirty").hidden = true;\n await loadPolicy();\n toast("ok", "Applied", `${n(result.data.rules)} rule(s) now in force.`);\n showResult("ok", (box) => {\n box.appendChild(el("div", null, `Applied. ${n(result.data.rules)} rule(s) are now in force.`));\n for (const warning of result.data.warnings ?? []) box.appendChild(el("div", "ev-meta", warning));\n });\n }\n function renderPresetButtons() {\n const box = $("preset-buttons");\n const presets = state.policy?.vocabulary.presets ?? [];\n if (box.childElementCount === presets.length) return;\n clear(box);\n for (const preset of presets) {\n const button = el("button", null, preset);\n button.addEventListener("click", () => {\n void (async () => {\n const result = await postJson("/api/policy/preview", { preset });\n if (!result.ok) {\n toast("bad", "Refused", result.error ?? "");\n return;\n }\n renderPreview(result.data);\n })();\n });\n box.appendChild(button);\n }\n }\n function initPolicy() {\n if (!SECTIONS.policy) return;\n $("mode-gui").addEventListener("click", switchToGui);\n $("mode-json").addEventListener("click", switchToJson);\n $("rule-add").addEventListener("click", () => {\n state.editorRules.push({ id: `new-rule-${state.editorRules.length + 1}`, match: {}, action: "tag", params: {}, _open: true });\n markDirty();\n renderEditor();\n });\n $("rule-expand").addEventListener("click", () => {\n const anyClosed = state.editorRules.some((rule) => rule._open !== true);\n for (const rule of state.editorRules) rule._open = anyClosed;\n $("rule-expand").textContent = anyClosed ? "Collapse all" : "Expand all";\n renderEditor();\n });\n byId("policy-json").addEventListener("input", markDirty);\n $("policy-preview").addEventListener("click", () => void runPreview());\n $("policy-apply").addEventListener("click", () => void applyPolicy());\n $("policy-revert").addEventListener("click", () => {\n state.editorDirty = false;\n $("policy-dirty").hidden = true;\n $("policy-result").hidden = true;\n resetGuardDraft();\n void loadPolicy();\n });\n $("policy-export").addEventListener("click", () => void exportSettings());\n $("policy-import").addEventListener("click", () => byId("policy-file").click());\n byId("policy-file").addEventListener("change", () => {\n const input = byId("policy-file");\n const file = input.files?.[0];\n if (file !== void 0) readSettingsFile(file);\n input.value = "";\n });\n const panel = $("editor-panel");\n for (const name of ["dragenter", "dragover"]) {\n panel.addEventListener(name, (event) => {\n if (state.policy?.editable !== true) return;\n event.preventDefault();\n panel.classList.add("drop");\n });\n }\n for (const name of ["dragleave", "drop"]) {\n panel.addEventListener(name, (event) => {\n panel.classList.remove("drop");\n if (name !== "drop") return;\n event.preventDefault();\n const file = event.dataTransfer?.files?.[0];\n if (file !== void 0) readSettingsFile(file);\n });\n }\n }\n\n // src/dashboard/client/feed.ts\n var FILTERS = [\n ["all", "All"],\n ["proven", "Proven"],\n ["suspected", "Suspected"],\n ["human", "Human"],\n ["guard", "Guard stops"],\n ["deny", "Denied"],\n ["mitigate", "Mitigated"],\n ["allow", "Served"]\n ];\n var rendered = /* @__PURE__ */ new Map();\n function initFeed() {\n const filters = $("filters");\n for (const [name, label2] of FILTERS) {\n const button = el("button", null, label2);\n button.setAttribute("aria-pressed", String(name === state.filter));\n button.dataset["filter"] = name;\n button.addEventListener("click", () => {\n state.filter = name;\n for (const other of Array.from(filters.children)) other.setAttribute("aria-pressed", "false");\n button.setAttribute("aria-pressed", "true");\n app.syncUrl();\n app.drawNow();\n });\n filters.appendChild(button);\n }\n const search = byId("search");\n search.addEventListener("input", () => {\n setSearch(search.value.trim());\n app.syncUrl();\n app.drawNow();\n });\n const exportShown = byId("feed-export");\n exportShown.hidden = !SECTIONS.evidence;\n exportShown.addEventListener("click", () => {\n const rows = visibleRows(Number.POSITIVE_INFINITY);\n if (rows.length === 0) {\n toast("warn", "Nothing to export", "No request in the window matches this filter.");\n return;\n }\n const entries = rows.map((row) => row.entry).reverse();\n download(`${replayFile(entries)}\n`, `bothandler-feed-${today()}.jsonl`, "application/x-ndjson");\n toast("ok", `Exported ${n(entries.length)} request(s)`, "Replay them with `bothandlerjs replay`.");\n });\n }\n function reflectFilterButtons() {\n for (const button of Array.from($("filters").children)) {\n button.setAttribute("aria-pressed", String(button instanceof HTMLElement && button.dataset["filter"] === state.filter));\n }\n const search = byId("search");\n if (search.value !== state.search) search.value = state.search;\n }\n function drawFeed() {\n const body = byId("rows");\n const shown = visibleRows();\n let index = 0;\n const place = (node) => {\n const current = body.childNodes[index] ?? null;\n if (current !== node) body.insertBefore(node, current);\n index++;\n };\n for (const row of shown) {\n const id = row.entry.requestId;\n const open = state.open.has(id);\n let cached = rendered.get(id);\n if (cached === void 0 || cached.rev !== row.rev || cached.open !== open) {\n cached = {\n row: buildRow(row.entry, open),\n detail: open ? buildDetail(row.entry) : void 0,\n rev: row.rev,\n open\n };\n rendered.set(id, cached);\n }\n place(cached.row);\n if (cached.detail !== void 0) place(cached.detail);\n }\n while (body.childNodes.length > index) body.removeChild(body.childNodes[index]);\n if (rendered.size > shown.length * 2 + 100) {\n const live = new Set(shown.map((row) => row.entry.requestId));\n for (const id of Array.from(rendered.keys())) if (!live.has(id)) rendered.delete(id);\n }\n const total = state.rows.length;\n const matching = matchingCount();\n $("empty").hidden = total > 0;\n $("feed-count").textContent = matching === total ? `${n(total)} in this window` : `${n(matching)} of ${n(total)}${matching > FEED_LIMIT ? ` \xB7 showing ${n(FEED_LIMIT)}` : ""}`;\n const skipped = (state.snapshot?.skipped ?? 0) + state.laggedDrops;\n const note = $("feed-skipped");\n note.hidden = skipped === 0;\n note.textContent = `${n(skipped)} not streamed`;\n note.title = state.laggedDrops > 0 ? `${n(state.laggedDrops)} were skipped because this connection could not keep up, and the rest by the rate cap. All of them are still in the window, the preview and the export.` : "Entries the rate cap kept off this stream. They are still in the window, the preview and the export \u2014 raise maxEventsPerSecond to see them live.";\n }\n function resetFeedCache() {\n rendered.clear();\n }\n function buildRow(entry, open) {\n const out = outcome(entry);\n const tr = el("tr", `row a-${entry.downgradedFrom !== void 0 ? "guard" : out}${open ? " open" : ""}`);\n tr.appendChild(el("td", "num mono tnum when", clockTime(entry.at)));\n const request = el("td", "edge req");\n const toggle = el("button", "row-toggle", `${entry.method} ${entry.path}`);\n toggle.type = "button";\n toggle.setAttribute("aria-expanded", String(open));\n toggle.setAttribute("aria-label", `${entry.method} ${entry.path}, ${entry.verdict}. Evidence.`);\n toggle.dataset["request"] = entry.requestId;\n request.appendChild(toggle);\n const ua = el("span", "ua");\n if (SECTIONS.actors) {\n const actorLink = el("a", null, entry.actor);\n actorLink.href = "#actor";\n actorLink.title = "Show everything from this actor";\n actorLink.addEventListener("click", (event) => {\n event.preventDefault();\n event.stopPropagation();\n openActor(entry.actor);\n });\n ua.appendChild(actorLink);\n ua.appendChild(document.createTextNode(` \xB7 ${entry.userAgent}`));\n } else {\n ua.appendChild(document.createTextNode(`${entry.actor} \xB7 ${entry.userAgent}`));\n }\n ua.title = `${entry.actor} \xB7 ${entry.userAgent}`;\n request.appendChild(ua);\n tr.appendChild(request);\n const verdictCell = el("td");\n const [badgeClass, badgeLabel] = verdictBadge(entry);\n verdictCell.appendChild(el("span", `badge ${badgeClass}`, badgeLabel));\n if (entry.identity !== void 0) verdictCell.appendChild(el("span", "sub", entry.identity));\n tr.appendChild(verdictCell);\n tr.appendChild(el("td", "num mono tnum", entry.certain ? "proven" : String(entry.score)));\n const actionCell = el("td");\n if (entry.action !== void 0) {\n const kind = out === "deny" ? "act-deny" : out === "mitigate" ? "act-mitigate" : out === "allow" ? "act-allow" : "act-tag";\n actionCell.appendChild(el("span", `act ${kind}`, entry.action));\n if (entry.rule !== void 0) {\n const ruleLabel = el("span", "sub", entry.rule);\n ruleLabel.title = entry.rule;\n actionCell.appendChild(ruleLabel);\n }\n if (entry.downgradedFrom !== void 0) actionCell.appendChild(el("span", "guard", `guard stopped ${entry.downgradedFrom}`));\n } else {\n actionCell.appendChild(el("span", "sub", "assessed only"));\n }\n tr.appendChild(actionCell);\n tr.appendChild(el("td", "num mono tnum", entry.durationMs.toFixed(2)));\n tr.dataset["request"] = entry.requestId;\n const flip = () => {\n if (state.open.has(entry.requestId)) state.open.delete(entry.requestId);\n else state.open.add(entry.requestId);\n app.drawNow();\n document.querySelector(`button.row-toggle[data-request="${cssEscape(entry.requestId)}"]`)?.focus();\n };\n toggle.addEventListener("click", (event) => {\n event.stopPropagation();\n flip();\n });\n tr.addEventListener("click", flip);\n return tr;\n }\n function buildDetail(entry) {\n const tr = el("tr", "detail");\n const cell = el("td");\n cell.colSpan = 6;\n if (!SECTIONS.evidence) {\n cell.appendChild(\n el(\n "div",\n "ev-meta",\n "The evidence section is switched off on this dashboard, so the reasons behind this verdict are not sent to it. What fired, and why, is on a dashboard that has `sections: { evidence: true }`."\n )\n );\n } else if (entry.evidence.length === 0) {\n cell.appendChild(\n el(\n "div",\n "ev-meta",\n entry.bypass !== void 0 ? `Detection was skipped for this request: ${entry.bypass}.` : "No detector produced any evidence. This is what ordinary traffic looks like."\n )\n );\n } else {\n const list = el("div", "ev");\n for (const item of entry.evidence) {\n const row = el("div", `ev-item${item.direction === "human" ? " human" : ""}`);\n row.appendChild(el("div", `tier t-${item.certainty}`, item.certainty));\n const body = el("div");\n body.appendChild(el("div", null, item.summary));\n let meta = `${item.detector} \xB7 points to ${item.direction}`;\n if (item.family !== void 0) meta += ` \xB7 family \u201C${item.family}\u201D, counted once with its siblings`;\n body.appendChild(el("div", "ev-meta", meta));\n if (item.deterministicBasis !== void 0) body.appendChild(el("div", "basis", item.deterministicBasis));\n row.appendChild(body);\n list.appendChild(row);\n }\n cell.appendChild(list);\n }\n for (const failure of entry.failures) {\n cell.appendChild(el("div", "ev-meta", `Detector ${failure.detector} ${failure.reason}: ${failure.message}`));\n }\n if (entry.downgradeReason !== void 0) cell.appendChild(el("div", "basis", `Guard: ${entry.downgradeReason}`));\n const queryNames = Object.keys(entry.query);\n if (queryNames.length > 0) {\n const queryTable = el("table", "hdr");\n for (const name of queryNames) {\n const value = entry.query[name] ?? "";\n const row = el("tr");\n row.appendChild(el("td", "n", `?${name}`));\n row.appendChild(el("td", `v${value === "[redacted]" ? " red" : ""}`, value));\n queryTable.appendChild(row);\n }\n cell.appendChild(queryTable);\n }\n if (entry.headers !== void 0 && entry.headers.length > 0) {\n const table = el("table", "hdr");\n for (const [name, value] of entry.headers) {\n const row = el("tr");\n row.appendChild(el("td", "n", `${name}:`));\n row.appendChild(el("td", `v${value === "[redacted]" ? " red" : ""}`, value));\n table.appendChild(row);\n }\n cell.appendChild(table);\n }\n const tools = el("div", "tools");\n if (SECTIONS.evidence) {\n tools.appendChild(copyButton("Copy replay line", () => replayLine(entry)));\n tools.appendChild(downloadButton("Download replay line", () => replayLine(entry), `request-${entry.requestId}.jsonl`));\n tools.appendChild(copyButton("Copy corpus case", () => corpusCase(entry)));\n }\n if (SECTIONS.policy) {\n const draft2 = el("button", null, "Draft a rule");\n draft2.title = "Start a rule from this request, in the policy editor";\n draft2.addEventListener("click", (event) => {\n event.stopPropagation();\n void draftIntoEditor(entry);\n });\n tools.appendChild(draft2);\n }\n if (SECTIONS.actors) {\n const actorButton = el("button", null, "Show this actor");\n actorButton.addEventListener("click", (event) => {\n event.stopPropagation();\n openActor(entry.actor);\n });\n tools.appendChild(actorButton);\n }\n cell.appendChild(tools);\n const foot = el("div", "detail-foot");\n foot.appendChild(el("span", null, clockTime(entry.at)));\n foot.appendChild(el("span", null, `actor ${entry.actor}`));\n if (entry.rule !== void 0) foot.appendChild(el("span", null, `rule \u201C${entry.rule}\u201D`));\n foot.appendChild(el("span", null, `assessed in ${entry.durationMs.toFixed(3)}ms`));\n foot.appendChild(el("span", "mono", entry.requestId));\n cell.appendChild(foot);\n tr.appendChild(cell);\n return tr;\n }\n function copyButton(label2, produce) {\n const button = el("button", null, label2);\n button.addEventListener("click", (event) => {\n event.stopPropagation();\n const text = produce();\n const done = () => {\n button.textContent = "Copied";\n setTimeout(() => {\n button.textContent = label2;\n }, 1200);\n };\n if (navigator.clipboard?.writeText !== void 0) navigator.clipboard.writeText(text).then(done, () => showText(text));\n else showText(text);\n });\n return button;\n }\n function downloadButton(label2, produce, filename) {\n const button = el("button", null, label2);\n button.addEventListener("click", (event) => {\n event.stopPropagation();\n download(`${produce()}\n`, filename, "application/x-ndjson");\n });\n return button;\n }\n function showText(text) {\n const box = el("pre", "code", text);\n const host = $("policy-result");\n host.hidden = false;\n host.className = "result";\n clear(host);\n host.appendChild(el("div", "ev-meta", "Copying needs a secure context; here is the text."));\n host.appendChild(box);\n const selection = getSelection();\n if (selection !== null) {\n const range = document.createRange();\n range.selectNodeContents(box);\n selection.removeAllRanges();\n selection.addRange(range);\n }\n }\n\n // src/dashboard/client/stream.ts\n function connectStream() {\n if (!SECTIONS.feed) {\n $("dot").className = "dot";\n $("conn").textContent = "feed off";\n return;\n }\n const source = new EventSource(`${API}/api/stream`);\n source.addEventListener("open", () => {\n $("dot").className = "dot on";\n $("conn").textContent = "live";\n });\n source.addEventListener("sync", (event) => {\n const detail = JSON.parse(event.data);\n if (!detail.replace) return;\n clearFeed();\n resetFeedCache();\n });\n source.addEventListener("entry", (event) => {\n ingest(JSON.parse(event.data));\n if (state.paused) {\n state.bufferedWhilePaused++;\n $("pause").textContent = `Resume (${state.bufferedWhilePaused})`;\n }\n app.draw();\n });\n source.addEventListener("update", (event) => {\n ingest(JSON.parse(event.data));\n app.draw();\n });\n source.addEventListener("reset", () => {\n clearFeed();\n resetFeedCache();\n app.draw();\n });\n source.addEventListener("lagged", (event) => {\n const detail = JSON.parse(event.data);\n state.laggedDrops += detail.dropped;\n app.draw();\n });\n source.addEventListener("stats", (event) => {\n state.snapshot = JSON.parse(event.data);\n app.draw();\n });\n source.addEventListener("error", () => {\n $("dot").className = "dot off";\n $("conn").textContent = "reconnecting\u2026";\n });\n }\n async function loadInitialSnapshot() {\n try {\n state.snapshot = await getJson("/api/stats");\n app.drawNow();\n } catch {\n }\n }\n\n // src/dashboard/client/panels.ts\n var paintedSnapshot;\n function drawTiles(force = false) {\n if (!SECTIONS.statistics) return;\n const box = $("tiles");\n const snapshot = state.snapshot;\n if (snapshot === void 0) return;\n if (!force && paintedSnapshot === snapshot) return;\n paintedSnapshot = snapshot;\n const metrics = snapshot.metrics;\n clear(box);\n if (metrics === void 0) {\n box.appendChild(el("div", "note", "Counters are switched off on this handler (metrics: false). The live feed still works."));\n return;\n }\n const actions = metrics.actions;\n const denied = actions.block + actions.drop + actions.redirect;\n const mitigated = actions.challenge + actions["rate-limit"] + actions.delay;\n const served = actions.allow + actions.tag + actions.log;\n const total = metrics.requests;\n const unremarkable = metrics.verdicts.unknown + metrics.verdicts.human;\n const tiles = [\n ["", n(total), "Requests", "since start"],\n ["proven", n(metrics.proven), "Proven bots", `${pct(metrics.proven, total)} of traffic`],\n ["warn", n(metrics.verdicts["suspected-bot"]), "Suspected", "never denied on this alone"],\n ["", n(unremarkable), "Unremarkable", `${pct(unremarkable, total)} of traffic`],\n ["warn", n(metrics.downgrades), "Guard stops", metrics.downgrades > 0 ? "rules asking for more than the evidence" : "no rule overreached"],\n ["crit", n(denied), "Denied", `${pct(denied, total)} of traffic`],\n [\n "",\n n(mitigated),\n "Mitigated",\n metrics.challenges.issued > 0 ? `${n(metrics.challenges.solved)} of ${n(metrics.challenges.issued)} challenges solved` : "challenged, limited or delayed"\n ],\n ["good", n(served), "Served", `${pct(served, total)} of traffic`],\n ["", n(metrics.actorsTracked), "Actors tracked", "in the registry now"]\n ];\n for (const [kind, value, key, sub] of tiles) {\n const tile = el("div", `tile ${kind}`);\n tile.appendChild(el("div", "v tnum", value));\n tile.appendChild(el("div", "k", key));\n tile.appendChild(el("div", "s", sub));\n box.appendChild(tile);\n }\n }\n function drawChips() {\n const snapshot = state.snapshot;\n if (snapshot === void 0) return;\n const box = $("chips");\n clear(box);\n const facts = [\n // Which process this is, first, because everything after it is a fact about this\n // process and nothing else. Behind a load balancer there are as many of these\n // dashboards as there are pods, each showing its own share of the traffic.\n ["instance", snapshot.instance],\n ["guard", snapshot.policy.falsePositivePolicy],\n ["suspect at", String(snapshot.policy.suspectThreshold)]\n ];\n if (SECTIONS.statistics) facts.push(["detectors", String(snapshot.detectors.length)]);\n if (SECTIONS.policy) facts.push(["rules", String(snapshot.rules.length)]);\n facts.push(["uptime", uptime(snapshot.now - snapshot.startedAt)]);\n for (const [label2, value] of facts) {\n const fact = el("span");\n fact.appendChild(document.createTextNode(`${label2} `));\n fact.appendChild(el("b", null, value));\n box.appendChild(fact);\n }\n }\n function updateWindowLabels() {\n const label2 = windowLabel(state.rows.length, oldestAt(), Date.now());\n for (const node of Array.from(document.querySelectorAll(".win"))) node.textContent = label2;\n }\n function drawLivePanels() {\n if (!SECTIONS.statistics) return;\n const totals = aggregate(state.rows);\n drawBars($("live-detectors"), totals.detectors, "Nothing has fired in this window.");\n if (SECTIONS.actors) drawBars($("live-actors"), totals.actors, "No traffic in this window.");\n }\n function drawStatsPanels() {\n const snapshot = state.snapshot;\n if (snapshot === void 0) return;\n const metrics = snapshot.metrics;\n if (metrics !== void 0) {\n drawBars($("stat-verdicts"), pairs(metrics.verdicts), "Nothing assessed yet.");\n drawBars($("stat-actions"), pairs(metrics.actions), "No decisions yet.");\n drawBars($("stat-classes"), pairs(metrics.botClasses), "Nothing classified yet.");\n drawBars($("stat-detectors"), pairs(metrics.detectorFirings), "No detector has produced evidence yet.");\n const challenges = $("stat-challenges");\n clear(challenges);\n if (!snapshot.policy.challengeEnabled) {\n challenges.appendChild(el("div", "note", "No challenge is configured, so a rule asking for one degrades to a tag. Set challenge.secrets to enable it."));\n } else {\n const funnel = [\n ["Issued", n(metrics.challenges.issued)],\n ["Solved", n(metrics.challenges.solved)],\n ["Rejected", n(metrics.challenges.rejected)],\n ["Solve rate", metrics.challenges.issued > 0 ? pct(metrics.challenges.solved, metrics.challenges.issued) : "\u2014"]\n ];\n for (const [key, value] of funnel) challenges.appendChild(statRow(key, value));\n }\n const health = $("stat-health");\n clear(health);\n const bypassed = metrics.bypassed.allowlist + metrics.bypassed["ignored-path"];\n const rows = [\n ["Requests assessed", n(metrics.requests)],\n ["Bypassed \u2014 allowlist", n(metrics.bypassed.allowlist)],\n ["Bypassed \u2014 ignored path", n(metrics.bypassed["ignored-path"])],\n ["Detection ran on", pct(metrics.requests - bypassed, metrics.requests)],\n ["Actors tracked", n(metrics.actorsTracked)],\n ["Guard stops", n(metrics.downgrades)]\n ];\n const failures = pairs(metrics.detectorFailures);\n for (const [detector, count] of failures) rows.push([`Detector failures \u2014 ${detector}`, n(count)]);\n for (const [key, value] of rows) health.appendChild(statRow(key, value));\n if (failures.length === 0) health.appendChild(el("div", "note", "No detector has thrown or timed out."));\n }\n const totals = aggregate(state.rows);\n if (SECTIONS.actors) drawBars($("stat-identities"), totals.identities, "No client has named itself in this window.");\n drawBars($("stat-paths"), totals.paths, "No traffic in this window.");\n drawBars($("stat-denied-paths"), totals.deniedPaths, "Nothing has been denied in this window.");\n drawBars($("stat-guard"), totals.guardStops, "No rule has asked for more than its evidence supports.");\n drawBars($("stat-bypassed"), totals.bypassed, "Nothing bypassed detection.");\n drawRuleHits(totals);\n const list = $("stat-detector-list");\n clear(list);\n $("detector-count").textContent = `${snapshot.detectors.length} installed`;\n for (const detector of snapshot.detectors) {\n const row = el("div", "det");\n const left = el("div");\n left.appendChild(el("div", "mono", detector.id));\n left.appendChild(el("div", "d", detector.description));\n row.appendChild(left);\n const fires = metrics?.detectorFirings[detector.id] ?? 0;\n const timing = metrics?.detectorTimings[detector.id];\n let right = `${n(fires)} \xB7 ${detector.cost} \xB7 ${detector.stage}`;\n if (timing !== void 0 && timing.count > 0) right += ` \xB7 ${ms(timing.totalMs / timing.count)} avg`;\n row.appendChild(el("div", "n", right));\n list.appendChild(row);\n }\n }\n function statRow(key, value) {\n const line = el("div", "stat-row");\n line.appendChild(el("span", "k", key));\n line.appendChild(el("span", "v", value));\n return line;\n }\n function drawRuleHits(totals) {\n const target = $("stat-rule-hits");\n clear(target);\n const rules = state.snapshot?.rules ?? [];\n if (rules.length === 0) {\n target.appendChild(el("div", "note", "No rules configured."));\n return;\n }\n let max = 1;\n for (const rule of rules) max = Math.max(max, totals.ruleHits.get(rule) ?? 0);\n for (const rule of rules) {\n const value = totals.ruleHits.get(rule) ?? 0;\n const bar = el("div", `bar${value === 0 ? " dead" : ""}`);\n const track = el("div", "track");\n if (value > 0) {\n const fill = el("div", "fill");\n fill.style.width = `${Math.max(2, Math.round(value / max * 100))}%`;\n track.appendChild(fill);\n }\n track.appendChild(el("div", "lbl", rule));\n bar.appendChild(track);\n bar.appendChild(el("div", "v tnum", value === 0 ? "never" : n(value)));\n target.appendChild(bar);\n }\n }\n function drawAudit() {\n const snapshot = state.snapshot;\n if (snapshot === void 0) return;\n const body = $("audit-body");\n const checks = $("audit-checks");\n clear(body);\n clear(checks);\n const audit = snapshot.audit;\n if (audit === void 0) {\n $("audit-spans").textContent = "";\n body.appendChild(\n el(\n "div",\n "note",\n "The traffic audit is switched off on this handler (audit: false). It watches the shape of your traffic rather than any one request \u2014 a spike in automation, a collapse in human traffic, a policy suddenly denying far more than usual."\n )\n );\n return;\n }\n const current = audit.window;\n const baseline = audit.baseline;\n $("audit-spans").textContent = `last ${rangeLabel(current.spanMs)} against the ${rangeLabel(baseline.spanMs)} before`;\n const rows = [\n ["Requests", n(current.requests), n(baseline.requests)],\n ["Rate", `${current.rate.toFixed(1)}/min`, `${baseline.rate.toFixed(1)}/min`],\n ["Bot share", `${Math.round(current.botShare * 100)}%`, `${Math.round(baseline.botShare * 100)}%`],\n ["Bots", n(current.bots), n(baseline.bots)],\n ["Humans", n(current.humans), n(baseline.humans)],\n ["Denials", n(current.denials), n(baseline.denials)],\n ["Challenges", n(current.challenges), n(baseline.challenges)],\n ["Guard stops", n(current.downgrades), n(baseline.downgrades)],\n ["Detector failures", n(current.failures), n(baseline.failures)],\n ["Bypassed", n(current.bypassed), n(baseline.bypassed)]\n ];\n for (const [key, now, was] of rows) {\n const line = el("div", "stat-row");\n line.appendChild(el("span", "k", key));\n const values = el("span", "v");\n values.appendChild(el("b", null, now));\n values.appendChild(el("span", "was", ` was ${was}`));\n line.appendChild(values);\n body.appendChild(line);\n }\n if (audit.checks.length === 0) {\n checks.appendChild(el("div", "note", "No checks are installed, so nothing here will ever raise an anomaly."));\n return;\n }\n for (const check of audit.checks) {\n const row = el("div", "det");\n const left = el("div");\n left.appendChild(el("div", "mono", check.id));\n left.appendChild(el("div", "d", check.description));\n row.appendChild(left);\n checks.appendChild(row);\n }\n }\n function drawNoticeBadge() {\n const notices = state.snapshot?.notices ?? [];\n const badge = $("notice-badge");\n badge.hidden = notices.length === 0 || !SECTIONS.notices;\n badge.textContent = String(notices.length);\n }\n function drawNotices() {\n const box = $("stat-notices");\n const notices = state.snapshot?.notices ?? [];\n clear(box);\n $("notice-count").textContent = notices.length > 0 ? `${notices.length} total` : "";\n if (notices.length === 0) {\n box.appendChild(el("div", "note", "Nothing to report: no startup warnings, no detector errors."));\n return;\n }\n for (const notice of notices.slice().reverse().slice(0, 40)) {\n const row = el("div", `notice ${notice.kind}`);\n const when = el("div", "when");\n when.appendChild(el("div", "tag", notice.kind));\n when.appendChild(el("div", null, clockTime(notice.at)));\n row.appendChild(when);\n const body = el("div");\n body.appendChild(el("div", null, notice.message));\n if (notice.source !== void 0) body.appendChild(el("div", "ev-meta", notice.source));\n row.appendChild(body);\n box.appendChild(row);\n }\n }\n function drawChanges() {\n if (!SECTIONS.changes) return;\n const box = $("stat-changes");\n const changes = state.snapshot?.changes ?? [];\n clear(box);\n $("change-count").textContent = changes.length > 0 ? `${changes.length} this run` : "";\n if (changes.length === 0) {\n box.appendChild(el("div", "note", "Nothing has been changed at runtime. Rules, guard and ranges are as the code that built this handler left them."));\n return;\n }\n for (const change of changes.slice().reverse()) {\n const row = el("div", "notice");\n const when = el("div", "when");\n when.appendChild(el("div", "tag", change.kind));\n when.appendChild(el("div", null, clockTime(change.at)));\n row.appendChild(when);\n const body = el("div");\n body.appendChild(el("div", null, change.summary));\n body.appendChild(el("div", "ev-meta", change.by === void 0 || change.by === "" ? "by an unnamed viewer \u2014 this listener\'s auth carries no identity" : `by ${change.by}`));\n row.appendChild(body);\n box.appendChild(row);\n }\n }\n function drawPeers() {\n const box = $("peers");\n if (BOOT.peers.length === 0) {\n box.hidden = true;\n return;\n }\n if (box.childElementCount > 0) return;\n box.appendChild(el("span", "hint", "also:"));\n for (const peer of BOOT.peers) {\n const link = el("a", "linkbtn", peer.label);\n link.href = peer.href;\n link.rel = "noreferrer noopener";\n box.appendChild(link);\n }\n }\n\n // src/dashboard/client/charts.ts\n var BUCKETS = 60;\n var LATENCY_BOUNDS = [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 25, 50, 100];\n var SCORE_BOUNDS = 10;\n function positionTip(tip, host, clientX, boxLeft) {\n tip.style.opacity = "1";\n tip.style.left = `${Math.min(host.clientWidth - 150, Math.max(4, clientX - boxLeft - 60))}px`;\n tip.style.top = "14px";\n }\n function tipRow(label2, value, colour) {\n const line = el("div", "r");\n const left = el("em");\n if (colour !== void 0) {\n const swatch = el("span", "swatch");\n swatch.style.background = colour;\n left.appendChild(swatch);\n }\n left.appendChild(document.createTextNode(label2));\n line.appendChild(left);\n line.appendChild(el("b", null, value));\n return line;\n }\n function timeline() {\n const bucketMs = state.rangeMs / BUCKETS;\n const now = Date.now();\n const start2 = now - state.rangeMs;\n const buckets = [];\n for (let i = 0; i < BUCKETS; i++) buckets.push({ at: start2 + i * bucketMs, served: 0, mitigated: 0, denied: 0, total: 0 });\n for (const { entry } of state.rows) {\n const index = Math.floor((entry.at - start2) / bucketMs);\n if (index < 0 || index >= BUCKETS) continue;\n const bucket = buckets[index];\n if (bucket === void 0) continue;\n bucket.total++;\n const out = outcome(entry);\n if (out === "deny") bucket.denied++;\n else if (out === "mitigate") bucket.mitigated++;\n else bucket.served++;\n }\n return buckets;\n }\n function drawTraffic() {\n const host = $("traffic-chart");\n const svg = $("traffic");\n const width = Math.max(320, host.clientWidth - 30);\n const height = 190;\n const padBottom = 26;\n const markerRow = 8;\n const plot = height - padBottom - markerRow;\n const buckets = timeline();\n const now = Date.now();\n const start2 = now - state.rangeMs;\n let peak = 1;\n for (const bucket of buckets) if (bucket.total > peak) peak = bucket.total;\n const max = Math.max(2, Math.ceil(peak / 2) * 2);\n clear(svg);\n svg.setAttribute("viewBox", `0 0 ${width} ${height}`);\n svg.setAttribute("height", String(height));\n const s1 = css("--s1");\n const s2 = css("--s2");\n const crit = css("--crit");\n const grid = css("--grid");\n const muted = css("--muted");\n const step = width / BUCKETS;\n const barWidth = Math.max(2, step - 2);\n for (const fraction of [0, 0.5, 1]) {\n const y = markerRow + plot - fraction * plot;\n svg.appendChild(svgEl("line", { x1: 0, x2: width, y1: y, y2: y, stroke: grid, "stroke-width": 1 }));\n if (fraction > 0) svg.appendChild(svgText({ x: 2, y: y - 3, fill: muted, "font-size": 10 }, Math.round(max * fraction)));\n }\n buckets.forEach((bucket, index) => {\n const x = index * step + 1;\n const servedHeight = bucket.served / max * plot;\n const mitigatedHeight = bucket.mitigated / max * plot;\n let y = markerRow + plot;\n if (servedHeight > 0) {\n y -= servedHeight;\n svg.appendChild(svgEl("rect", { x, y, width: barWidth, height: servedHeight, fill: s1, rx: 2 }));\n }\n if (mitigatedHeight > 0) {\n y -= mitigatedHeight + (servedHeight > 0 ? 2 : 0);\n svg.appendChild(svgEl("rect", { x, y: Math.max(markerRow, y), width: barWidth, height: mitigatedHeight, fill: s2, rx: 2 }));\n }\n if (bucket.denied > 0) svg.appendChild(svgEl("rect", { x, y: 0, width: barWidth, height: 5, fill: crit, rx: 2 }));\n });\n svg.appendChild(svgEl("line", { x1: 0, x2: width, y1: markerRow + plot, y2: markerRow + plot, stroke: css("--line"), "stroke-width": 1 }));\n const span = rangeLabel(state.rangeMs);\n const labels = [\n [0, `${span} ago`],\n [BUCKETS / 2, rangeLabel(state.rangeMs / 2)],\n [BUCKETS - 1, "now"]\n ];\n for (const [position, text] of labels) {\n svg.appendChild(svgText({ x: Math.min(width - 26, Math.max(0, position * step)), y: height - 8, fill: muted, "font-size": 10 }, text));\n }\n const changes = (state.snapshot?.changes ?? []).filter((change) => change.at >= start2 && change.at <= now);\n const markColour = css("--proven-text");\n for (const change of changes) {\n const x = (change.at - start2) / state.rangeMs * width;\n svg.appendChild(svgEl("line", { x1: x, x2: x, y1: 0, y2: markerRow + plot, stroke: markColour, "stroke-width": 1.5, "stroke-dasharray": "3 2", opacity: 0.85 }));\n const dot = svgEl("circle", { cx: x, cy: 3, r: 3, fill: markColour });\n const title = svgEl("title");\n title.textContent = `${new Date(change.at).toLocaleTimeString()} \xB7 ${change.kind}: ${change.summary}${change.by === void 0 ? "" : ` (by ${change.by})`}`;\n dot.appendChild(title);\n svg.appendChild(dot);\n }\n const hover = svgEl("rect", { x: 0, y: 0, width: 0, height: markerRow + plot, fill: css("--ink"), opacity: 0.06 });\n svg.appendChild(hover);\n const tip = $("traffic-tip");\n host.onmousemove = (event) => {\n const box = svg.getBoundingClientRect();\n const index = Math.floor((event.clientX - box.left) / box.width * BUCKETS);\n const bucket = buckets[index];\n if (bucket === void 0) {\n tip.style.opacity = "0";\n hover.setAttribute("width", "0");\n return;\n }\n hover.setAttribute("x", String(index * step));\n hover.setAttribute("width", String(step));\n clear(tip);\n tip.appendChild(el("div", "t", `${new Date(bucket.at).toLocaleTimeString()} \xB7 ${Math.round(state.rangeMs / BUCKETS / 1e3)}s`));\n tip.appendChild(tipRow("Served", n(bucket.served), s1));\n tip.appendChild(tipRow("Mitigated", n(bucket.mitigated), s2));\n tip.appendChild(tipRow("Denied", n(bucket.denied), crit));\n positionTip(tip, host, event.clientX, box.left);\n };\n host.onmouseleave = () => {\n tip.style.opacity = "0";\n hover.setAttribute("width", "0");\n };\n const total = buckets.reduce((sum, bucket) => sum + bucket.total, 0);\n $("traffic-window").textContent = `last ${rangeLabel(state.rangeMs)}`;\n const served = buckets.reduce((sum, bucket) => sum + bucket.served, 0);\n const mitigated = buckets.reduce((sum, bucket) => sum + bucket.mitigated, 0);\n const denied = buckets.reduce((sum, bucket) => sum + bucket.denied, 0);\n const busiest = buckets.reduce((best, bucket) => bucket.total > best.total ? bucket : best, buckets[0] ?? { at: now, total: 0, served: 0, mitigated: 0, denied: 0 });\n $("traffic-alt").textContent = `Traffic over the last ${rangeLabel(state.rangeMs)}: ${n(total)} requests \u2014 ${n(served)} served, ${n(mitigated)} mitigated, ${n(denied)} denied. ` + (total === 0 ? "No traffic in this range." : `Busiest ${Math.round(state.rangeMs / BUCKETS / 1e3)}-second interval: ${n(busiest.total)} requests at ${new Date(busiest.at).toLocaleTimeString()}.`) + (changes.length === 0 ? "" : ` ${n(changes.length)} runtime change${changes.length === 1 ? "" : "s"} in this range: ${changes.map((change) => `${change.kind}, ${change.summary}`).join("; ")}.`);\n const legend = $("traffic-legend");\n clear(legend);\n legend.appendChild(el("span", null, `${n(total)} requests in the last ${rangeLabel(state.rangeMs)} \xB7`));\n const oldest = oldestAt();\n if (oldest !== void 0 && Date.now() - oldest < state.rangeMs * 0.9) {\n legend.appendChild(el("span", null, `window holds ${rangeLabel(Date.now() - oldest)} \xB7`));\n }\n for (const [label2, colour] of [\n ["Served", s1],\n ["Mitigated \u2014 challenged, limited or delayed", s2],\n ["Denied", crit]\n ]) {\n const item = el("span");\n const swatch = el("span", "swatch");\n swatch.style.background = colour;\n item.appendChild(swatch);\n item.appendChild(document.createTextNode(label2));\n legend.appendChild(item);\n }\n if (changes.length > 0) {\n const item = el("span");\n item.appendChild(el("span", "mark"));\n item.appendChild(document.createTextNode(`${n(changes.length)} runtime change${changes.length === 1 ? "" : "s"} \u2014 hover a marker`));\n legend.appendChild(item);\n }\n }\n function differences(cumulative) {\n const counts = [];\n for (let i = 0; i < cumulative.length; i++) counts.push((cumulative[i] ?? 0) - (i > 0 ? cumulative[i - 1] ?? 0 : 0));\n return counts;\n }\n function drawScores() {\n const host = $("score-chart");\n const svg = $("scores");\n clear(svg);\n const metrics = state.snapshot?.metrics;\n const fromRun = state.scoreScope === "run" && metrics !== void 0;\n let buckets;\n let scored;\n let proven;\n if (fromRun && metrics !== void 0) {\n buckets = differences(metrics.scores.buckets);\n scored = metrics.scores.count;\n proven = metrics.proven;\n } else {\n buckets = new Array(SCORE_BOUNDS).fill(0);\n scored = 0;\n proven = 0;\n for (const { entry } of state.rows) {\n if (entry.bypass !== void 0) continue;\n if (entry.certain) {\n proven++;\n continue;\n }\n const index = Math.min(SCORE_BOUNDS - 1, Math.floor(entry.score / 10));\n buckets[index] = (buckets[index] ?? 0) + 1;\n scored++;\n }\n }\n const width = Math.max(280, host.clientWidth - 30);\n const height = 190;\n const padBottom = 30;\n const plot = height - padBottom;\n let max = 1;\n for (const value of buckets) if (value > max) max = value;\n svg.setAttribute("viewBox", `0 0 ${width} ${height}`);\n svg.setAttribute("height", String(height));\n const fill = css("--s1");\n const muted = css("--muted");\n const grid = css("--grid");\n const crit = css("--crit");\n svg.appendChild(svgEl("line", { x1: 0, x2: width, y1: plot, y2: plot, stroke: grid, "stroke-width": 1 }));\n const step = width / SCORE_BOUNDS;\n buckets.forEach((value, index) => {\n const barHeight = value / max * (plot - 8);\n const barWidth = Math.max(2, Math.min(step - 6, 56));\n if (barHeight > 0) {\n svg.appendChild(svgEl("rect", { x: index * step + (step - barWidth) / 2, y: plot - barHeight, width: barWidth, height: barHeight, fill, rx: 3 }));\n }\n svg.appendChild(svgText({ x: index * step + 1, y: height - 14, fill: muted, "font-size": 9.5 }, index * 10));\n });\n const threshold = state.snapshot?.policy.suspectThreshold ?? 60;\n const x = threshold / 100 * width;\n svg.appendChild(svgEl("line", { x1: x, x2: x, y1: 0, y2: plot, stroke: crit, "stroke-width": 2, "stroke-dasharray": "4 3" }));\n svg.appendChild(svgText({ x: Math.min(width - 92, x + 5), y: 11, fill: crit, "font-size": 10 }, `suspect at ${threshold}`));\n svg.appendChild(svgText({ x: 0, y: height - 2, fill: muted, "font-size": 10 }, "score (probabilistic requests only)"));\n const tip = $("score-tip");\n host.onmousemove = (event) => {\n const box = svg.getBoundingClientRect();\n const index = Math.floor((event.clientX - box.left) / box.width * SCORE_BOUNDS);\n const value = buckets[index];\n if (value === void 0) {\n tip.style.opacity = "0";\n return;\n }\n clear(tip);\n tip.appendChild(el("div", "t", `score ${index * 10}\u2013${index * 10 + 9}`));\n tip.appendChild(tipRow("requests", n(value)));\n tip.appendChild(tipRow("share", pct(value, scored)));\n positionTip(tip, host, event.clientX, box.left);\n };\n host.onmouseleave = () => {\n tip.style.opacity = "0";\n };\n let over = 0;\n for (let bucket = 0; bucket < SCORE_BOUNDS; bucket++) if (bucket * 10 >= threshold) over += buckets[bucket] ?? 0;\n const legend = $("score-legend");\n clear(legend);\n legend.appendChild(el("span", null, `${n(scored)} scored \xB7 ${n(over)} at or over the threshold \xB7 ${n(proven)} proven, which carry no score`));\n $("score-alt").textContent = `Distribution of probabilistic scores ${fromRun ? "since start" : "in the retained window"}, with the suspect threshold at ${threshold}. ${n(scored)} scored requests, ${n(over)} at or over the threshold, ${n(proven)} proven and therefore unscored. ` + (scored === 0 ? "Nothing scored yet." : `By ten-point band: ${buckets.map((value, index) => `${index * 10}\u2013${index * 10 + 9}: ${n(value)}`).join(", ")}.`);\n $("score-window").textContent = fromRun ? "since start" : windowLabel(state.rows.length, oldestAt(), Date.now());\n if (state.scoreScope === "run" && metrics === void 0) {\n legend.appendChild(el("span", null, "\xB7 counters are off on this handler, so this is the retained window"));\n }\n }\n function drawLatency() {\n const host = $("latency-chart");\n const svg = $("latency");\n clear(svg);\n const metrics = state.snapshot?.metrics;\n if (metrics === void 0 || metrics.duration.count === 0) {\n $("latency-summary").textContent = "No assessments yet.";\n $("latency-alt").textContent = "Assessment latency: no assessments yet.";\n svg.setAttribute("viewBox", "0 0 100 40");\n svg.setAttribute("height", "40");\n svg.appendChild(svgText({ x: 0, y: 20, fill: css("--muted"), "font-size": 11 }, "No assessments yet."));\n return;\n }\n const cumulative = metrics.duration.buckets;\n const counts = differences(cumulative);\n const width = Math.max(280, host.clientWidth - 30);\n const height = 190;\n const padBottom = 30;\n const plot = height - padBottom;\n let max = 1;\n for (const value of counts) if (value > max) max = value;\n svg.setAttribute("viewBox", `0 0 ${width} ${height}`);\n svg.setAttribute("height", String(height));\n const fill = css("--s1");\n const muted = css("--muted");\n const grid = css("--grid");\n svg.appendChild(svgEl("line", { x1: 0, x2: width, y1: plot, y2: plot, stroke: grid, "stroke-width": 1 }));\n const step = width / counts.length;\n counts.forEach((value, index) => {\n const barHeight = value / max * (plot - 6);\n if (barHeight > 0) {\n svg.appendChild(svgEl("rect", { x: index * step + 1, y: plot - barHeight, width: Math.max(2, step - 3), height: barHeight, fill, rx: 3 }));\n }\n if (index % 2 === 0) {\n svg.appendChild(svgText({ x: index * step + 1, y: height - 14, fill: muted, "font-size": 9.5 }, index < LATENCY_BOUNDS.length ? String(LATENCY_BOUNDS[index]) : "more"));\n }\n });\n svg.appendChild(svgText({ x: 0, y: height - 2, fill: muted, "font-size": 10 }, "milliseconds (upper bound of each bucket)"));\n const tip = $("latency-tip");\n host.onmousemove = (event) => {\n const box = svg.getBoundingClientRect();\n const index = Math.floor((event.clientX - box.left) / box.width * counts.length);\n const value = counts[index];\n if (value === void 0) {\n tip.style.opacity = "0";\n return;\n }\n clear(tip);\n tip.appendChild(el("div", "t", index < LATENCY_BOUNDS.length ? `\u2264 ${LATENCY_BOUNDS[index]}ms` : "over 100ms"));\n tip.appendChild(tipRow("requests", n(value)));\n tip.appendChild(tipRow("share", pct(value, metrics.duration.count)));\n positionTip(tip, host, event.clientX, box.left);\n };\n host.onmouseleave = () => {\n tip.style.opacity = "0";\n };\n const mean = metrics.duration.totalMs / metrics.duration.count;\n const p95 = percentile(cumulative, metrics.duration.count, 0.95);\n $("latency-summary").textContent = `Time spent in detection, per request \xB7 mean ${ms(mean)} \xB7 p95 ${ms(p95)} \xB7 max ${ms(metrics.duration.maxMs)}`;\n $("latency-alt").textContent = `Assessment latency since start over ${n(metrics.duration.count)} requests: mean ${ms(mean)}, 95th percentile ${ms(p95)}, maximum ${ms(metrics.duration.maxMs)}. By bucket: ${counts.map((value, index) => `${index < LATENCY_BOUNDS.length ? `up to ${LATENCY_BOUNDS[index]}ms` : "over 100ms"}: ${n(value)}`).join(", ")}.`;\n }\n function percentile(cumulative, count, fraction) {\n const target = count * fraction;\n const last = LATENCY_BOUNDS[LATENCY_BOUNDS.length - 1] ?? 100;\n for (let i = 0; i < cumulative.length; i++) {\n if ((cumulative[i] ?? 0) >= target) return i < LATENCY_BOUNDS.length ? LATENCY_BOUNDS[i] ?? last : last;\n }\n return last;\n }\n\n // src/dashboard/client/registry.ts\n var timer;\n async function loadActors() {\n if (!SECTIONS.registry) return;\n try {\n const body = await getJson("/api/actors?limit=100");\n state.actors = body.actors;\n state.actorsTracked = body.tracked;\n drawActors();\n } catch {\n }\n }\n function trackActors() {\n if (timer !== void 0) clearInterval(timer);\n timer = void 0;\n if (state.tab !== "actors") return;\n void loadActors();\n timer = setInterval(() => {\n if (state.tab !== "actors" || state.paused || isConfirming()) return;\n void loadActors();\n }, 4e3);\n }\n function drawActors() {\n if (!SECTIONS.registry) return;\n if (isConfirming()) return;\n const body = byId("actor-rows");\n clear(body);\n const actors = state.actors;\n $("actors-count").textContent = `${n(actors.length)} shown \xB7 ${n(state.actorsTracked)} tracked`;\n byId("actors-empty").hidden = actors.length > 0;\n for (const actor of actors) {\n const row = el("tr");\n row.appendChild(el("td", "who", actor.key));\n row.appendChild(el("td", "num tnum", n(actor.requests)));\n row.appendChild(el("td", "num tnum", n(actor.recentRate)));\n row.appendChild(el("td", "num tnum", n(actor.distinctPaths)));\n const cadence = el("td", "num tnum", actor.cadenceCv === void 0 ? "\u2014" : actor.cadenceCv.toFixed(2));\n if (actor.cadenceCv !== void 0 && actor.cadenceCv < 0.15) cadence.className += " warn-text";\n row.appendChild(cadence);\n row.appendChild(el("td", "num tnum", n(actor.priorConfirmations)));\n const unsolved = el("td", "num tnum", n(actor.unsolvedChallenges));\n if (actor.unsolvedChallenges >= 3) unsolved.className += " warn-text";\n row.appendChild(unsolved);\n const stateCell = el("td");\n const tags = [];\n if (actor.cleared) tags.push("cleared as human");\n if (actor.distinctUserAgents > 1) tags.push(`${actor.distinctUserAgents} User-Agents`);\n tags.push(`first seen ${clockTime(actor.firstSeen)}`);\n stateCell.appendChild(el("div", "tagline", tags.join(" \xB7 ")));\n row.appendChild(stateCell);\n const actions = el("td", "acts");\n const inFeed = el("button", null, "In feed");\n inFeed.title = "Show this actor\'s requests in the live feed";\n inFeed.addEventListener("click", () => {\n setSearch(`actor:${actor.key}`);\n const search = byId("search");\n search.value = state.search;\n app.showTab("live");\n app.syncUrl();\n });\n actions.appendChild(inFeed);\n for (const button of actorActions(actor.key, () => void loadActors())) actions.appendChild(button);\n row.appendChild(actions);\n body.appendChild(row);\n }\n }\n\n // src/dashboard/client/tester.ts\n function initTester() {\n if (!SECTIONS.tester) return;\n $("test-run").addEventListener("click", () => void run());\n byId("test-input").addEventListener("keydown", (event) => {\n if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {\n event.preventDefault();\n void run();\n }\n });\n }\n async function run() {\n const raw2 = byId("test-input").value;\n if (raw2.trim() === "") {\n toast("warn", "Nothing to assess", "Paste a User-Agent, a curl command, or a header block.");\n return;\n }\n const result = await postJson("/api/test", {\n raw: raw2,\n ip: byId("test-ip").value.trim(),\n url: byId("test-url").value.trim()\n });\n const box = $("test-result");\n box.hidden = false;\n clear(box);\n if (!result.ok) {\n box.className = "result bad";\n box.appendChild(el("div", null, result.error ?? "The server could not read that."));\n return;\n }\n const { entry, reason, assumed } = result.data;\n const out = outcome(entry);\n box.className = `result ${out === "deny" ? "bad" : out === "mitigate" ? "warn" : "ok"}`;\n const head = el("div");\n const [badgeClass, badgeLabel] = verdictBadge(entry);\n head.appendChild(el("span", `badge ${badgeClass}`, badgeLabel));\n head.appendChild(document.createTextNode(" "));\n head.appendChild(el("b", null, entry.action ?? "no decision"));\n if (entry.rule !== void 0) head.appendChild(el("span", "ev-meta", ` via ${entry.rule}`));\n box.appendChild(head);\n box.appendChild(el("div", "ev-meta", `${entry.certain ? "proven" : `score ${entry.score}`} \xB7 assessed in ${ms(entry.durationMs)}`));\n if (entry.downgradedFrom !== void 0) {\n box.appendChild(el("div", "guard", `The guard stopped ${entry.downgradedFrom} here.`));\n if (entry.downgradeReason !== void 0) box.appendChild(el("div", "basis", entry.downgradeReason));\n }\n if (entry.evidence.length === 0) {\n box.appendChild(el("div", "ev-meta", entry.bypass !== void 0 ? `Detection was skipped: ${entry.bypass}.` : "No detector produced any evidence."));\n } else {\n const list = el("div", "ev");\n for (const item of entry.evidence) {\n const row = el("div", `ev-item${item.direction === "human" ? " human" : ""}`);\n row.appendChild(el("div", `tier t-${item.certainty}`, item.certainty));\n const detail = el("div");\n detail.appendChild(el("div", null, item.summary));\n detail.appendChild(el("div", "ev-meta", `${item.detector} \xB7 points to ${item.direction}`));\n if (item.deterministicBasis !== void 0) detail.appendChild(el("div", "basis", item.deterministicBasis));\n row.appendChild(detail);\n list.appendChild(row);\n }\n box.appendChild(list);\n }\n box.appendChild(el("div", "ev-meta", reason));\n const notes = [...assumed, "no history: this is assessed as a first request, so cadence and crawl breadth have nothing to read"];\n box.appendChild(el("div", "assumed", `Assumed \u2014 ${notes.join("; ")}.`));\n }\n\n // src/dashboard/client/index.ts\n var TABS = [\n ["tab-live", "live", SECTIONS.feed],\n ["tab-actors", "actors", SECTIONS.registry],\n ["tab-stats", "stats", SECTIONS.statistics],\n ["tab-policy", "policy", SECTIONS.policy]\n ];\n var available = TABS.filter(([, , enabled]) => enabled);\n var FIRST = available[0]?.[1] ?? "live";\n function tabIndexOf(name) {\n const at = available.findIndex(([, tab]) => tab === name);\n return at === -1 ? 0 : at;\n }\n function isTab(value) {\n return available.some(([, name]) => name === value);\n }\n function initHeader() {\n const box = $("links");\n for (const link of BOOT.links) {\n const anchor = el("a", "linkbtn", link.label);\n anchor.href = link.href;\n anchor.rel = "noreferrer noopener";\n box.appendChild(anchor);\n }\n let stored = null;\n try {\n stored = localStorage.getItem("bothandler-dashboard-theme");\n } catch {\n stored = null;\n }\n if (stored === "dark" || stored === "light") document.documentElement.setAttribute("data-theme", stored);\n $("theme").addEventListener("click", () => {\n let current = document.documentElement.getAttribute("data-theme");\n if (current === null) current = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";\n const next = current === "dark" ? "light" : "dark";\n document.documentElement.setAttribute("data-theme", next);\n try {\n localStorage.setItem("bothandler-dashboard-theme", next);\n } catch {\n }\n drawNow();\n });\n const pause = byId("pause");\n pause.hidden = !SECTIONS.feed;\n pause.addEventListener("click", () => {\n state.paused = !state.paused;\n pause.setAttribute("aria-pressed", String(state.paused));\n pause.textContent = state.paused ? `Resume${state.bufferedWhilePaused > 0 ? ` (${state.bufferedWhilePaused})` : ""}` : "Pause";\n if (!state.paused) {\n state.bufferedWhilePaused = 0;\n drawNow();\n }\n });\n if (BOOT.allowReset) {\n const reset = byId("reset");\n reset.hidden = false;\n reset.addEventListener("click", () => {\n reset.disabled = true;\n void fetch(`${API}/api/reset`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }).then(async (response) => {\n if (!response.ok) {\n const body = await response.json().catch(() => ({}));\n toast("bad", "Reset refused", body.error ?? String(response.status));\n return;\n }\n clearFeed();\n resetFeedCache();\n drawNow();\n }).catch(() => {\n }).finally(() => {\n reset.disabled = false;\n });\n });\n }\n const header = document.querySelector("header");\n if (header !== null) {\n const apply = () => {\n document.documentElement.style.setProperty("--header-h", `${header.getBoundingClientRect().height}px`);\n };\n apply();\n if (typeof ResizeObserver === "function") new ResizeObserver(apply).observe(header);\n else addEventListener("resize", apply);\n }\n }\n function syncUrl(replace = true) {\n const params = new URLSearchParams();\n if (state.filter !== "all") params.set("f", state.filter);\n if (state.search !== "") params.set("q", state.search);\n const query = params.toString();\n const hash = `#${state.tab}${query === "" ? "" : `?${query}`}`;\n if (location.hash === hash) return;\n history[replace ? "replaceState" : "pushState"]({ tab: state.tab }, "", hash);\n }\n function readUrl() {\n const raw2 = location.hash.slice(1);\n const split = raw2.indexOf("?");\n const name = split === -1 ? raw2 : raw2.slice(0, split);\n const params = new URLSearchParams(split === -1 ? "" : raw2.slice(split + 1));\n const filter = params.get("f") ?? "all";\n return {\n tab: isTab(name) ? name : FIRST,\n filter,\n search: params.get("q") ?? ""\n };\n }\n function showTab(name, options = {}) {\n const target = isTab(name) ? name : FIRST;\n state.tab = target;\n for (const [id, tab, enabled] of TABS) {\n const selected = tab === target;\n const node = $(id);\n node.hidden = !enabled;\n node.setAttribute("aria-selected", String(selected));\n node.tabIndex = selected ? 0 : -1;\n $(`view-${tab}`).hidden = !selected || !enabled;\n }\n if (options.focus === true) $(available[tabIndexOf(target)]?.[0] ?? "tab-live").focus();\n if (target === "policy" && state.policy === void 0) {\n void loadPolicy();\n void loadRanges();\n }\n trackActors();\n if (options.push !== false) syncUrl(options.replace !== false);\n drawNow();\n }\n function initTabs() {\n available.forEach(([id, name], index) => {\n const tab = $(id);\n tab.addEventListener("click", () => showTab(name, { replace: false }));\n tab.addEventListener("keydown", (event) => {\n let next = -1;\n if (event.key === "ArrowRight") next = (index + 1) % available.length;\n else if (event.key === "ArrowLeft") next = (index - 1 + available.length) % available.length;\n else if (event.key === "Home") next = 0;\n else if (event.key === "End") next = available.length - 1;\n if (next === -1) return;\n event.preventDefault();\n showTab(available[next]?.[1] ?? FIRST, { focus: true, replace: false });\n });\n });\n addEventListener("popstate", () => {\n const url = readUrl();\n state.filter = url.filter;\n setSearch(url.search);\n reflectFilterButtons();\n showTab(url.tab, { push: false });\n });\n }\n function initKeyboard() {\n addEventListener("keydown", (event) => {\n const target = event.target;\n const typing = target !== null && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT");\n if (event.key === "Escape") {\n if (typing && target?.id === "search" && target.value !== "") {\n target.value = "";\n setSearch("");\n syncUrl();\n drawNow();\n return;\n }\n if (typing) {\n target?.blur();\n return;\n }\n if (state.actor !== void 0) {\n state.actor = void 0;\n drawNow();\n return;\n }\n if (state.open.size > 0) {\n state.open.clear();\n drawNow();\n }\n return;\n }\n if (typing || event.metaKey || event.ctrlKey || event.altKey) return;\n if (event.key === "/" && SECTIONS.feed) {\n event.preventDefault();\n showTab("live");\n const search = byId("search");\n search.focus();\n search.select();\n return;\n }\n const digit = ["1", "2", "3", "4"].indexOf(event.key);\n if (digit !== -1 && digit < available.length) {\n event.preventDefault();\n showTab(available[digit]?.[1] ?? FIRST, { focus: true, replace: false });\n }\n });\n }\n function initRanges() {\n const ranges = [\n ["1m", 6e4],\n ["5m", 3e5],\n ["15m", 9e5],\n ["1h", 36e5]\n ];\n const host = $("traffic-range");\n for (const [label2, value] of ranges) {\n const button = el("button", null, label2);\n button.setAttribute("aria-pressed", String(value === state.rangeMs));\n button.addEventListener("click", () => {\n state.rangeMs = value;\n for (const other of Array.from(host.children)) other.setAttribute("aria-pressed", "false");\n button.setAttribute("aria-pressed", "true");\n drawTraffic();\n });\n host.appendChild(button);\n }\n const scopes = [\n ["since start", "run"],\n ["this window", "window"]\n ];\n const scopeHost = $("score-scope");\n for (const [label2, value] of scopes) {\n const button = el("button", null, label2);\n button.setAttribute("aria-pressed", String(value === state.scoreScope));\n button.addEventListener("click", () => {\n state.scoreScope = value;\n for (const other of Array.from(scopeHost.children)) other.setAttribute("aria-pressed", "false");\n button.setAttribute("aria-pressed", "true");\n drawScores();\n });\n scopeHost.appendChild(button);\n }\n }\n var pending = false;\n function schedule() {\n if (state.paused || pending) return;\n pending = true;\n requestAnimationFrame(() => {\n pending = false;\n draw();\n });\n }\n function draw() {\n drawChips();\n drawTiles();\n drawNoticeBadge();\n updateWindowLabels();\n if (state.tab === "live") {\n drawActor();\n drawFeed();\n drawLivePanels();\n } else if (state.tab === "actors") {\n drawActors();\n } else if (state.tab === "stats") {\n drawTraffic();\n drawLatency();\n drawScores();\n drawStatsPanels();\n if (SECTIONS.audit) drawAudit();\n } else {\n drawPolicyTab();\n if (SECTIONS.notices) drawNotices();\n if (SECTIONS.changes) drawChanges();\n }\n }\n function drawNow() {\n draw();\n }\n function applySections() {\n const gated = [\n ["tiles", SECTIONS.statistics],\n ["tester-panel", SECTIONS.tester],\n ["ranges-panel", SECTIONS.ranges],\n ["changes-panel", SECTIONS.changes],\n ["actor-panel", SECTIONS.actors],\n ["live-actors-panel", SECTIONS.actors],\n ["audit-panel", SECTIONS.audit],\n ["audit-checks-panel", SECTIONS.audit],\n ["notices-panel", SECTIONS.notices],\n ["guard-panel", SECTIONS.guard],\n ["robots-panel", SECTIONS.robots],\n ["identities-panel", SECTIONS.actors],\n ["evidence-legend", SECTIONS.evidence]\n ];\n for (const [id, enabled] of gated) {\n const node = document.getElementById(id);\n if (node !== null && !enabled) node.remove();\n }\n }\n function start() {\n app.draw = schedule;\n app.drawNow = drawNow;\n app.showTab = showTab;\n app.syncUrl = () => syncUrl();\n applySections();\n initHeader();\n drawPeers();\n initTabs();\n initKeyboard();\n initRanges();\n if (SECTIONS.feed) initFeed();\n initActor();\n initTester();\n initPolicy();\n let resizeTimer;\n addEventListener("resize", () => {\n if (resizeTimer !== void 0) clearTimeout(resizeTimer);\n resizeTimer = setTimeout(() => {\n if (state.tab === "stats") {\n drawTraffic();\n drawLatency();\n drawScores();\n }\n }, 120);\n });\n const url = readUrl();\n state.filter = url.filter;\n setSearch(url.search);\n if (SECTIONS.feed) reflectFilterButtons();\n showTab(url.tab, { replace: true });\n void loadInitialSnapshot();\n connectStream();\n }\n start();\n})();\n';
|
|
6544
|
+
CLIENT_SCRIPT = '"use strict";\n(() => {\n // src/dashboard/client/css.ts\n function cssEscape(value) {\n return typeof CSS !== "undefined" && typeof CSS.escape === "function" ? CSS.escape(value) : String(value).replace(/[^\\w-]/g, "\\\\$&");\n }\n\n // src/dashboard/client/dom.ts\n function el(tag, className, value) {\n const node = document.createElement(tag);\n if (className !== void 0 && className !== null && className !== "") node.className = className;\n if (value !== void 0 && value !== null) node.textContent = String(value);\n return node;\n }\n function svgEl(name, attributes = {}) {\n const node = document.createElementNS("http://www.w3.org/2000/svg", name);\n for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, String(value));\n return node;\n }\n function svgText(attributes, text) {\n const node = svgEl("text", attributes);\n node.textContent = String(text);\n return node;\n }\n function clear(node) {\n while (node.firstChild) node.removeChild(node.firstChild);\n }\n var root = typeof document === "undefined" ? void 0 : document;\n var themeHost = typeof document === "undefined" ? void 0 : document.documentElement;\n var embedded = false;\n function isEmbedded() {\n return embedded;\n }\n function eventTarget() {\n return embedded ? root : globalThis;\n }\n function rootNode() {\n return root;\n }\n function themeElement() {\n return themeHost;\n }\n function $(id) {\n const node = root.querySelector(`#${cssEscape(id)}`);\n if (node === null || node === void 0) throw new Error(`dashboard: no element #${id}`);\n return node;\n }\n function byId(id) {\n return $(id);\n }\n function css(name) {\n return getComputedStyle(themeHost).getPropertyValue(name).trim();\n }\n var sequence = 0;\n function label(text, control) {\n const node = document.createElement("label");\n node.textContent = text;\n const single = Array.isArray(control) ? control.length === 1 ? control[0] : void 0 : control;\n if (single !== void 0 && /^(input|select|textarea)$/i.test(single.tagName)) {\n if (single.id === "") single.id = `field-${++sequence}`;\n node.htmlFor = single.id;\n } else {\n const group = Array.isArray(control) ? control : [control];\n for (const node_ of group) if (!node_.hasAttribute("aria-label")) node_.setAttribute("aria-label", text);\n }\n return node;\n }\n\n // src/dashboard/client/boot.ts\n var raw = globalThis.__BOOTSTRAP__;\n var BOOT = raw ?? {\n base: "",\n title: "bothandlerjs",\n allowReset: false,\n allowEdit: false,\n allowGuardEdit: false,\n allowActing: false,\n peers: [],\n sections: { feed: true, evidence: true, actors: true, registry: true, tester: true, statistics: true, audit: true, notices: true, changes: true, policy: true, guard: true, robots: true, ranges: true },\n links: []\n };\n var API = BOOT.base;\n var SECTIONS = BOOT.sections;\n\n // src/dashboard/client/app.ts\n var app = {\n draw: () => {\n },\n drawNow: () => {\n },\n showTab: () => {\n },\n syncUrl: () => {\n }\n };\n function toast(kind, title, detail = "") {\n const node = el("div", `toast ${kind}`);\n node.appendChild(el("b", null, title));\n if (detail !== "") node.appendChild(el("span", null, detail));\n const host = rootNode().querySelector("#toasts");\n if (host === null) return;\n host.appendChild(node);\n setTimeout(() => node.remove(), 6e3);\n }\n function download(text, filename, type) {\n const blob = new Blob([text], { type });\n const url = URL.createObjectURL(blob);\n const anchor = el("a");\n anchor.href = url;\n anchor.download = filename;\n anchor.click();\n setTimeout(() => URL.revokeObjectURL(url), 1e3);\n }\n function today() {\n return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);\n }\n\n // src/dashboard/client/outcome.ts\n var DENY = /* @__PURE__ */ new Set(["block", "drop", "redirect"]);\n var MITIGATE = /* @__PURE__ */ new Set(["challenge", "rate-limit", "delay"]);\n function outcome(entry) {\n return actionKind(entry.action);\n }\n function actionKind(action) {\n if (action === void 0) return "pending";\n if (DENY.has(action)) return "deny";\n if (MITIGATE.has(action)) return "mitigate";\n return "allow";\n }\n function verdictBadge(entry) {\n if (entry.verdict === "verified-bot" || entry.verdict === "confirmed-bot") return ["b-proven", entry.verdict];\n if (entry.verdict === "suspected-bot") return ["b-suspected", "suspected-bot"];\n if (entry.verdict === "human") return ["b-human", "human"];\n return ["b-unknown", entry.bypass !== void 0 ? `skipped \xB7 ${entry.bypass}` : "unknown"];\n }\n function provenBots(verdicts) {\n return (verdicts["confirmed-bot"] ?? 0) + (verdicts["verified-bot"] ?? 0);\n }\n\n // src/dashboard/client/query.ts\n var FIELDS = {\n path: "path",\n url: "path",\n actor: "actor",\n ip: "actor",\n ua: "userAgent",\n useragent: "userAgent",\n agent: "userAgent",\n verdict: "verdict",\n action: "action",\n rule: "rule",\n detector: "detector",\n identity: "identity",\n method: "method",\n class: "botClass",\n botclass: "botClass",\n id: "requestId",\n request: "requestId",\n bypass: "bypass",\n score: "score",\n outcome: "outcome",\n certain: "certain"\n };\n var NUMERIC = /* @__PURE__ */ new Set(["score"]);\n function tokenize(input) {\n const tokens = [];\n let current = "";\n let quoted = false;\n for (const character of input) {\n if (character === \'"\') {\n quoted = !quoted;\n continue;\n }\n if (!quoted && /\\s/.test(character)) {\n if (current !== "") tokens.push(current);\n current = "";\n continue;\n }\n current += character;\n }\n if (current !== "") tokens.push(current);\n return tokens;\n }\n function parseQuery(input) {\n const terms = [];\n for (const token of tokenize(input.trim())) {\n const negated = token.startsWith("-") || token.startsWith("!");\n const body = negated ? token.slice(1) : token;\n if (body === "") continue;\n const colon = body.indexOf(":");\n const name = colon === -1 ? "" : body.slice(0, colon).toLowerCase();\n const field2 = FIELDS[name];\n if (colon === -1 || field2 === void 0) {\n terms.push({ field: void 0, value: body.toLowerCase(), negated });\n continue;\n }\n let value = body.slice(colon + 1).toLowerCase();\n let compare;\n if (NUMERIC.has(field2)) {\n compare = value.startsWith(">") ? ">" : value.startsWith("<") ? "<" : "=";\n if (compare !== "=") value = value.slice(1);\n }\n if (value === "") continue;\n terms.push({ field: field2, value, negated, compare });\n }\n return terms;\n }\n function searchableText(entry) {\n const parts = [\n entry.method,\n entry.path,\n entry.actor,\n entry.userAgent,\n entry.verdict,\n entry.botClass,\n entry.identity ?? "",\n entry.action ?? "",\n entry.rule ?? "",\n entry.requestId\n ];\n for (const item of entry.evidence) parts.push(item.detector, item.summary);\n return parts.join(" ").toLowerCase();\n }\n function fieldValue(entry, field2) {\n switch (field2) {\n case "path":\n return entry.path;\n case "actor":\n return entry.actor;\n case "userAgent":\n return entry.userAgent;\n case "verdict":\n return entry.verdict;\n case "action":\n return entry.action ?? "";\n case "rule":\n return entry.rule ?? "";\n case "identity":\n return entry.identity ?? "";\n case "method":\n return entry.method;\n case "botClass":\n return entry.botClass;\n case "requestId":\n return entry.requestId;\n case "bypass":\n return entry.bypass ?? "";\n case "outcome":\n return outcome(entry);\n case "certain":\n return String(entry.certain);\n case "detector":\n return entry.evidence.map((item) => item.detector).join(" ");\n default:\n return "";\n }\n }\n function matchesTerm(term, entry, haystack) {\n if (term.field === void 0) return haystack.includes(term.value);\n if (term.field === "score") {\n const wanted = Number(term.value);\n if (Number.isNaN(wanted)) return false;\n if (term.compare === ">") return entry.score > wanted;\n if (term.compare === "<") return entry.score < wanted;\n return entry.score === wanted;\n }\n return fieldValue(entry, term.field).toLowerCase().includes(term.value);\n }\n function matchesQuery(terms, entry, haystack) {\n for (const term of terms) {\n if (matchesTerm(term, entry, haystack) === term.negated) return false;\n }\n return true;\n }\n function matchesFilter(filter, entry) {\n switch (filter) {\n case "proven":\n return entry.certain;\n case "suspected":\n return entry.verdict === "suspected-bot";\n case "human":\n return entry.verdict === "human";\n case "guard":\n return entry.downgradedFrom !== void 0;\n case "deny":\n return outcome(entry) === "deny";\n case "mitigate":\n return outcome(entry) === "mitigate";\n case "allow":\n return outcome(entry) === "allow";\n default:\n return true;\n }\n }\n\n // src/dashboard/client/store.ts\n var MAX_ROWS = 1e3;\n var state = {\n rows: [],\n byId: /* @__PURE__ */ new Map(),\n snapshot: void 0,\n policy: void 0,\n actors: [],\n actorsTracked: 0,\n paused: false,\n filter: "all",\n search: "",\n terms: [],\n tab: "live",\n open: /* @__PURE__ */ new Set(),\n actor: void 0,\n rangeMs: 3e5,\n scoreScope: "run",\n editorRules: [],\n editorDirty: false,\n editorMode: "gui",\n guardDirty: false,\n bufferedWhilePaused: 0,\n laggedDrops: 0,\n feedPage: 0,\n feedFrozen: void 0,\n actorsPage: 0,\n feedPageSize: 50,\n actorsPageSize: 25,\n caughtUp: 0\n };\n function ingest(entry) {\n const existing = state.byId.get(entry.requestId);\n if (existing !== void 0) {\n existing.entry = entry;\n existing.rev++;\n existing.text = void 0;\n return;\n }\n const row = { entry, rev: 0 };\n state.byId.set(entry.requestId, row);\n state.rows.push(row);\n if (state.rows.length > MAX_ROWS) {\n for (const dropped of state.rows.splice(0, state.rows.length - MAX_ROWS)) {\n state.byId.delete(dropped.entry.requestId);\n state.open.delete(dropped.entry.requestId);\n }\n }\n }\n function clearFeed() {\n state.rows = [];\n state.byId = /* @__PURE__ */ new Map();\n state.open.clear();\n state.actor = void 0;\n state.bufferedWhilePaused = 0;\n state.laggedDrops = 0;\n resetPaging();\n }\n function setSearch(value) {\n state.search = value;\n state.terms = parseQuery(value);\n resetPaging();\n }\n function resetPaging() {\n state.feedPage = 0;\n state.feedFrozen = void 0;\n }\n function textOf(row) {\n if (row.text === void 0) row.text = searchableText(row.entry);\n return row.text;\n }\n function sortRows() {\n state.rows.sort((a, b) => a.entry.at - b.entry.at);\n }\n function matches(row) {\n return matchesFilter(state.filter, row.entry) && matchesQuery(state.terms, row.entry, textOf(row));\n }\n function matchingRows(limit = Number.POSITIVE_INFINITY) {\n const shown = [];\n for (let i = state.rows.length - 1; i >= 0 && shown.length < limit; i--) {\n const row = state.rows[i];\n if (row !== void 0 && matches(row)) shown.push(row);\n }\n return shown;\n }\n function feedPage(size) {\n const all = state.feedPage === 0 || state.feedFrozen === void 0 ? matchingRows() : state.feedFrozen;\n const pages = Math.max(1, Math.ceil(all.length / size));\n const page = Math.min(Math.max(0, state.feedPage), pages - 1);\n if (page !== state.feedPage) state.feedPage = page;\n return { rows: all.slice(page * size, page * size + size), page, pages, total: all.length };\n }\n function goToFeedPage(page) {\n const next = Math.max(0, page);\n if (next === 0) {\n state.feedFrozen = void 0;\n } else if (state.feedFrozen === void 0) {\n state.feedFrozen = matchingRows();\n }\n state.feedPage = next;\n }\n function matchingCount() {\n let count = 0;\n for (const row of state.rows) if (matches(row)) count++;\n return count;\n }\n function oldestAt() {\n return state.rows[0]?.entry.at;\n }\n function bump(counter, key) {\n counter.set(key, (counter.get(key) ?? 0) + 1);\n }\n function aggregate(rows) {\n const totals = {\n detectors: /* @__PURE__ */ new Map(),\n actors: /* @__PURE__ */ new Map(),\n identities: /* @__PURE__ */ new Map(),\n paths: /* @__PURE__ */ new Map(),\n deniedPaths: /* @__PURE__ */ new Map(),\n guardStops: /* @__PURE__ */ new Map(),\n ruleHits: /* @__PURE__ */ new Map(),\n bypassed: /* @__PURE__ */ new Map()\n };\n for (const { entry } of rows) {\n for (const item of entry.evidence) bump(totals.detectors, item.detector);\n bump(totals.actors, entry.actor);\n if (entry.bypass !== void 0) {\n bump(totals.bypassed, `${entry.path} (${entry.bypass})`);\n continue;\n }\n bump(totals.paths, entry.path);\n if (entry.identity !== void 0 && entry.identity !== "") {\n bump(totals.identities, `${entry.identity} \xB7 ${entry.verdict === "verified-bot" ? "verified" : "claimed"}`);\n }\n if (outcome(entry) === "deny") bump(totals.deniedPaths, entry.path);\n if (entry.downgradedFrom !== void 0 && entry.rule !== void 0) bump(totals.guardStops, `${entry.rule} \u2192 ${entry.downgradedFrom}`);\n if (entry.rule !== void 0) bump(totals.ruleHits, entry.rule);\n }\n return totals;\n }\n\n // src/dashboard/client/api.ts\n async function getJson(path) {\n const response = await fetch(API + path);\n if (!response.ok) throw new Error(await errorFrom(response));\n return await response.json();\n }\n async function postJson(path, body) {\n const response = await fetch(API + path, {\n method: "POST",\n headers: { "content-type": "application/json" },\n body: JSON.stringify(body)\n });\n const data = await response.json().catch(() => ({}));\n return response.ok ? { ok: true, data } : { ok: false, data, error: String(data.error ?? "The server refused this.") };\n }\n async function errorFrom(response) {\n const body = await response.json().catch(() => ({}));\n return body.error ?? `${response.status} ${response.statusText}`;\n }\n\n // src/dashboard/client/actions.ts\n var armed = 0;\n function isConfirming() {\n return armed > 0;\n }\n function actorActions(key, after) {\n if (!BOOT.allowActing) return [];\n const forget = el("button", null, "Forget");\n forget.title = "Discard this actor\'s history \u2014 the cure for a false positive that has stuck";\n forget.addEventListener("click", () => {\n void act({ key, action: "forget" }, `Forgot ${key}`, "Its next request is assessed as a first request.", after);\n });\n const clear2 = el("button", null, "Clear as human");\n clear2.title = "Grant this actor human clearance for an hour, as though it had solved a challenge";\n clear2.addEventListener("click", () => {\n void act({ key, action: "clear", forMs: 60 * 6e4 }, `Cleared ${key}`, "Held as human for an hour, then reassessed.", after);\n });\n return [confirmingButton("Allowlist", `Allowlist ${key} \u2014 it stops being assessed at all`, () => allowlist(key, after)), forget, clear2];\n }\n function confirmingButton(label2, confirmation, run2) {\n const button = el("button", "danger", label2);\n let pending2 = false;\n let timer2;\n const disarm = () => {\n if (!pending2) return;\n pending2 = false;\n armed--;\n button.textContent = label2;\n button.className = "danger";\n };\n button.addEventListener("click", () => {\n if (pending2) {\n if (timer2 !== void 0) clearTimeout(timer2);\n disarm();\n run2();\n return;\n }\n pending2 = true;\n armed++;\n button.textContent = confirmation;\n button.className = "danger primary";\n timer2 = setTimeout(disarm, 5e3);\n });\n return button;\n }\n async function allowlist(key, after) {\n const result = await postJson("/api/ranges", { name: "allowlist", add: [key] });\n if (!result.ok) {\n toast("bad", "Not allowlisted", result.error ?? "");\n return;\n }\n toast("warn", `Allowlisted ${key}`, "Requests from it are no longer assessed at all.");\n after();\n }\n async function act(body, title, detail, after) {\n const result = await postJson("/api/actor", body);\n if (!result.ok) {\n toast("bad", "Refused", result.error ?? "");\n return;\n }\n toast("ok", title, detail);\n after();\n }\n\n // src/dashboard/client/format.ts\n var numbers = new Intl.NumberFormat();\n function n(value) {\n return numbers.format(value ?? 0);\n }\n function pct(part, whole) {\n return whole > 0 ? `${Math.round(part / whole * 100)}%` : "\u2014";\n }\n function ms(value) {\n return value >= 10 ? `${value.toFixed(1)}ms` : `${value.toFixed(2)}ms`;\n }\n function uptime(milliseconds) {\n const seconds = Math.max(0, Math.round(milliseconds / 1e3));\n if (seconds < 90) return `${seconds}s`;\n const minutes = Math.round(seconds / 60);\n if (minutes < 90) return `${minutes}m`;\n return `${Math.round(minutes / 60)}h`;\n }\n function rangeLabel(milliseconds) {\n const seconds = Math.round(milliseconds / 1e3);\n if (seconds < 90) return `${seconds}s`;\n const minutes = Math.round(seconds / 60);\n return minutes < 90 ? `${minutes} min` : `${Math.round(minutes / 60)}h`;\n }\n function clockTime(at) {\n const when = new Date(at);\n return `${pad(when.getHours())}:${pad(when.getMinutes())}:${pad(when.getSeconds())}`;\n }\n function clockDate(at) {\n const when = new Date(at);\n return `${pad(when.getDate())}-${pad(when.getMonth() + 1)}-${when.getFullYear()}`;\n }\n function clockStamp(at) {\n return `${clockDate(at)} ${clockTime(at)}`;\n }\n function pad(value) {\n return String(value).padStart(2, "0");\n }\n function windowLabel(count, oldestAt2, now) {\n if (count === 0) return "this window \xB7 empty";\n const span = oldestAt2 === void 0 ? 0 : Math.max(0, now - oldestAt2);\n return `last ${n(count)} requests \xB7 ${rangeLabel(span)}`;\n }\n\n // src/dashboard/client/bars.ts\n function drawBars(target, rows, emptyText) {\n clear(target);\n const filtered = [...rows].filter(([, value]) => value > 0);\n if (filtered.length === 0) {\n target.appendChild(el("div", "note", emptyText));\n return;\n }\n filtered.sort((a, b) => b[1] - a[1]);\n const max = filtered[0]?.[1] ?? 1;\n for (const [label2, value] of filtered.slice(0, 14)) {\n const bar = el("div", "bar");\n const track = el("div", "track");\n const fill = el("div", "fill");\n fill.style.width = `${Math.max(2, Math.round(value / max * 100))}%`;\n track.appendChild(fill);\n track.appendChild(el("div", "lbl", label2));\n bar.appendChild(track);\n bar.appendChild(el("div", "v tnum", n(value)));\n target.appendChild(bar);\n }\n }\n function pairs(record) {\n return Object.entries(record ?? {});\n }\n\n // src/dashboard/client/actor.ts\n function initActor() {\n if (!SECTIONS.actors) return;\n $("actor-close").addEventListener("click", closeActor);\n }\n function openActor(key) {\n state.actor = key;\n app.drawNow();\n $("actor-panel").scrollIntoView({ block: "nearest" });\n }\n function closeActor() {\n state.actor = void 0;\n app.drawNow();\n }\n function drawActor() {\n if (!SECTIONS.actors) return;\n const panel = $("actor-panel");\n if (state.actor === void 0) {\n panel.hidden = true;\n return;\n }\n panel.hidden = false;\n $("actor-key").textContent = state.actor;\n const mine = state.rows.filter((row) => row.entry.actor === state.actor).map((row) => row.entry);\n const latest = mine[mine.length - 1];\n const stats = latest?.actorStats;\n const gaps = [];\n for (let i = 1; i < mine.length; i++) gaps.push(mine[i].at - mine[i - 1].at);\n const meanGap = gaps.length > 0 ? gaps.reduce((a, b) => a + b, 0) / gaps.length : 0;\n const box = $("actor-stats");\n clear(box);\n const rows = [\n ["In this window", `${n(mine.length)} requests`],\n ["Engine sees", stats !== void 0 ? `${n(stats.requests)} requests, ${n(stats.distinctPaths)} distinct paths` : "\u2014"],\n ["Prior confirmations", stats !== void 0 ? n(stats.priorConfirmations) : "\u2014"],\n ["Holds clearance", stats !== void 0 ? stats.cleared ? "yes" : "no" : "\u2014"],\n ["First seen", stats !== void 0 ? clockStamp(stats.firstSeen) : "\u2014"],\n ["Mean gap", gaps.length > 0 ? `${Math.round(meanGap)}ms over ${n(gaps.length)} gaps` : "one request only"]\n ];\n for (const [key, value] of rows) {\n box.appendChild(el("dt", null, key));\n box.appendChild(el("dd", null, value));\n }\n const verdicts = /* @__PURE__ */ new Map();\n const actions = /* @__PURE__ */ new Map();\n for (const entry of mine) {\n verdicts.set(entry.verdict, (verdicts.get(entry.verdict) ?? 0) + 1);\n if (entry.action !== void 0) actions.set(entry.action, (actions.get(entry.action) ?? 0) + 1);\n }\n drawBars($("actor-mix"), [...verdicts, ...actions], "Nothing yet.");\n const bar = $("actor-actions");\n clear(bar);\n const buttons = actorActions(state.actor, () => app.drawNow());\n bar.hidden = buttons.length === 0;\n for (const button of buttons) bar.appendChild(button);\n }\n\n // src/dashboard/client/pager.ts\n var painted = /* @__PURE__ */ new WeakMap();\n function renderPager(host, model, options) {\n const signature = JSON.stringify([model.page, model.from, model.to, model.total, model.atStart, model.atEnd, model.held ?? "", options.withSize, model.size?.current]);\n if (painted.get(host) === signature && host.childElementCount > 0) return;\n painted.set(host, signature);\n clear(host);\n const steps = [\n { to: model.page - 1, glyph: "\u2039", label: "Previous page", disabled: model.atStart },\n { to: model.page + 1, glyph: "\u203A", label: "Next page", disabled: model.atEnd }\n ];\n const [previous, next] = steps;\n host.appendChild(stepButton(previous, model));\n const range = model.total === void 0 ? `${n(model.from)}\u2013${n(model.to)}` : `${n(model.from)}\u2013${n(model.to)} of ${n(model.total)}`;\n const where = el("span", "where", range);\n where.setAttribute("aria-live", "polite");\n host.appendChild(where);\n host.appendChild(stepButton(next, model));\n if (model.held !== void 0) {\n const held = el("span", "held", model.held);\n held.title = "New requests are still arriving and are still counted. They appear when you return to the first page.";\n host.appendChild(held);\n }\n if (options.withSize && model.size !== void 0) {\n const size = model.size;\n const label2 = el("label", "size");\n label2.appendChild(document.createTextNode("Per page"));\n const select2 = document.createElement("select");\n for (const choice of size.choices) {\n const option = document.createElement("option");\n option.value = String(choice);\n option.textContent = String(choice);\n option.selected = choice === size.current;\n select2.appendChild(option);\n }\n select2.addEventListener("change", () => {\n const chosen = Number(select2.value);\n if (Number.isFinite(chosen) && chosen > 0) size.set(chosen);\n });\n label2.appendChild(select2);\n host.appendChild(label2);\n }\n }\n function stepButton(step, model) {\n const button = el("button", "step", step.glyph);\n const element = button;\n element.type = "button";\n element.disabled = step.disabled;\n button.setAttribute("aria-label", step.label);\n button.title = step.label;\n button.addEventListener("click", () => model.go(Math.max(0, step.to)));\n return button;\n }\n\n // src/dashboard/client/replay.ts\n function replayLine(entry) {\n const headers = {};\n for (const [name, value] of entry.headers ?? []) headers[name] = value;\n const query = Object.keys(entry.query).map((name) => `${encodeURIComponent(name)}=${encodeURIComponent(entry.query[name] ?? "")}`).join("&");\n return JSON.stringify({\n method: entry.method,\n url: entry.path + (query === "" ? "" : `?${query}`),\n headers,\n ip: entry.actor,\n timestamp: new Date(entry.at).toISOString(),\n protocol: entry.protocol ?? "https",\n httpVersion: entry.httpVersion ?? "1.1"\n });\n }\n function replayFile(entries) {\n return entries.map(replayLine).join("\\n");\n }\n function corpusCase(entry) {\n const headers = (entry.headers ?? []).map((pair) => ` [${JSON.stringify(pair[0])}, ${JSON.stringify(pair[1])}]`).join(",\\n");\n return [\n "bot({",\n ` id: ${JSON.stringify(`case-${entry.requestId.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`)},`,\n ` title: ${JSON.stringify(`${entry.method} ${entry.path} from ${entry.userAgent.slice(0, 60)}`)},`,\n \' audience: "unwanted-bot", // human | benign-bot | declared-bot | unwanted-bot | hostile | infrastructure\',\n \' category: "observed",\',\n ` provenance: "Captured from the live dashboard on ${new Date(entry.at).toISOString().slice(0, 10)}",`,\n " requests: [",\n " {",\n " headers: [",\n headers,\n " ],",\n ` protocol: ${JSON.stringify(entry.protocol ?? "https")},`,\n ` httpVersion: ${JSON.stringify(entry.httpVersion ?? "1.1")},`,\n ` path: ${JSON.stringify(entry.path)},`,\n " },",\n " ],",\n ` expect: { verdict: ${JSON.stringify(entry.verdict)}, certain: ${String(entry.certain)} },`,\n "}),"\n ].join("\\n");\n }\n\n // src/dashboard/client/result.ts\n function showResult(kind, build) {\n const box = $("policy-result");\n box.hidden = false;\n box.className = `result ${kind}`;\n clear(box);\n build(box);\n }\n function renderPreview(preview, notes = []) {\n showResult(preview.newDenials > 0 ? "warn" : "ok", (box) => {\n const head = el("div");\n head.appendChild(el("b", null, `${n(preview.changed)} of ${n(preview.evaluated)} requests would be treated differently.`));\n box.appendChild(head);\n for (const note of notes) box.appendChild(el("div", "ev-meta", note));\n if (preview.newDenials > 0) {\n box.appendChild(el("div", null, `${n(preview.newDenials)} request(s) that are served today would be denied. Read the samples before applying this.`));\n }\n for (const warning of preview.warnings) box.appendChild(el("div", "ev-meta", warning));\n const dead = preview.ruleHits.filter((row) => row.hits === 0 && row.rule !== "default");\n if (dead.length > 0) box.appendChild(el("div", "ev-meta", `Never matched in this window: ${dead.map((row) => row.rule).join(", ")}`));\n if (preview.samples.length > 0) {\n const table = el("table", "diff");\n const head2 = el("tr");\n for (const label2 of ["Request", "Now", "Would be"]) head2.appendChild(el("th", null, label2));\n table.appendChild(head2);\n for (const sample of preview.samples) {\n const row = el("tr");\n const what = el("td");\n what.appendChild(el("div", "mono", sample.path));\n what.appendChild(el("div", "ev-meta", `${sample.verdict} \xB7 ${sample.userAgent.slice(0, 48)}`));\n row.appendChild(what);\n const from = el("td", "from");\n from.appendChild(el("div", null, sample.from));\n from.appendChild(el("div", "ev-meta", sample.fromRule));\n row.appendChild(from);\n const kind = sample.to === "block" || sample.to === "drop" || sample.to === "redirect" ? "deny" : sample.to === "allow" ? "allow" : "";\n const to = el("td", `to ${kind}`);\n to.appendChild(el("div", null, sample.to));\n to.appendChild(el("div", "ev-meta", sample.toRule));\n row.appendChild(to);\n table.appendChild(row);\n }\n box.appendChild(table);\n } else if (preview.evaluated === 0) {\n box.appendChild(el("div", "ev-meta", "No traffic in the window to preview against \u2014 send some requests first."));\n }\n });\n }\n\n // src/dashboard/client/guard.ts\n var draft;\n var MODE_NOTES = {\n strict: "A terminal action survives only on proven evidence. Nothing is ever denied on a guess. This is the default, and it is the claim this library makes about itself.",\n balanced: "A terminal action also survives on a probabilistic verdict that clears the score threshold with at least two independent strong signals. Real people do trip two signals \u2014 a hardened browser behind a corporate proxy is the usual pair \u2014 so this setting will eventually deny somebody who should have been served.",\n aggressive: "The guard is off. Every rule does exactly what it says, on proof or on suspicion alike, and the people it turns away first are the ones with the most unusual and most legitimate setups."\n };\n function drawGuard() {\n if (!SECTIONS.guard) return;\n const document_ = state.policy;\n const snapshot = state.snapshot;\n if (snapshot === void 0) return;\n const editable = document_?.guardEditable === true;\n const panel = $("stat-policy");\n clear(panel);\n if (!editable || document_ === void 0) {\n const rows = [\n ["False-positive policy", snapshot.policy.falsePositivePolicy],\n ["Fallback when the guard stops a rule", snapshot.policy.fallbackAction],\n ["Terminal score threshold", String(snapshot.policy.terminalScoreThreshold)],\n ["Suspect threshold", String(snapshot.policy.suspectThreshold)],\n ["Action when no rule matches", snapshot.policy.defaultAction],\n ["Challenge configured", snapshot.policy.challengeEnabled ? "yes" : "no"],\n ["Range sets", snapshot.ranges.length === 0 ? "none" : snapshot.ranges.map((range) => `${range.name} (${range.size})`).join(", ")]\n ];\n for (const [key, value] of rows) {\n const line = el("div", "stat-row");\n line.appendChild(el("span", "k", key));\n line.appendChild(el("span", "v", value));\n panel.appendChild(line);\n }\n $("guard-mode").textContent = "not editable here";\n $("guard-note").textContent = "These are fixed at construction on this dashboard. It can change which rules exist; it cannot change how far a rule is allowed to go, because relaxing that is the one edit that can start denying people. Enable it deliberately with controls: { editGuard: true }.";\n return;\n }\n if (draft === void 0) draft = { ...document_.guard };\n const vocabulary = document_.vocabulary;\n $("guard-mode").textContent = state.guardDirty ? "unsaved changes" : "editable";\n $("guard-note").textContent = "Changing these changes what every rule is allowed to do, on the next request. Preview it against the traffic in the window first \u2014 that is the only view you get of who it would start turning away.";\n panel.appendChild(\n guardField(\n // "Mode" rather than "Guard": the panel is already called that, and a field\n // repeating its own panel\'s name reads as a heading rather than as a control.\n "Mode",\n segmented(\n vocabulary.falsePositivePolicies.map((mode) => [mode, mode]),\n draft.falsePositivePolicy,\n (value) => {\n setDraft({ falsePositivePolicy: value });\n drawGuard();\n }\n )\n )\n );\n panel.appendChild(el("div", "note guard-explains", MODE_NOTES[draft.falsePositivePolicy] ?? ""));\n panel.appendChild(\n guardField(\n "Fallback",\n select(vocabulary.fallbackActions, draft.fallbackAction, (value) => setDraft({ fallbackAction: value })),\n "what a stopped rule becomes \u2014 the terminal actions are absent because a terminal fallback would deny the request the guard just protected"\n )\n );\n panel.appendChild(\n guardField(\n "Default action",\n select(vocabulary.actions, draft.defaultAction, (value) => setDraft({ defaultAction: value })),\n "when no rule matches"\n )\n );\n panel.appendChild(\n guardField(\n "Terminal score",\n number(draft.terminalScoreThreshold, (value) => setDraft({ terminalScoreThreshold: value })),\n "balanced mode only: the score a probabilistic verdict must clear"\n )\n );\n panel.appendChild(\n guardField(\n "Suspect at",\n number(draft.suspectThreshold, (value) => setDraft({ suspectThreshold: value })),\n "the score at which a request becomes suspected-bot"\n )\n );\n const bar = el("div", "bar-actions");\n const preview = el("button", null, "Preview");\n preview.addEventListener("click", () => {\n void previewGuard();\n });\n const apply = el("button", "primary", "Apply");\n apply.addEventListener("click", () => {\n void applyGuard();\n });\n const revert = el("button", null, "Revert");\n revert.addEventListener("click", () => {\n draft = { ...document_.guard };\n state.guardDirty = false;\n drawGuard();\n });\n bar.appendChild(preview);\n bar.appendChild(apply);\n bar.appendChild(revert);\n panel.appendChild(bar);\n }\n function setDraft(change) {\n if (draft === void 0) return;\n draft = { ...draft, ...change };\n state.guardDirty = true;\n $("guard-mode").textContent = "unsaved changes";\n }\n function resetGuardDraft() {\n draft = void 0;\n state.guardDirty = false;\n }\n function liveRules() {\n return (state.policy?.rules ?? []).filter((row) => row.editable && row.rule !== void 0).map((row) => row.rule);\n }\n async function previewGuard() {\n if (draft === void 0) return;\n const result = await postJson("/api/policy/preview", { rules: liveRules(), guard: draft });\n if (!result.ok) {\n showResult("bad", (box) => box.appendChild(el("div", null, result.error ?? "")));\n toast("bad", "Refused", result.error ?? "");\n return;\n }\n renderPreview(result.data, state.editorDirty ? ["Previewed against the rules currently in force, not the unsaved edits in the editor."] : []);\n app.showTab("policy");\n }\n async function applyGuard() {\n if (draft === void 0) return;\n const result = await postJson("/api/guard", draft);\n if (!result.ok) {\n showResult("bad", (box) => box.appendChild(el("div", null, result.error ?? "")));\n toast("bad", "Guard unchanged", result.error ?? "");\n return;\n }\n state.guardDirty = false;\n draft = { ...result.data.guard };\n if (state.policy !== void 0) state.policy.guard = { ...result.data.guard };\n toast("ok", "Guard changed", `${result.data.guard.falsePositivePolicy}, falling back to ${result.data.guard.fallbackAction}.`);\n showResult("warn", (box) => {\n box.appendChild(el("div", null, "The guard changed. It applies from the next request, and it is in the notices panel and in your logs."));\n if (result.data.guard.falsePositivePolicy !== "strict") {\n box.appendChild(\n el(\n "div",\n "ev-meta",\n "Requests can now be denied without proof. The guard-stop count is the series to watch: every stop that no longer happens is a request that used to be recoverable and is not any more."\n )\n );\n }\n });\n app.draw();\n }\n function guardField(name, control, hint) {\n const row = el("div", "field");\n row.appendChild(label(name, control));\n const right = el("div", "field-row");\n right.appendChild(control);\n if (hint !== void 0) right.appendChild(el("span", "hint", hint));\n row.appendChild(right);\n return row;\n }\n function segmented(options, value, onChange) {\n const box = el("div", "seg");\n for (const [label2, option] of options) {\n const button = el("button", null, label2);\n button.type = "button";\n button.setAttribute("aria-pressed", String(option === value));\n button.addEventListener("click", () => onChange(option));\n box.appendChild(button);\n }\n return box;\n }\n function select(options, value, onChange) {\n const node = el("select");\n for (const option of options) {\n const item = el("option", null, option);\n item.value = option;\n if (option === value) item.selected = true;\n node.appendChild(item);\n }\n node.addEventListener("change", () => onChange(node.value));\n return node;\n }\n function number(value, onChange) {\n const input = el("input");\n input.type = "number";\n input.min = "1";\n input.max = "100";\n input.value = String(value);\n input.addEventListener("input", () => {\n if (input.value !== "") onChange(Number(input.value));\n });\n return input;\n }\n\n // src/dashboard/client/ranges.ts\n var sets = [];\n async function loadRanges() {\n if (!SECTIONS.ranges) return;\n try {\n const body = await getJson("/api/ranges");\n sets = body.ranges;\n drawRanges();\n } catch {\n }\n }\n function drawRanges() {\n if (!SECTIONS.ranges) return;\n const body = $("ranges-body");\n clear(body);\n $("ranges-mode").textContent = BOOT.allowActing ? "editable" : "read-only";\n $("ranges-note").textContent = BOOT.allowActing ? "An address on the allowlist is not judged leniently \u2014 it is not judged at all. Detection does not run on it, no evidence is produced, and no rule sees it." : "These come from the code that constructed the handler. Enable controls.editRanges to add an address from here.";\n if (sets.length === 0) {\n body.appendChild(el("div", "note", "No range sets are configured. Add an address to the allowlist and one appears."));\n }\n for (const set of sets) {\n const block = el("div", "rangeset");\n const heading = el("h3");\n heading.appendChild(document.createTextNode(set.name));\n heading.appendChild(el("span", null, `${n(set.size)} entr${set.size === 1 ? "y" : "ies"}`));\n block.appendChild(heading);\n const list = el("div", "cidrs");\n for (const entry of set.entries) {\n const chip = el("span", BOOT.allowActing ? "cidr" : "cidr readonly");\n chip.appendChild(document.createTextNode(entry));\n if (BOOT.allowActing) {\n const remove = el("button", null, "\xD7");\n remove.title = `Remove ${entry} from ${set.name}`;\n remove.setAttribute("aria-label", `Remove ${entry} from ${set.name}`);\n remove.addEventListener("click", () => void update(set.name, { remove: [entry] }));\n chip.appendChild(remove);\n }\n list.appendChild(chip);\n }\n if (set.entries.length === 0) list.appendChild(el("span", "hint", "empty"));\n block.appendChild(list);\n body.appendChild(block);\n }\n if (!BOOT.allowActing) return;\n const form = el("div", "rangeset");\n const row = el("div", "field-row");\n const name = el("input", "mono-input");\n name.type = "text";\n name.value = "allowlist";\n name.setAttribute("aria-label", "Range set");\n name.style.maxWidth = "150px";\n const value = el("input", "mono-input");\n value.type = "text";\n value.placeholder = "203.0.113.0/24";\n value.setAttribute("aria-label", "Address or CIDR to add");\n const add = el("button", null, "Add");\n const submit = () => {\n const entry = value.value.trim();\n if (entry === "") return;\n value.value = "";\n void update(name.value.trim(), { add: [entry] });\n };\n add.addEventListener("click", submit);\n value.addEventListener("keydown", (event) => {\n if (event.key === "Enter") submit();\n });\n row.appendChild(name);\n row.appendChild(value);\n row.appendChild(add);\n form.appendChild(row);\n body.appendChild(form);\n }\n async function update(name, change) {\n const result = await postJson("/api/ranges", { name, ...change });\n if (!result.ok) {\n toast("bad", "Ranges unchanged", result.error ?? "");\n return;\n }\n toast(name === "allowlist" && change.add !== void 0 ? "warn" : "ok", `\u201C${name}\u201D updated`, `${n(result.data.entries?.length ?? 0)} entr${result.data.entries?.length === 1 ? "y" : "ies"} now.`);\n await loadRanges();\n }\n\n // src/dashboard/client/draft.ts\n function draftRule(entry, existingIds = []) {\n const match = {};\n let because;\n let stem;\n const proven = entry.evidence.filter((item) => item.certainty === "certain" && item.direction !== "human");\n const detectors = [...new Set((proven.length > 0 ? proven : entry.evidence.filter((item) => item.direction !== "human")).map((item) => item.detector))];\n if (entry.identity !== void 0 && entry.identity !== "") {\n match["identity"] = [entry.identity];\n if (entry.certain) match["certain"] = true;\n stem = entry.identity;\n because = entry.certain ? `Matched on the identity \u201C${entry.identity}\u201D, and on proof \u2014 so a client merely claiming that name does not match.` : `Matched on the claimed identity \u201C${entry.identity}\u201D. Nothing has verified it, so this matches anything that says so.`;\n } else if (proven.length > 0) {\n match["detector"] = detectors;\n match["certain"] = true;\n stem = detectors[0] ?? "proven";\n because = `Matched on proof from ${detectors.join(", ")}. Only requests that carry the same proof match.`;\n } else if (detectors.length > 0) {\n match["verdict"] = [entry.verdict];\n match["detector"] = detectors;\n match["minScore"] = Math.max(0, Math.floor(entry.score / 10) * 10);\n stem = detectors[0] ?? entry.verdict;\n because = `Matched on ${entry.verdict} at score ${String(match["minScore"])} or more, from ${detectors.join(", ")}. Every one of those is probabilistic, so the guard will not let this rule deny anybody.`;\n } else {\n match["verdict"] = [entry.verdict];\n stem = entry.verdict;\n because = `Nothing fired on this request, so there is nothing sharper to match on than the verdict itself. Narrow it before you use it.`;\n }\n return {\n rule: {\n id: uniqueId(`from-${slug(stem)}`, existingIds),\n match,\n action: "tag",\n reason: "Drafted from a request on the dashboard.",\n _open: true\n },\n because\n };\n }\n function slug(value) {\n const cleaned = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");\n return cleaned === "" ? "request" : cleaned.slice(0, 40);\n }\n function uniqueId(wanted, taken) {\n if (!taken.includes(wanted)) return wanted;\n for (let suffix = 2; suffix < 1e3; suffix++) {\n const candidate = `${wanted}-${suffix}`;\n if (!taken.includes(candidate)) return candidate;\n }\n return `${wanted}-${Date.now()}`;\n }\n\n // src/dashboard/client/policy.ts\n async function loadPolicy() {\n if (!SECTIONS.policy) return;\n try {\n const document_ = await getJson("/api/policy");\n state.policy = document_;\n if (!state.editorDirty) {\n setEditorRules(document_.rules.filter((row) => row.editable).map((row) => row.rule));\n }\n if (!state.guardDirty) resetGuardDraft();\n drawPolicyTab();\n } catch {\n }\n }\n function setEditorRules(rules) {\n state.editorRules = JSON.parse(JSON.stringify(rules ?? []));\n renderEditor();\n }\n function markDirty() {\n state.editorDirty = true;\n $("policy-dirty").hidden = false;\n }\n function editorRules() {\n if (state.editorMode === "json") {\n try {\n const parsed = JSON.parse(byId("policy-json").value);\n if (!Array.isArray(parsed)) return { error: "The JSON must be an array of rules." };\n return { rules: parsed };\n } catch (error) {\n return { error: `The editor does not contain valid JSON: ${String(error)}` };\n }\n }\n return { rules: state.editorRules };\n }\n function cleanRule(rule) {\n const match = {};\n for (const [key, value] of Object.entries(rule.match ?? {})) {\n if (value === void 0 || value === null || value === "") continue;\n if (Array.isArray(value) && value.length === 0) continue;\n match[key] = value;\n }\n const params = {};\n for (const [key, value] of Object.entries(rule.params ?? {})) {\n if (value === void 0 || value === null || value === "") continue;\n if (key === "limit") {\n const limit = value;\n if (limit.max === void 0 || limit.windowMs === void 0) continue;\n }\n params[key] = value;\n }\n const out = { id: rule.id, match, action: rule.action };\n if (Object.keys(params).length > 0) out["params"] = params;\n if (rule.reason !== void 0 && rule.reason !== "") out["reason"] = rule.reason;\n return out;\n }\n function cleanRules(rules) {\n return rules.map(cleanRule);\n }\n function field(name, control, hint) {\n const row = el("div", "field");\n row.appendChild(label(name, control));\n const right = el("div", "field-row");\n for (const node of Array.isArray(control) ? control : [control]) right.appendChild(node);\n if (hint !== void 0) right.appendChild(el("span", "hint", hint));\n row.appendChild(right);\n return row;\n }\n function chipSelect(options, selected, onChange) {\n const box = el("div", "chips-select");\n const chosen = Array.isArray(selected) ? [...selected] : selected === void 0 ? [] : [String(selected)];\n for (const option of options) {\n const button = el("button", null, option);\n button.type = "button";\n button.setAttribute("aria-pressed", String(chosen.includes(option)));\n button.addEventListener("click", () => {\n const at = chosen.indexOf(option);\n if (at === -1) chosen.push(option);\n else chosen.splice(at, 1);\n button.setAttribute("aria-pressed", String(at === -1));\n onChange(chosen.length === 0 ? void 0 : [...chosen]);\n });\n box.appendChild(button);\n }\n return box;\n }\n function segmented2(options, value, onChange) {\n const box = el("div", "seg");\n for (const [label2, option] of options) {\n const button = el("button", null, label2);\n button.type = "button";\n button.setAttribute("aria-pressed", String(option === value));\n button.addEventListener("click", () => {\n for (const other of Array.from(box.children)) other.setAttribute("aria-pressed", "false");\n button.setAttribute("aria-pressed", "true");\n onChange(option);\n });\n box.appendChild(button);\n }\n return box;\n }\n function textInput(value, placeholder, onChange, mono = false) {\n const input = el("input", mono ? "mono-input" : null);\n input.type = "text";\n input.value = value === void 0 || value === null ? "" : String(value);\n input.placeholder = placeholder;\n input.addEventListener("input", () => onChange(input.value.trim() === "" ? void 0 : input.value));\n return input;\n }\n function listInput(value, placeholder, onChange) {\n const current = value === void 0 ? "" : Array.isArray(value) ? value.join(", ") : String(value);\n const input = textInput(current, placeholder, () => {\n }, true);\n input.addEventListener("input", () => {\n const parts = input.value.split(",").map((part) => part.trim()).filter(Boolean);\n onChange(parts.length === 0 ? void 0 : parts.length === 1 ? parts[0] : parts);\n });\n return input;\n }\n function numberInput(value, placeholder, onChange) {\n const input = el("input");\n input.type = "number";\n input.value = value === void 0 || value === null ? "" : String(value);\n input.placeholder = placeholder;\n input.addEventListener("input", () => onChange(input.value === "" ? void 0 : Number(input.value)));\n return input;\n }\n function matchSummary(rule) {\n const box = el("div", "rule-summary");\n const match = rule.match ?? {};\n const parts = [];\n for (const key of ["verdict", "botClass", "category", "identity", "detector", "method", "path"]) {\n const value = match[key];\n if (value === void 0) continue;\n parts.push([key, Array.isArray(value) ? value.join(", ") : String(value)]);\n }\n if (match["certain"] !== void 0) parts.push(["certain", String(match["certain"])]);\n if (match["minScore"] !== void 0 || match["maxScore"] !== void 0) {\n parts.push(["score", `${String(match["minScore"] ?? 0)}\u2013${String(match["maxScore"] ?? 99)}`]);\n }\n if (match["minPriorConfirmations"] !== void 0) parts.push(["prior", String(match["minPriorConfirmations"])]);\n if (match["minUnsolvedChallenges"] !== void 0) parts.push(["unsolved", String(match["minUnsolvedChallenges"])]);\n if (parts.length === 0) {\n box.appendChild(el("span", "none", "matches everything"));\n return box;\n }\n for (const [key, value] of parts.slice(0, 4)) box.appendChild(el("span", "t", `${key}: ${value}`));\n if (parts.length > 4) box.appendChild(el("span", "k", `+${parts.length - 4} more`));\n return box;\n }\n function ruleCard(rule, index) {\n const vocabulary = state.policy?.vocabulary;\n const open = rule._open === true;\n const card = el("div", `rule${open ? "" : " collapsed"}`);\n const head = el("div", "rule-head");\n const chevron = el("button", "chev", open ? "\u25BE" : "\u25B8");\n chevron.title = open ? "Collapse" : "Expand";\n chevron.setAttribute("aria-expanded", String(open));\n chevron.addEventListener("click", () => {\n rule._open = !open;\n renderEditor();\n });\n head.appendChild(chevron);\n head.appendChild(el("span", "ord", index + 1));\n const id = textInput(rule.id, "rule-id", (value) => {\n rule.id = value ?? "";\n markDirty();\n }, true);\n id.setAttribute("aria-label", "Rule id");\n head.appendChild(id);\n if (open) {\n const action = el("select");\n action.setAttribute("aria-label", "Action");\n for (const name of vocabulary?.actions ?? []) {\n const option = el("option", null, name);\n option.value = name;\n if (name === rule.action) option.selected = true;\n action.appendChild(option);\n }\n action.addEventListener("change", () => {\n rule.action = action.value;\n rule.params = {};\n markDirty();\n renderEditor();\n });\n head.appendChild(action);\n } else {\n head.appendChild(matchSummary(rule));\n head.appendChild(el("span", `act-pill ${actionKind(rule.action)}`, rule.action));\n }\n const up = el("button", "icon", "\u2191");\n up.title = "Move earlier \u2014 the first matching rule wins";\n up.addEventListener("click", () => moveRule(index, -1));\n const down = el("button", "icon", "\u2193");\n down.title = "Move later";\n down.addEventListener("click", () => moveRule(index, 1));\n const remove = el("button", "icon danger", "Remove");\n remove.addEventListener("click", () => {\n state.editorRules.splice(index, 1);\n markDirty();\n renderEditor();\n });\n head.appendChild(up);\n head.appendChild(down);\n head.appendChild(remove);\n card.appendChild(head);\n if (!open) return card;\n const body = el("div", "rule-body");\n rule.match = rule.match ?? {};\n const match = rule.match;\n body.appendChild(field("Verdict", chipSelect(vocabulary?.verdicts ?? [], match["verdict"], (value) => {\n match["verdict"] = value;\n markDirty();\n })));\n body.appendChild(field("Bot class", chipSelect(vocabulary?.botClasses ?? [], match["botClass"], (value) => {\n match["botClass"] = value;\n markDirty();\n })));\n body.appendChild(field("Category", chipSelect(vocabulary?.categories ?? [], match["category"], (value) => {\n match["category"] = value;\n markDirty();\n })));\n body.appendChild(field("Detector", chipSelect(vocabulary?.detectors ?? [], match["detector"], (value) => {\n match["detector"] = value;\n markDirty();\n })));\n body.appendChild(field("Method", chipSelect(vocabulary?.methods ?? [], match["method"], (value) => {\n match["method"] = value;\n markDirty();\n })));\n body.appendChild(\n field(\n "Evidence",\n segmented2(\n [\n ["any", void 0],\n ["proven", true],\n ["unproven", false]\n ],\n match["certain"],\n (value) => {\n match["certain"] = value;\n markDirty();\n }\n ),\n "proven means at least one piece of certain evidence \u2014 including a proven human"\n )\n );\n body.appendChild(\n field("Score", [\n numberInput(match["minScore"], "min", (value) => {\n match["minScore"] = value;\n markDirty();\n }),\n el("span", "hint", "to"),\n numberInput(match["maxScore"], "max", (value) => {\n match["maxScore"] = value;\n markDirty();\n })\n ])\n );\n body.appendChild(field("Identity", listInput(match["identity"], "googlebot, gptbot", (value) => {\n match["identity"] = value;\n markDirty();\n })));\n body.appendChild(field("Path", listInput(match["path"], "/api/, /search", (value) => {\n match["path"] = value;\n markDirty();\n }), "a string matches as a prefix"));\n body.appendChild(\n field(\n "Prior bots",\n numberInput(match["minPriorConfirmations"], "0", (value) => {\n match["minPriorConfirmations"] = value;\n markDirty();\n }),\n "times this actor was already proven a bot"\n )\n );\n body.appendChild(\n field(\n "Unsolved",\n numberInput(match["minUnsolvedChallenges"], "0", (value) => {\n match["minUnsolvedChallenges"] = value;\n markDirty();\n }),\n "challenges issued to this actor that were never answered \u2014 solving one clears the count"\n )\n );\n rule.params = rule.params ?? {};\n const params = rule.params;\n for (const node of paramFields(rule.action, params)) body.appendChild(node);\n body.appendChild(field("Reason", textInput(rule.reason, "shown in the decision and in your logs", (value) => {\n rule.reason = value ?? "";\n markDirty();\n })));\n card.appendChild(body);\n return card;\n }\n function paramFields(action, params) {\n switch (action) {\n case "block":\n return [\n field("Status", numberInput(params["status"], "403", (value) => {\n params["status"] = value;\n markDirty();\n })),\n field("Body", textInput(params["body"], "Automated traffic is not served here.", (value) => {\n params["body"] = value;\n markDirty();\n }))\n ];\n case "redirect":\n return [field("Location", textInput(params["location"], "/too-fast", (value) => {\n params["location"] = value;\n markDirty();\n }, true))];\n case "delay":\n return [field("Delay", numberInput(params["delayMs"], "250", (value) => {\n params["delayMs"] = value;\n markDirty();\n }), "milliseconds")];\n case "rate-limit": {\n const limit = params["limit"] ?? {};\n params["limit"] = limit;\n return [\n field("Limit", [\n numberInput(limit["max"], "60", (value) => {\n limit["max"] = value;\n markDirty();\n }),\n el("span", "hint", "requests per"),\n numberInput(limit["windowMs"], "60000", (value) => {\n limit["windowMs"] = value;\n markDirty();\n }),\n el("span", "hint", "ms")\n ])\n ];\n }\n case "custom":\n return [field("Handler", textInput(params["handler"], "handler-id", (value) => {\n params["handler"] = value;\n markDirty();\n }, true), "id of a handler you registered")];\n default:\n return [];\n }\n }\n function lockedCard(row) {\n const card = el("div", "rule locked");\n const head = el("div", "rule-head");\n head.appendChild(el("span", "ord", `#${row.index + 1}`));\n head.appendChild(el("span", "mono", row.id));\n head.appendChild(el("span", "grow"));\n head.appendChild(el("span", "pill", "predicate \u2014 locked"));\n card.appendChild(head);\n card.appendChild(\n el("div", "rule-body", "This rule matches with a function, which cannot be represented here or sent over HTTP. It stays exactly as it is, at this position, whatever else you change.")\n );\n return card;\n }\n function moveRule(index, delta) {\n const target = index + delta;\n if (target < 0 || target >= state.editorRules.length) return;\n const moved = state.editorRules.splice(index, 1)[0];\n if (moved === void 0) return;\n state.editorRules.splice(target, 0, moved);\n markDirty();\n renderEditor();\n }\n function renderEditor() {\n if (!SECTIONS.policy) return;\n const list = $("rulelist");\n clear(list);\n const locked = (state.policy?.rules ?? []).filter((row) => !row.editable);\n if (state.editorRules.length === 0 && locked.length === 0) {\n list.appendChild(el("div", "note", "No rules. Every request takes the default action \u2014 add one, or import a set."));\n }\n const rendered2 = state.editorRules.map((rule, index) => ruleCard(rule, index));\n for (const row of locked) rendered2.splice(Math.min(row.index, rendered2.length), 0, lockedCard(row));\n for (const node of rendered2) list.appendChild(node);\n if (state.editorMode === "json") byId("policy-json").value = JSON.stringify(cleanRules(state.editorRules), null, 2);\n }\n function drawPolicyTab() {\n const document_ = state.policy;\n if (document_ === void 0) return;\n const editable = document_.editable;\n $("policy-apply").hidden = !editable;\n byId("policy-json").readOnly = !editable;\n $("policy-mode").textContent = editable ? "editable" : "read-only";\n $("rule-add").hidden = !editable;\n $("policy-import").hidden = !editable;\n const preserved = document_.rules.filter((row) => !row.editable);\n let note = editable ? "First match wins \u2014 order matters." : "Read-only. Enable controls.editPolicy to change these.";\n if (preserved.length > 0) note += ` ${preserved.length} rule(s) use a predicate function and are locked.`;\n $("policy-note").textContent = note;\n renderPresetButtons();\n drawGuard();\n drawRanges();\n if (SECTIONS.robots) {\n $("robots-preview").textContent = document_.robots === "" ? "(this policy declines no crawler by name)" : document_.robots;\n const notes = $("robots-notes");\n clear(notes);\n for (const note_ of document_.robotsNotes) notes.appendChild(el("div", "ev-meta", `${note_.rule}: ${note_.reason}`));\n }\n const rules = $("stat-rules");\n clear(rules);\n const installed = state.snapshot?.rules ?? [];\n if (installed.length === 0) rules.appendChild(el("div", "note", "No rules configured \u2014 every request takes the default action."));\n else installed.forEach((rule, index) => rules.appendChild(el("span", "chip", `${index + 1}. ${rule}`)));\n }\n async function draftIntoEditor(entry) {\n if (state.policy === void 0) await loadPolicy();\n const drafted = draftRule(entry, state.editorRules.map((rule) => rule.id));\n state.editorRules.push(drafted.rule);\n markDirty();\n if (state.editorMode === "json") byId("policy-json").value = JSON.stringify(cleanRules(state.editorRules), null, 2);\n renderEditor();\n app.showTab("policy");\n const notes = [\n `Drafted \u201C${drafted.rule.id}\u201D, tagging only. Nothing is applied \u2014 read it, choose the action, then apply.`,\n drafted.because,\n "It was added last, which is the only position that cannot change what an existing rule does. Move it up with \u2191 if it needs to win."\n ];\n showResult("warn", (box) => {\n for (const note of notes) box.appendChild(el("div", note === notes[0] ? null : "ev-meta", note));\n });\n toast("ok", "Rule drafted", "In the editor, tagging only, not applied.");\n await runPreview(notes);\n }\n function switchToGui() {\n if (state.editorMode === "gui") return;\n const parsed = editorRules();\n if (parsed.error !== void 0) {\n toast("bad", "That JSON will not parse", parsed.error);\n return;\n }\n state.editorRules = parsed.rules ?? [];\n state.editorMode = "gui";\n $("mode-gui").setAttribute("aria-pressed", "true");\n $("mode-json").setAttribute("aria-pressed", "false");\n $("editor-gui").hidden = false;\n $("editor-json").hidden = true;\n renderEditor();\n }\n function switchToJson() {\n state.editorMode = "json";\n $("mode-gui").setAttribute("aria-pressed", "false");\n $("mode-json").setAttribute("aria-pressed", "true");\n $("editor-gui").hidden = true;\n $("editor-json").hidden = false;\n byId("policy-json").value = JSON.stringify(cleanRules(state.editorRules), null, 2);\n }\n async function exportSettings() {\n try {\n const response = await fetch(`${API}/api/settings`);\n const text = await response.text();\n if (!response.ok) throw new Error(text);\n download(text, `bothandler-settings-${today()}.json`, "application/json");\n toast("ok", "Settings exported", "Rules, plus a record of the configuration around them.");\n } catch (error) {\n toast("bad", "Export failed", String(error));\n }\n }\n function readSettingsFile(file) {\n const reader = new FileReader();\n reader.onload = () => {\n try {\n applyImported(JSON.parse(String(reader.result)), file.name);\n } catch (error) {\n toast("bad", "That file is not JSON", String(error));\n }\n };\n reader.onerror = () => toast("bad", "Could not read that file", "");\n reader.readAsText(file);\n }\n function applyImported(document_, name) {\n const rules = Array.isArray(document_) ? document_ : Array.isArray(document_.rules) ? document_.rules : void 0;\n if (rules === void 0) {\n toast("bad", "Nothing to import", "Expected an array of rules, or a settings file with a rules array.");\n return;\n }\n setEditorRules(rules);\n markDirty();\n switchToGui();\n const ignored = [];\n const readOnly = document_.readOnly;\n if (readOnly !== void 0) {\n ignored.push("the guard, the detectors, the ranges and the audit \u2014 those come from the code that built the handler, not from a file");\n if (Array.isArray(readOnly.lockedRules) && readOnly.lockedRules.length > 0) {\n ignored.push(`${readOnly.lockedRules.length} predicate rule(s), which stay as they are`);\n }\n }\n showResult("warn", (box) => {\n box.appendChild(el("div", null, `Loaded ${rules.length} rule(s) from ${name}. Nothing has been applied yet \u2014 preview it first.`));\n for (const line of ignored) box.appendChild(el("div", "ev-meta", `Ignored: ${line}`));\n });\n toast("ok", `Imported ${rules.length} rule(s)`, "Review, preview, then apply.");\n }\n function collectForSubmit() {\n const parsed = editorRules();\n if (parsed.error !== void 0) {\n showResult("bad", (box) => box.appendChild(el("div", null, parsed.error ?? "")));\n toast("bad", "That JSON will not parse", parsed.error);\n return void 0;\n }\n const cleaned = cleanRules(parsed.rules ?? []);\n const blank = cleaned.filter((rule) => rule["id"] === void 0 || rule["id"] === "").length;\n if (blank > 0) {\n toast("bad", "Every rule needs an id", "It is what every decision and log line names.");\n return void 0;\n }\n return cleaned;\n }\n async function runPreview(notes = []) {\n const rules = collectForSubmit();\n if (rules === void 0) return;\n const result = await postJson("/api/policy/preview", { rules });\n if (!result.ok) {\n showResult("bad", (box) => box.appendChild(el("div", null, result.error ?? "")));\n toast("bad", "Refused", result.error ?? "");\n return;\n }\n renderPreview(result.data, notes);\n }\n async function applyPolicy() {\n const rules = collectForSubmit();\n if (rules === void 0) return;\n const result = await postJson("/api/policy/apply", { rules });\n if (!result.ok) {\n showResult("bad", (box) => box.appendChild(el("div", null, result.error ?? "")));\n toast("bad", "Refused", result.error ?? "");\n return;\n }\n state.editorDirty = false;\n $("policy-dirty").hidden = true;\n await loadPolicy();\n toast("ok", "Applied", `${n(result.data.rules)} rule(s) now in force.`);\n showResult("ok", (box) => {\n box.appendChild(el("div", null, `Applied. ${n(result.data.rules)} rule(s) are now in force.`));\n for (const warning of result.data.warnings ?? []) box.appendChild(el("div", "ev-meta", warning));\n });\n }\n function renderPresetButtons() {\n const box = $("preset-buttons");\n const presets = state.policy?.vocabulary.presets ?? [];\n if (box.childElementCount === presets.length) return;\n clear(box);\n for (const preset of presets) {\n const button = el("button", null, preset);\n button.addEventListener("click", () => {\n void (async () => {\n const result = await postJson("/api/policy/preview", { preset });\n if (!result.ok) {\n toast("bad", "Refused", result.error ?? "");\n return;\n }\n renderPreview(result.data);\n })();\n });\n box.appendChild(button);\n }\n }\n function initPolicy() {\n if (!SECTIONS.policy) return;\n $("mode-gui").addEventListener("click", switchToGui);\n $("mode-json").addEventListener("click", switchToJson);\n $("rule-add").addEventListener("click", () => {\n state.editorRules.push({ id: `new-rule-${state.editorRules.length + 1}`, match: {}, action: "tag", params: {}, _open: true });\n markDirty();\n renderEditor();\n });\n $("rule-expand").addEventListener("click", () => {\n const anyClosed = state.editorRules.some((rule) => rule._open !== true);\n for (const rule of state.editorRules) rule._open = anyClosed;\n $("rule-expand").textContent = anyClosed ? "Collapse all" : "Expand all";\n renderEditor();\n });\n byId("policy-json").addEventListener("input", markDirty);\n $("policy-preview").addEventListener("click", () => void runPreview());\n $("policy-apply").addEventListener("click", () => void applyPolicy());\n $("policy-revert").addEventListener("click", () => {\n state.editorDirty = false;\n $("policy-dirty").hidden = true;\n $("policy-result").hidden = true;\n resetGuardDraft();\n void loadPolicy();\n });\n $("policy-export").addEventListener("click", () => void exportSettings());\n $("policy-import").addEventListener("click", () => byId("policy-file").click());\n byId("policy-file").addEventListener("change", () => {\n const input = byId("policy-file");\n const file = input.files?.[0];\n if (file !== void 0) readSettingsFile(file);\n input.value = "";\n });\n const panel = $("editor-panel");\n for (const name of ["dragenter", "dragover"]) {\n panel.addEventListener(name, (event) => {\n if (state.policy?.editable !== true) return;\n event.preventDefault();\n panel.classList.add("drop");\n });\n }\n for (const name of ["dragleave", "drop"]) {\n panel.addEventListener(name, (event) => {\n panel.classList.remove("drop");\n if (name !== "drop") return;\n event.preventDefault();\n const file = event.dataTransfer?.files?.[0];\n if (file !== void 0) readSettingsFile(file);\n });\n }\n }\n\n // src/dashboard/client/feed.ts\n var FILTERS = [\n ["all", "All"],\n ["proven", "Proven"],\n ["suspected", "Suspected"],\n ["human", "Human"],\n ["guard", "Guard stops"],\n ["deny", "Denied"],\n ["mitigate", "Mitigated"],\n ["allow", "Served"]\n ];\n var rendered = /* @__PURE__ */ new Map();\n function initFeed() {\n const loadThem = byId("feed-load-skipped");\n loadThem.addEventListener("click", () => {\n loadThem.disabled = true;\n void loadSkipped().finally(() => {\n loadThem.disabled = false;\n });\n });\n const filters = $("filters");\n for (const [name, label2] of FILTERS) {\n const button = el("button", null, label2);\n button.setAttribute("aria-pressed", String(name === state.filter));\n button.dataset["filter"] = name;\n button.addEventListener("click", () => {\n state.filter = name;\n resetPaging();\n for (const other of Array.from(filters.children)) other.setAttribute("aria-pressed", "false");\n button.setAttribute("aria-pressed", "true");\n app.syncUrl();\n app.drawNow();\n });\n filters.appendChild(button);\n }\n const search = byId("search");\n search.addEventListener("input", () => {\n setSearch(search.value.trim());\n app.syncUrl();\n app.drawNow();\n });\n const exportShown = byId("feed-export");\n exportShown.hidden = !SECTIONS.evidence;\n exportShown.addEventListener("click", () => {\n const rows = matchingRows();\n if (rows.length === 0) {\n toast("warn", "Nothing to export", "No request in the window matches this filter.");\n return;\n }\n const entries = rows.map((row) => row.entry).reverse();\n download(`${replayFile(entries)}\n`, `bothandler-feed-${today()}.jsonl`, "application/x-ndjson");\n toast("ok", `Exported ${n(entries.length)} request(s)`, "Replay them with `bothandlerjs replay`.");\n });\n }\n function reflectFilterButtons() {\n for (const button of Array.from($("filters").children)) {\n button.setAttribute("aria-pressed", String(button instanceof HTMLElement && button.dataset["filter"] === state.filter));\n }\n const search = byId("search");\n if (search.value !== state.search) search.value = state.search;\n }\n async function loadSkipped() {\n const before = state.rows.length;\n try {\n const body = await getJson("/api/feed");\n for (const entry of body.entries) ingest(entry);\n sortRows();\n } catch {\n toast("bad", "Could not load them", "The dashboard did not answer. The entries are still in the window; try again.");\n return;\n }\n state.caughtUp = (state.snapshot?.skipped ?? 0) + state.laggedDrops;\n const added = state.rows.length - before;\n resetPaging();\n app.drawNow();\n toast(\n added > 0 ? "ok" : "warn",\n added > 0 ? `Loaded ${n(added)}` : "Nothing left to load",\n added > 0 ? "They are in the feed now, in the order they happened." : "The window no longer holds them; the ring had already rotated past."\n );\n }\n var FEED_PAGE_SIZES = [25, 50, 100, 200];\n function drawPager(paged) {\n const size = state.feedPageSize;\n const hidden = paged.pages <= 1;\n const model = {\n page: paged.page,\n from: paged.page * size + 1,\n to: Math.min(paged.total, (paged.page + 1) * size),\n total: paged.total,\n atStart: paged.page === 0,\n atEnd: paged.page >= paged.pages - 1,\n ...paged.page > 0 ? { held: "held while you read" } : {},\n go: (page) => {\n goToFeedPage(page);\n app.drawNow();\n },\n size: {\n current: size,\n choices: FEED_PAGE_SIZES,\n set: (next) => {\n state.feedPageSize = next;\n resetPaging();\n app.drawNow();\n }\n }\n };\n for (const [id, withSize] of [\n ["feed-pager-top", true],\n ["feed-pager", false]\n ]) {\n const host = $(id);\n host.hidden = hidden;\n if (hidden) clear(host);\n else renderPager(host, model, { withSize });\n }\n }\n function drawFeed() {\n const body = byId("rows");\n const paged = feedPage(state.feedPageSize);\n const shown = paged.rows;\n let index = 0;\n const place = (node) => {\n const current = body.childNodes[index] ?? null;\n if (current !== node) body.insertBefore(node, current);\n index++;\n };\n for (const row of shown) {\n const id = row.entry.requestId;\n const open = state.open.has(id);\n let cached = rendered.get(id);\n if (cached === void 0 || cached.rev !== row.rev || cached.open !== open) {\n cached = {\n row: buildRow(row.entry, open),\n detail: open ? buildDetail(row.entry) : void 0,\n rev: row.rev,\n open\n };\n rendered.set(id, cached);\n }\n place(cached.row);\n if (cached.detail !== void 0) place(cached.detail);\n }\n while (body.childNodes.length > index) body.removeChild(body.childNodes[index]);\n if (rendered.size > shown.length * 2 + 100) {\n const live = new Set(shown.map((row) => row.entry.requestId));\n for (const id of Array.from(rendered.keys())) if (!live.has(id)) rendered.delete(id);\n }\n const total = state.rows.length;\n const matching = matchingCount();\n $("empty").hidden = total > 0;\n $("feed-count").textContent = matching === total ? `${n(total)} in this window` : `${n(matching)} of ${n(total)}`;\n drawPager(paged);\n const skipped = Math.max(0, (state.snapshot?.skipped ?? 0) + state.laggedDrops - state.caughtUp);\n const note = $("feed-skipped");\n note.hidden = skipped === 0;\n note.textContent = `${n(skipped)} not streamed`;\n byId("feed-load-skipped").hidden = skipped === 0;\n note.title = state.laggedDrops > 0 ? `${n(state.laggedDrops)} were skipped because this connection could not keep up, and the rest by the rate cap. All of them are still in the window, the preview and the export.` : "Entries the rate cap kept off this stream. They are still in the window, the preview and the export \u2014 raise maxEventsPerSecond to see them live.";\n }\n function resetFeedCache() {\n rendered.clear();\n }\n function buildRow(entry, open) {\n const out = outcome(entry);\n const tr = el("tr", `row a-${entry.downgradedFrom !== void 0 ? "guard" : out}${open ? " open" : ""}`);\n tr.appendChild(el("td", "num mono tnum when", clockTime(entry.at)));\n const request = el("td", "edge req");\n const toggle = el("button", "row-toggle", `${entry.method} ${entry.path}`);\n toggle.type = "button";\n toggle.setAttribute("aria-expanded", String(open));\n toggle.setAttribute("aria-label", `${entry.method} ${entry.path}, ${entry.verdict}. Evidence.`);\n toggle.dataset["request"] = entry.requestId;\n request.appendChild(toggle);\n const ua = el("span", "ua");\n if (SECTIONS.actors) {\n const actorLink = el("a", null, entry.actor);\n actorLink.href = "#actor";\n actorLink.title = "Show everything from this actor";\n actorLink.addEventListener("click", (event) => {\n event.preventDefault();\n event.stopPropagation();\n openActor(entry.actor);\n });\n ua.appendChild(actorLink);\n ua.appendChild(document.createTextNode(` \xB7 ${entry.userAgent}`));\n } else {\n ua.appendChild(document.createTextNode(`${entry.actor} \xB7 ${entry.userAgent}`));\n }\n ua.title = `${entry.actor} \xB7 ${entry.userAgent}`;\n request.appendChild(ua);\n tr.appendChild(request);\n const verdictCell = el("td");\n const [badgeClass, badgeLabel] = verdictBadge(entry);\n verdictCell.appendChild(el("span", `badge ${badgeClass}`, badgeLabel));\n if (entry.identity !== void 0) verdictCell.appendChild(el("span", "sub", entry.identity));\n tr.appendChild(verdictCell);\n tr.appendChild(el("td", "num mono tnum", entry.certain ? "proven" : String(entry.score)));\n const actionCell = el("td");\n if (entry.action !== void 0) {\n const kind = out === "deny" ? "act-deny" : out === "mitigate" ? "act-mitigate" : out === "allow" ? "act-allow" : "act-tag";\n actionCell.appendChild(el("span", `act ${kind}`, entry.action));\n if (entry.rule !== void 0) {\n const ruleLabel = el("span", "sub", entry.rule);\n ruleLabel.title = entry.rule;\n actionCell.appendChild(ruleLabel);\n }\n if (entry.downgradedFrom !== void 0) actionCell.appendChild(el("span", "guard", `guard stopped ${entry.downgradedFrom}`));\n } else {\n actionCell.appendChild(el("span", "sub", "assessed only"));\n }\n tr.appendChild(actionCell);\n tr.appendChild(el("td", "num mono tnum", entry.durationMs.toFixed(2)));\n tr.dataset["request"] = entry.requestId;\n const flip = () => {\n if (state.open.has(entry.requestId)) state.open.delete(entry.requestId);\n else state.open.add(entry.requestId);\n app.drawNow();\n rootNode().querySelector(`button.row-toggle[data-request="${cssEscape(entry.requestId)}"]`)?.focus();\n };\n toggle.addEventListener("click", (event) => {\n event.stopPropagation();\n flip();\n });\n tr.addEventListener("click", flip);\n return tr;\n }\n function buildDetail(entry) {\n const tr = el("tr", "detail");\n const cell = el("td");\n cell.colSpan = 6;\n if (!SECTIONS.evidence) {\n cell.appendChild(\n el(\n "div",\n "ev-meta",\n "The evidence section is switched off on this dashboard, so the reasons behind this verdict are not sent to it. What fired, and why, is on a dashboard that has `sections: { evidence: true }`."\n )\n );\n } else if (entry.evidence.length === 0) {\n cell.appendChild(\n el(\n "div",\n "ev-meta",\n entry.bypass !== void 0 ? `Detection was skipped for this request: ${entry.bypass}.` : "No detector produced any evidence. This is what ordinary traffic looks like."\n )\n );\n } else {\n const list = el("div", "ev");\n for (const item of entry.evidence) {\n const row = el("div", `ev-item${item.direction === "human" ? " human" : ""}`);\n row.appendChild(el("div", `tier t-${item.certainty}`, item.certainty));\n const body = el("div");\n body.appendChild(el("div", null, item.summary));\n let meta = `${item.detector} \xB7 points to ${item.direction}`;\n if (item.family !== void 0) meta += ` \xB7 family \u201C${item.family}\u201D, counted once with its siblings`;\n body.appendChild(el("div", "ev-meta", meta));\n if (item.deterministicBasis !== void 0) body.appendChild(el("div", "basis", item.deterministicBasis));\n row.appendChild(body);\n list.appendChild(row);\n }\n cell.appendChild(list);\n }\n for (const failure of entry.failures) {\n cell.appendChild(el("div", "ev-meta", `Detector ${failure.detector} ${failure.reason}: ${failure.message}`));\n }\n if (entry.downgradeReason !== void 0) cell.appendChild(el("div", "basis", `Guard: ${entry.downgradeReason}`));\n const queryNames = Object.keys(entry.query);\n if (queryNames.length > 0) {\n const queryTable = el("table", "hdr");\n for (const name of queryNames) {\n const value = entry.query[name] ?? "";\n const row = el("tr");\n row.appendChild(el("td", "n", `?${name}`));\n row.appendChild(el("td", `v${value === "[redacted]" ? " red" : ""}`, value));\n queryTable.appendChild(row);\n }\n cell.appendChild(queryTable);\n }\n if (entry.headers !== void 0 && entry.headers.length > 0) {\n const table = el("table", "hdr");\n for (const [name, value] of entry.headers) {\n const row = el("tr");\n row.appendChild(el("td", "n", `${name}:`));\n row.appendChild(el("td", `v${value === "[redacted]" ? " red" : ""}`, value));\n table.appendChild(row);\n }\n cell.appendChild(table);\n }\n const tools = el("div", "tools");\n if (SECTIONS.evidence) {\n tools.appendChild(copyButton("Copy replay line", () => replayLine(entry)));\n tools.appendChild(downloadButton("Download replay line", () => replayLine(entry), `request-${entry.requestId}.jsonl`));\n tools.appendChild(copyButton("Copy corpus case", () => corpusCase(entry)));\n }\n if (SECTIONS.policy) {\n const draft2 = el("button", null, "Draft a rule");\n draft2.title = "Start a rule from this request, in the policy editor";\n draft2.addEventListener("click", (event) => {\n event.stopPropagation();\n void draftIntoEditor(entry);\n });\n tools.appendChild(draft2);\n }\n if (SECTIONS.actors) {\n const actorButton = el("button", null, "Show this actor");\n actorButton.addEventListener("click", (event) => {\n event.stopPropagation();\n openActor(entry.actor);\n });\n tools.appendChild(actorButton);\n }\n cell.appendChild(tools);\n const foot = el("div", "detail-foot");\n foot.appendChild(el("span", null, clockTime(entry.at)));\n foot.appendChild(el("span", null, `actor ${entry.actor}`));\n if (entry.rule !== void 0) foot.appendChild(el("span", null, `rule \u201C${entry.rule}\u201D`));\n foot.appendChild(el("span", null, `assessed in ${entry.durationMs.toFixed(3)}ms`));\n foot.appendChild(el("span", "mono", entry.requestId));\n cell.appendChild(foot);\n tr.appendChild(cell);\n return tr;\n }\n function copyButton(label2, produce) {\n const button = el("button", null, label2);\n button.addEventListener("click", (event) => {\n event.stopPropagation();\n const text = produce();\n const done = () => {\n button.textContent = "Copied";\n setTimeout(() => {\n button.textContent = label2;\n }, 1200);\n };\n if (navigator.clipboard?.writeText !== void 0) navigator.clipboard.writeText(text).then(done, () => showText(text));\n else showText(text);\n });\n return button;\n }\n function downloadButton(label2, produce, filename) {\n const button = el("button", null, label2);\n button.addEventListener("click", (event) => {\n event.stopPropagation();\n download(`${produce()}\n`, filename, "application/x-ndjson");\n });\n return button;\n }\n function showText(text) {\n const box = el("pre", "code", text);\n const host = $("policy-result");\n host.hidden = false;\n host.className = "result";\n clear(host);\n host.appendChild(el("div", "ev-meta", "Copying needs a secure context; here is the text."));\n host.appendChild(box);\n const selection = getSelection();\n if (selection !== null) {\n const range = document.createRange();\n range.selectNodeContents(box);\n selection.removeAllRanges();\n selection.addRange(range);\n }\n }\n\n // src/dashboard/client/stream.ts\n var source;\n function connectStream() {\n if (!SECTIONS.feed) {\n $("dot").className = "dot";\n $("conn").textContent = "feed off";\n return;\n }\n if (source !== void 0) return;\n source = new EventSource(`${API}/api/stream`);\n source.addEventListener("open", () => {\n $("dot").className = "dot on";\n $("conn").textContent = "live";\n });\n source.addEventListener("sync", (event) => {\n const detail = JSON.parse(event.data);\n if (!detail.replace) return;\n clearFeed();\n resetFeedCache();\n });\n source.addEventListener("entry", (event) => {\n ingest(JSON.parse(event.data));\n if (state.paused) {\n state.bufferedWhilePaused++;\n $("pause").textContent = `Resume (${state.bufferedWhilePaused})`;\n }\n app.draw();\n });\n source.addEventListener("update", (event) => {\n ingest(JSON.parse(event.data));\n app.draw();\n });\n source.addEventListener("reset", () => {\n clearFeed();\n resetFeedCache();\n app.draw();\n });\n source.addEventListener("lagged", (event) => {\n const detail = JSON.parse(event.data);\n state.laggedDrops += detail.dropped;\n app.draw();\n });\n source.addEventListener("stats", (event) => {\n state.snapshot = JSON.parse(event.data);\n app.draw();\n });\n source.addEventListener("error", () => {\n $("dot").className = "dot off";\n $("conn").textContent = "reconnecting\u2026";\n });\n }\n async function loadInitialSnapshot() {\n try {\n state.snapshot = await getJson("/api/stats");\n app.drawNow();\n } catch {\n }\n }\n\n // src/dashboard/client/panels.ts\n var paintedSnapshot;\n function drawTiles(force = false) {\n if (!SECTIONS.statistics) return;\n const box = $("tiles");\n const snapshot = state.snapshot;\n if (snapshot === void 0) return;\n if (!force && paintedSnapshot === snapshot) return;\n paintedSnapshot = snapshot;\n const metrics = snapshot.metrics;\n clear(box);\n if (metrics === void 0) {\n box.appendChild(el("div", "note", "Counters are switched off on this handler (metrics: false). The live feed still works."));\n return;\n }\n const actions = metrics.actions;\n const denied = actions.block + actions.drop + actions.redirect;\n const mitigated = actions.challenge + actions["rate-limit"] + actions.delay;\n const served = actions.allow + actions.tag + actions.log;\n const total = metrics.requests;\n const unremarkable = metrics.verdicts.unknown + metrics.verdicts.human;\n const provenBotCount = provenBots(metrics.verdicts);\n const actors = SECTIONS.registry ? { tab: "actors", label: "Actors" } : void 0;\n const tiles = [\n ["", n(total), "Requests", "since start", void 0],\n ["proven", n(provenBotCount), "Proven bots", `${pct(provenBotCount, total)} of traffic`, void 0],\n ["warn", n(metrics.verdicts["suspected-bot"]), "Suspected", "never denied alone", void 0],\n ["", n(unremarkable), "Unremarkable", `${pct(unremarkable, total)} of traffic`, void 0],\n ["warn", n(metrics.downgrades), "Guard stops", metrics.downgrades > 0 ? "a rule over-reached" : "no rule overreached", void 0],\n ["crit", n(denied), "Denied", `${pct(denied, total)} of traffic`, void 0],\n [\n "",\n n(mitigated),\n "Mitigated",\n metrics.challenges.issued > 0 ? `${n(metrics.challenges.solved)} of ${n(metrics.challenges.issued)} challenges solved` : "challenged or limited",\n void 0\n ],\n ["good", n(served), "Served", `${pct(served, total)} of traffic`, void 0],\n ["", n(metrics.actorsTracked), "Actors tracked", "in the registry now", actors]\n ];\n for (const [kind, value, key, sub, goes] of tiles) {\n const tile = goes === void 0 ? el("div", `tile ${kind}`) : el("button", `tile ${kind} go`);\n tile.appendChild(el("div", "v tnum", value));\n tile.appendChild(el("div", "k", key));\n tile.appendChild(el("div", "s", sub));\n if (goes !== void 0) {\n tile.type = "button";\n tile.appendChild(el("span", "sr-only", `. Show the ${goes.label} screen`));\n tile.addEventListener("click", () => app.showTab(goes.tab, { replace: false }));\n }\n box.appendChild(tile);\n }\n }\n function drawChips() {\n const snapshot = state.snapshot;\n if (snapshot === void 0) return;\n const box = $("chips");\n clear(box);\n const facts = [\n // Which process this is, first, because everything after it is a fact about this\n // process and nothing else. Behind a load balancer there are as many of these\n // dashboards as there are pods, each showing its own share of the traffic.\n ["instance", snapshot.instance],\n ["guard", snapshot.policy.falsePositivePolicy],\n ["suspect at", String(snapshot.policy.suspectThreshold)]\n ];\n if (SECTIONS.statistics) facts.push(["detectors", String(snapshot.detectors.length)]);\n if (SECTIONS.policy) facts.push(["rules", String(snapshot.rules.length)]);\n facts.push(["uptime", uptime(snapshot.now - snapshot.startedAt)]);\n for (const [label2, value] of facts) {\n const fact = el("span");\n fact.appendChild(document.createTextNode(`${label2} `));\n fact.appendChild(el("b", null, value));\n box.appendChild(fact);\n }\n }\n function updateWindowLabels() {\n const label2 = windowLabel(state.rows.length, oldestAt(), Date.now());\n for (const node of Array.from(rootNode().querySelectorAll(".win"))) node.textContent = label2;\n }\n function drawLivePanels() {\n if (!SECTIONS.statistics) return;\n const totals = aggregate(state.rows);\n drawBars($("live-detectors"), totals.detectors, "Nothing has fired in this window.");\n if (SECTIONS.actors) drawBars($("live-actors"), totals.actors, "No traffic in this window.");\n }\n function drawStatsPanels() {\n const snapshot = state.snapshot;\n if (snapshot === void 0) return;\n const metrics = snapshot.metrics;\n if (metrics !== void 0) {\n drawBars($("stat-verdicts"), pairs(metrics.verdicts), "Nothing assessed yet.");\n drawBars($("stat-actions"), pairs(metrics.actions), "No decisions yet.");\n drawBars($("stat-classes"), pairs(metrics.botClasses), "Nothing classified yet.");\n drawBars($("stat-detectors"), pairs(metrics.detectorFirings), "No detector has produced evidence yet.");\n const challenges = $("stat-challenges");\n clear(challenges);\n if (!snapshot.policy.challengeEnabled) {\n challenges.appendChild(el("div", "note", "No challenge is configured, so a rule asking for one degrades to a tag. Set challenge.secrets to enable it."));\n } else {\n const funnel = [\n ["Issued", n(metrics.challenges.issued)],\n ["Solved", n(metrics.challenges.solved)],\n ["Rejected", n(metrics.challenges.rejected)],\n ["Solve rate", metrics.challenges.issued > 0 ? pct(metrics.challenges.solved, metrics.challenges.issued) : "\u2014"]\n ];\n for (const [key, value] of funnel) challenges.appendChild(statRow(key, value));\n }\n const health = $("stat-health");\n clear(health);\n const bypassed = metrics.bypassed.allowlist + metrics.bypassed["ignored-path"];\n const rows = [\n ["Requests assessed", n(metrics.requests)],\n ["Bypassed \u2014 allowlist", n(metrics.bypassed.allowlist)],\n ["Bypassed \u2014 ignored path", n(metrics.bypassed["ignored-path"])],\n ["Detection ran on", pct(metrics.requests - bypassed, metrics.requests)],\n ["Actors tracked", n(metrics.actorsTracked)],\n ["Guard stops", n(metrics.downgrades)]\n ];\n const failures = pairs(metrics.detectorFailures);\n for (const [detector, count] of failures) rows.push([`Detector failures \u2014 ${detector}`, n(count)]);\n for (const [key, value] of rows) health.appendChild(statRow(key, value));\n if (failures.length === 0) health.appendChild(el("div", "note", "No detector has thrown or timed out."));\n }\n const totals = aggregate(state.rows);\n if (SECTIONS.actors) drawBars($("stat-identities"), totals.identities, "No client has named itself in this window.");\n drawBars($("stat-paths"), totals.paths, "No traffic in this window.");\n drawBars($("stat-denied-paths"), totals.deniedPaths, "Nothing has been denied in this window.");\n drawBars($("stat-guard"), totals.guardStops, "No rule has asked for more than its evidence supports.");\n drawBars($("stat-bypassed"), totals.bypassed, "Nothing bypassed detection.");\n drawRuleHits(totals);\n const list = $("stat-detector-list");\n clear(list);\n $("detector-count").textContent = `${snapshot.detectors.length} installed`;\n for (const detector of snapshot.detectors) {\n const row = el("div", "det");\n const left = el("div");\n left.appendChild(el("div", "mono", detector.id));\n left.appendChild(el("div", "d", detector.description));\n row.appendChild(left);\n const fires = metrics?.detectorFirings[detector.id] ?? 0;\n const timing = metrics?.detectorTimings[detector.id];\n let right = `${n(fires)} \xB7 ${detector.cost} \xB7 ${detector.stage}`;\n if (timing !== void 0 && timing.count > 0) right += ` \xB7 ${ms(timing.totalMs / timing.count)} avg`;\n row.appendChild(el("div", "n", right));\n list.appendChild(row);\n }\n }\n function statRow(key, value) {\n const line = el("div", "stat-row");\n line.appendChild(el("span", "k", key));\n line.appendChild(el("span", "v", value));\n return line;\n }\n function drawRuleHits(totals) {\n const target = $("stat-rule-hits");\n clear(target);\n const rules = state.snapshot?.rules ?? [];\n if (rules.length === 0) {\n target.appendChild(el("div", "note", "No rules configured."));\n return;\n }\n let max = 1;\n for (const rule of rules) max = Math.max(max, totals.ruleHits.get(rule) ?? 0);\n for (const rule of rules) {\n const value = totals.ruleHits.get(rule) ?? 0;\n const bar = el("div", `bar${value === 0 ? " dead" : ""}`);\n const track = el("div", "track");\n if (value > 0) {\n const fill = el("div", "fill");\n fill.style.width = `${Math.max(2, Math.round(value / max * 100))}%`;\n track.appendChild(fill);\n }\n track.appendChild(el("div", "lbl", rule));\n bar.appendChild(track);\n bar.appendChild(el("div", "v tnum", value === 0 ? "never" : n(value)));\n target.appendChild(bar);\n }\n }\n function drawAudit() {\n const snapshot = state.snapshot;\n if (snapshot === void 0) return;\n const body = $("audit-body");\n const checks = $("audit-checks");\n clear(body);\n clear(checks);\n const audit = snapshot.audit;\n if (audit === void 0) {\n $("audit-spans").textContent = "";\n body.appendChild(\n el(\n "div",\n "note",\n "The traffic audit is switched off on this handler (audit: false). It watches the shape of your traffic rather than any one request \u2014 a spike in automation, a collapse in human traffic, a policy suddenly denying far more than usual."\n )\n );\n return;\n }\n const current = audit.window;\n const baseline = audit.baseline;\n $("audit-spans").textContent = `last ${rangeLabel(current.spanMs)} against the ${rangeLabel(baseline.spanMs)} before`;\n const rows = [\n ["Requests", n(current.requests), n(baseline.requests)],\n ["Rate", `${current.rate.toFixed(1)}/min`, `${baseline.rate.toFixed(1)}/min`],\n ["Bot share", `${Math.round(current.botShare * 100)}%`, `${Math.round(baseline.botShare * 100)}%`],\n ["Bots", n(current.bots), n(baseline.bots)],\n ["Humans", n(current.humans), n(baseline.humans)],\n ["Denials", n(current.denials), n(baseline.denials)],\n ["Challenges", n(current.challenges), n(baseline.challenges)],\n ["Guard stops", n(current.downgrades), n(baseline.downgrades)],\n ["Detector failures", n(current.failures), n(baseline.failures)],\n ["Bypassed", n(current.bypassed), n(baseline.bypassed)]\n ];\n for (const [key, now, was] of rows) {\n const line = el("div", "stat-row");\n line.appendChild(el("span", "k", key));\n const values = el("span", "v");\n values.appendChild(el("b", null, now));\n values.appendChild(el("span", "was", ` was ${was}`));\n line.appendChild(values);\n body.appendChild(line);\n }\n if (audit.checks.length === 0) {\n checks.appendChild(el("div", "note", "No checks are installed, so nothing here will ever raise an anomaly."));\n return;\n }\n for (const check of audit.checks) {\n const row = el("div", "det");\n const left = el("div");\n left.appendChild(el("div", "mono", check.id));\n left.appendChild(el("div", "d", check.description));\n row.appendChild(left);\n checks.appendChild(row);\n }\n }\n function drawNoticeBadge() {\n const notices = state.snapshot?.notices ?? [];\n const badge = $("notice-badge");\n badge.hidden = notices.length === 0 || !SECTIONS.notices;\n badge.textContent = String(notices.length);\n }\n function drawNotices() {\n const box = $("stat-notices");\n const notices = state.snapshot?.notices ?? [];\n clear(box);\n $("notice-count").textContent = notices.length > 0 ? `${notices.length} total` : "";\n if (notices.length === 0) {\n box.appendChild(el("div", "note", "Nothing to report: no startup warnings, no detector errors."));\n return;\n }\n for (const notice of notices.slice().reverse().slice(0, 40)) {\n const row = el("div", `notice ${notice.kind}`);\n const when = el("div", "when");\n when.appendChild(el("div", "tag", notice.kind));\n when.appendChild(el("div", null, clockTime(notice.at)));\n row.appendChild(when);\n const body = el("div");\n body.appendChild(el("div", null, notice.message));\n if (notice.source !== void 0) body.appendChild(el("div", "ev-meta", notice.source));\n row.appendChild(body);\n box.appendChild(row);\n }\n }\n function drawChanges() {\n if (!SECTIONS.changes) return;\n const box = $("stat-changes");\n const changes = state.snapshot?.changes ?? [];\n clear(box);\n $("change-count").textContent = changes.length > 0 ? `${changes.length} this run` : "";\n if (changes.length === 0) {\n box.appendChild(el("div", "note", "Nothing has been changed at runtime. Rules, guard and ranges are as the code that built this handler left them."));\n return;\n }\n for (const change of changes.slice().reverse()) {\n const row = el("div", "notice");\n const when = el("div", "when");\n when.appendChild(el("div", "tag", change.kind));\n when.appendChild(el("div", null, clockTime(change.at)));\n row.appendChild(when);\n const body = el("div");\n body.appendChild(el("div", null, change.summary));\n body.appendChild(el("div", "ev-meta", change.by === void 0 || change.by === "" ? "by an unnamed viewer \u2014 this listener\'s auth carries no identity" : `by ${change.by}`));\n row.appendChild(body);\n box.appendChild(row);\n }\n }\n function drawPeers() {\n const box = $("peers");\n if (BOOT.peers.length === 0) {\n box.hidden = true;\n return;\n }\n if (box.childElementCount > 0) return;\n box.appendChild(el("span", "hint", "also:"));\n for (const peer of BOOT.peers) {\n const link = el("a", "linkbtn", peer.label);\n link.href = peer.href;\n link.rel = "noreferrer noopener";\n box.appendChild(link);\n }\n }\n\n // src/dashboard/client/charts.ts\n var BUCKETS = 60;\n var LATENCY_BOUNDS = [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 25, 50, 100];\n var SCORE_BOUNDS = 10;\n function positionTip(tip, host, clientX, boxLeft) {\n tip.style.opacity = "1";\n tip.style.left = `${Math.min(host.clientWidth - 150, Math.max(4, clientX - boxLeft - 60))}px`;\n tip.style.top = "14px";\n }\n function tipRow(label2, value, colour) {\n const line = el("div", "r");\n const left = el("em");\n if (colour !== void 0) {\n const swatch = el("span", "swatch");\n swatch.style.background = colour;\n left.appendChild(swatch);\n }\n left.appendChild(document.createTextNode(label2));\n line.appendChild(left);\n line.appendChild(el("b", null, value));\n return line;\n }\n function timeline() {\n const bucketMs = state.rangeMs / BUCKETS;\n const now = Date.now();\n const start2 = now - state.rangeMs;\n const buckets = [];\n for (let i = 0; i < BUCKETS; i++) buckets.push({ at: start2 + i * bucketMs, served: 0, mitigated: 0, denied: 0, total: 0 });\n for (const { entry } of state.rows) {\n const index = Math.floor((entry.at - start2) / bucketMs);\n if (index < 0 || index >= BUCKETS) continue;\n const bucket = buckets[index];\n if (bucket === void 0) continue;\n bucket.total++;\n const out = outcome(entry);\n if (out === "deny") bucket.denied++;\n else if (out === "mitigate") bucket.mitigated++;\n else bucket.served++;\n }\n return buckets;\n }\n function drawTraffic() {\n const host = $("traffic-chart");\n const svg = $("traffic");\n const width = Math.max(320, host.clientWidth - 30);\n const height = 190;\n const padBottom = 26;\n const markerRow = 8;\n const plot = height - padBottom - markerRow;\n const buckets = timeline();\n const now = Date.now();\n const start2 = now - state.rangeMs;\n let peak = 1;\n for (const bucket of buckets) if (bucket.total > peak) peak = bucket.total;\n const max = Math.max(2, Math.ceil(peak / 2) * 2);\n clear(svg);\n svg.setAttribute("viewBox", `0 0 ${width} ${height}`);\n svg.setAttribute("height", String(height));\n const s1 = css("--s1");\n const s2 = css("--s2");\n const crit = css("--crit");\n const grid = css("--grid");\n const muted = css("--muted");\n const step = width / BUCKETS;\n const barWidth = Math.max(2, step - 2);\n for (const fraction of [0, 0.5, 1]) {\n const y = markerRow + plot - fraction * plot;\n svg.appendChild(svgEl("line", { class: "gridline", x1: 0, x2: width, y1: y, y2: y, stroke: grid, "stroke-width": 1 }));\n if (fraction > 0) svg.appendChild(svgText({ x: 2, y: y - 3, fill: muted, "font-size": 10 }, Math.round(max * fraction)));\n }\n buckets.forEach((bucket, index) => {\n const x = index * step + 1;\n const servedHeight = bucket.served / max * plot;\n const mitigatedHeight = bucket.mitigated / max * plot;\n let y = markerRow + plot;\n if (servedHeight > 0) {\n y -= servedHeight;\n svg.appendChild(svgEl("rect", { x, y, width: barWidth, height: servedHeight, fill: s1, rx: 2 }));\n }\n if (mitigatedHeight > 0) {\n y -= mitigatedHeight + (servedHeight > 0 ? 2 : 0);\n svg.appendChild(svgEl("rect", { x, y: Math.max(markerRow, y), width: barWidth, height: mitigatedHeight, fill: s2, rx: 2 }));\n }\n if (bucket.denied > 0) svg.appendChild(svgEl("rect", { x, y: 0, width: barWidth, height: 5, fill: crit, rx: 2 }));\n });\n svg.appendChild(svgEl("line", { x1: 0, x2: width, y1: markerRow + plot, y2: markerRow + plot, stroke: css("--line"), "stroke-width": 1 }));\n const span = rangeLabel(state.rangeMs);\n const labels = [\n [0, `${span} ago`],\n [BUCKETS / 2, rangeLabel(state.rangeMs / 2)],\n [BUCKETS - 1, "now"]\n ];\n for (const [position, text] of labels) {\n svg.appendChild(svgText({ x: Math.min(width - 26, Math.max(0, position * step)), y: height - 8, fill: muted, "font-size": 10 }, text));\n }\n const changes = (state.snapshot?.changes ?? []).filter((change) => change.at >= start2 && change.at <= now);\n const markColour = css("--proven-text");\n for (const change of changes) {\n const x = (change.at - start2) / state.rangeMs * width;\n svg.appendChild(svgEl("line", { x1: x, x2: x, y1: 0, y2: markerRow + plot, stroke: markColour, "stroke-width": 1.5, "stroke-dasharray": "3 2", opacity: 0.85 }));\n const dot = svgEl("circle", { cx: x, cy: 3, r: 3, fill: markColour });\n const title = svgEl("title");\n title.textContent = `${clockTime(change.at)} \xB7 ${change.kind}: ${change.summary}${change.by === void 0 ? "" : ` (by ${change.by})`}`;\n dot.appendChild(title);\n svg.appendChild(dot);\n }\n const hover = svgEl("rect", { x: 0, y: 0, width: 0, height: markerRow + plot, fill: css("--ink"), opacity: 0.06 });\n svg.appendChild(hover);\n const tip = $("traffic-tip");\n host.onmousemove = (event) => {\n const box = svg.getBoundingClientRect();\n const index = Math.floor((event.clientX - box.left) / box.width * BUCKETS);\n const bucket = buckets[index];\n if (bucket === void 0) {\n tip.style.opacity = "0";\n hover.setAttribute("width", "0");\n return;\n }\n hover.setAttribute("x", String(index * step));\n hover.setAttribute("width", String(step));\n clear(tip);\n tip.appendChild(el("div", "t", `${clockTime(bucket.at)} \xB7 ${Math.round(state.rangeMs / BUCKETS / 1e3)}s`));\n tip.appendChild(tipRow("Served", n(bucket.served), s1));\n tip.appendChild(tipRow("Mitigated", n(bucket.mitigated), s2));\n tip.appendChild(tipRow("Denied", n(bucket.denied), crit));\n positionTip(tip, host, event.clientX, box.left);\n };\n host.onmouseleave = () => {\n tip.style.opacity = "0";\n hover.setAttribute("width", "0");\n };\n const total = buckets.reduce((sum, bucket) => sum + bucket.total, 0);\n $("traffic-window").textContent = `last ${rangeLabel(state.rangeMs)}`;\n const served = buckets.reduce((sum, bucket) => sum + bucket.served, 0);\n const mitigated = buckets.reduce((sum, bucket) => sum + bucket.mitigated, 0);\n const denied = buckets.reduce((sum, bucket) => sum + bucket.denied, 0);\n const busiest = buckets.reduce((best, bucket) => bucket.total > best.total ? bucket : best, buckets[0] ?? { at: now, total: 0, served: 0, mitigated: 0, denied: 0 });\n $("traffic-alt").textContent = `Traffic over the last ${rangeLabel(state.rangeMs)}: ${n(total)} requests \u2014 ${n(served)} served, ${n(mitigated)} mitigated, ${n(denied)} denied. ` + (total === 0 ? "No traffic in this range." : `Busiest ${Math.round(state.rangeMs / BUCKETS / 1e3)}-second interval: ${n(busiest.total)} requests at ${clockTime(busiest.at)}.`) + (changes.length === 0 ? "" : ` ${n(changes.length)} runtime change${changes.length === 1 ? "" : "s"} in this range: ${changes.map((change) => `${change.kind}, ${change.summary}`).join("; ")}.`);\n const legend = $("traffic-legend");\n clear(legend);\n legend.appendChild(el("span", null, `${n(total)} requests in the last ${rangeLabel(state.rangeMs)} \xB7`));\n const oldest = oldestAt();\n if (oldest !== void 0 && Date.now() - oldest < state.rangeMs * 0.9) {\n legend.appendChild(el("span", null, `window holds ${rangeLabel(Date.now() - oldest)} \xB7`));\n }\n for (const [label2, colour] of [\n ["Served", s1],\n ["Mitigated \u2014 challenged, limited or delayed", s2],\n ["Denied", crit]\n ]) {\n const item = el("span");\n const swatch = el("span", "swatch");\n swatch.style.background = colour;\n item.appendChild(swatch);\n item.appendChild(document.createTextNode(label2));\n legend.appendChild(item);\n }\n if (changes.length > 0) {\n const item = el("span");\n item.appendChild(el("span", "mark"));\n item.appendChild(document.createTextNode(`${n(changes.length)} runtime change${changes.length === 1 ? "" : "s"} \u2014 hover a marker`));\n legend.appendChild(item);\n }\n }\n function differences(cumulative) {\n const counts = [];\n for (let i = 0; i < cumulative.length; i++) counts.push((cumulative[i] ?? 0) - (i > 0 ? cumulative[i - 1] ?? 0 : 0));\n return counts;\n }\n function drawScores() {\n const host = $("score-chart");\n const svg = $("scores");\n clear(svg);\n const metrics = state.snapshot?.metrics;\n const fromRun = state.scoreScope === "run" && metrics !== void 0;\n let buckets;\n let scored;\n let proven;\n if (fromRun && metrics !== void 0) {\n buckets = differences(metrics.scores.buckets);\n scored = metrics.scores.count;\n proven = metrics.proven;\n } else {\n buckets = new Array(SCORE_BOUNDS).fill(0);\n scored = 0;\n proven = 0;\n for (const { entry } of state.rows) {\n if (entry.bypass !== void 0) continue;\n if (entry.certain) {\n proven++;\n continue;\n }\n const index = Math.min(SCORE_BOUNDS - 1, Math.floor(entry.score / 10));\n buckets[index] = (buckets[index] ?? 0) + 1;\n scored++;\n }\n }\n const width = Math.max(280, host.clientWidth - 30);\n const height = 190;\n const padBottom = 30;\n const plot = height - padBottom;\n let max = 1;\n for (const value of buckets) if (value > max) max = value;\n svg.setAttribute("viewBox", `0 0 ${width} ${height}`);\n svg.setAttribute("height", String(height));\n const fill = css("--s1");\n const muted = css("--muted");\n const grid = css("--grid");\n const crit = css("--crit");\n svg.appendChild(svgEl("line", { class: "gridline", x1: 0, x2: width, y1: plot, y2: plot, stroke: grid, "stroke-width": 1 }));\n const step = width / SCORE_BOUNDS;\n buckets.forEach((value, index) => {\n const barHeight = value / max * (plot - 8);\n const barWidth = Math.max(2, Math.min(step - 6, 56));\n if (barHeight > 0) {\n svg.appendChild(svgEl("rect", { x: index * step + (step - barWidth) / 2, y: plot - barHeight, width: barWidth, height: barHeight, fill, rx: 3 }));\n }\n svg.appendChild(svgText({ x: index * step + 1, y: height - 14, fill: muted, "font-size": 9.5 }, index * 10));\n });\n const threshold = state.snapshot?.policy.suspectThreshold ?? 60;\n const x = threshold / 100 * width;\n svg.appendChild(svgEl("line", { x1: x, x2: x, y1: 0, y2: plot, stroke: crit, "stroke-width": 2, "stroke-dasharray": "4 3" }));\n svg.appendChild(svgText({ x: Math.min(width - 92, x + 5), y: 11, fill: crit, "font-size": 10 }, `suspect at ${threshold}`));\n svg.appendChild(svgText({ x: 0, y: height - 2, fill: muted, "font-size": 10 }, "score (probabilistic requests only)"));\n const tip = $("score-tip");\n host.onmousemove = (event) => {\n const box = svg.getBoundingClientRect();\n const index = Math.floor((event.clientX - box.left) / box.width * SCORE_BOUNDS);\n const value = buckets[index];\n if (value === void 0) {\n tip.style.opacity = "0";\n return;\n }\n clear(tip);\n tip.appendChild(el("div", "t", `score ${index * 10}\u2013${index * 10 + 9}`));\n tip.appendChild(tipRow("requests", n(value)));\n tip.appendChild(tipRow("share", pct(value, scored)));\n positionTip(tip, host, event.clientX, box.left);\n };\n host.onmouseleave = () => {\n tip.style.opacity = "0";\n };\n let over = 0;\n for (let bucket = 0; bucket < SCORE_BOUNDS; bucket++) if (bucket * 10 >= threshold) over += buckets[bucket] ?? 0;\n const legend = $("score-legend");\n clear(legend);\n legend.appendChild(el("span", null, `${n(scored)} scored \xB7 ${n(over)} at or over the threshold \xB7 ${n(proven)} proven, which carry no score`));\n $("score-alt").textContent = `Distribution of probabilistic scores ${fromRun ? "since start" : "in the retained window"}, with the suspect threshold at ${threshold}. ${n(scored)} scored requests, ${n(over)} at or over the threshold, ${n(proven)} proven and therefore unscored. ` + (scored === 0 ? "Nothing scored yet." : `By ten-point band: ${buckets.map((value, index) => `${index * 10}\u2013${index * 10 + 9}: ${n(value)}`).join(", ")}.`);\n $("score-window").textContent = fromRun ? "since start" : windowLabel(state.rows.length, oldestAt(), Date.now());\n if (state.scoreScope === "run" && metrics === void 0) {\n legend.appendChild(el("span", null, "\xB7 counters are off on this handler, so this is the retained window"));\n }\n }\n function drawLatency() {\n const host = $("latency-chart");\n const svg = $("latency");\n clear(svg);\n const metrics = state.snapshot?.metrics;\n if (metrics === void 0 || metrics.duration.count === 0) {\n $("latency-summary").textContent = "No assessments yet.";\n $("latency-alt").textContent = "Assessment latency: no assessments yet.";\n svg.setAttribute("viewBox", "0 0 100 40");\n svg.setAttribute("height", "40");\n svg.appendChild(svgText({ x: 0, y: 20, fill: css("--muted"), "font-size": 11 }, "No assessments yet."));\n return;\n }\n const cumulative = metrics.duration.buckets;\n const counts = differences(cumulative);\n const width = Math.max(280, host.clientWidth - 30);\n const height = 190;\n const padBottom = 30;\n const plot = height - padBottom;\n let max = 1;\n for (const value of counts) if (value > max) max = value;\n svg.setAttribute("viewBox", `0 0 ${width} ${height}`);\n svg.setAttribute("height", String(height));\n const fill = css("--s1");\n const muted = css("--muted");\n const grid = css("--grid");\n svg.appendChild(svgEl("line", { class: "gridline", x1: 0, x2: width, y1: plot, y2: plot, stroke: grid, "stroke-width": 1 }));\n const step = width / counts.length;\n counts.forEach((value, index) => {\n const barHeight = value / max * (plot - 6);\n if (barHeight > 0) {\n svg.appendChild(svgEl("rect", { x: index * step + 1, y: plot - barHeight, width: Math.max(2, step - 3), height: barHeight, fill, rx: 3 }));\n }\n if (index % 2 === 0) {\n svg.appendChild(svgText({ x: index * step + 1, y: height - 14, fill: muted, "font-size": 9.5 }, index < LATENCY_BOUNDS.length ? String(LATENCY_BOUNDS[index]) : "more"));\n }\n });\n svg.appendChild(svgText({ x: 0, y: height - 2, fill: muted, "font-size": 10 }, "milliseconds (upper bound of each bucket)"));\n const tip = $("latency-tip");\n host.onmousemove = (event) => {\n const box = svg.getBoundingClientRect();\n const index = Math.floor((event.clientX - box.left) / box.width * counts.length);\n const value = counts[index];\n if (value === void 0) {\n tip.style.opacity = "0";\n return;\n }\n clear(tip);\n tip.appendChild(el("div", "t", index < LATENCY_BOUNDS.length ? `\u2264 ${LATENCY_BOUNDS[index]}ms` : "over 100ms"));\n tip.appendChild(tipRow("requests", n(value)));\n tip.appendChild(tipRow("share", pct(value, metrics.duration.count)));\n positionTip(tip, host, event.clientX, box.left);\n };\n host.onmouseleave = () => {\n tip.style.opacity = "0";\n };\n const mean = metrics.duration.totalMs / metrics.duration.count;\n const p95 = percentile(cumulative, metrics.duration.count, 0.95);\n $("latency-summary").textContent = `Time spent in detection, per request \xB7 mean ${ms(mean)} \xB7 p95 ${ms(p95)} \xB7 max ${ms(metrics.duration.maxMs)}`;\n $("latency-alt").textContent = `Assessment latency since start over ${n(metrics.duration.count)} requests: mean ${ms(mean)}, 95th percentile ${ms(p95)}, maximum ${ms(metrics.duration.maxMs)}. By bucket: ${counts.map((value, index) => `${index < LATENCY_BOUNDS.length ? `up to ${LATENCY_BOUNDS[index]}ms` : "over 100ms"}: ${n(value)}`).join(", ")}.`;\n }\n function percentile(cumulative, count, fraction) {\n const target = count * fraction;\n const last = LATENCY_BOUNDS[LATENCY_BOUNDS.length - 1] ?? 100;\n for (let i = 0; i < cumulative.length; i++) {\n if ((cumulative[i] ?? 0) >= target) return i < LATENCY_BOUNDS.length ? LATENCY_BOUNDS[i] ?? last : last;\n }\n return last;\n }\n\n // src/dashboard/client/registry.ts\n var timer;\n async function loadActors() {\n if (!SECTIONS.registry) return;\n try {\n const body = await getJson(`/api/actors?limit=${state.actorsPageSize}&offset=${state.actorsPage * state.actorsPageSize}`);\n state.actors = body.actors;\n state.actorsTracked = body.tracked;\n drawActors();\n } catch {\n }\n }\n function trackActors() {\n if (timer !== void 0) clearInterval(timer);\n timer = void 0;\n if (state.tab !== "actors") return;\n void loadActors();\n timer = setInterval(() => {\n if (state.tab !== "actors" || state.paused || isConfirming()) return;\n void loadActors();\n }, 4e3);\n }\n var ACTORS_PAGE_SIZES = [25, 50, 100, 200];\n function drawActorsPager(full) {\n const page = state.actorsPage;\n const hidden = page === 0 && !full;\n const from = page * state.actorsPageSize + 1;\n const model = {\n page,\n from,\n to: from + state.actors.length - 1,\n total: state.actorsTracked,\n atStart: page === 0,\n atEnd: !full,\n go: (next) => {\n state.actorsPage = Math.max(0, next);\n void loadActors();\n },\n size: {\n current: state.actorsPageSize,\n choices: ACTORS_PAGE_SIZES,\n set: (next) => {\n state.actorsPageSize = next;\n state.actorsPage = 0;\n void loadActors();\n }\n }\n };\n for (const [id, withSize] of [\n ["actors-pager-top", true],\n ["actors-pager", false]\n ]) {\n const host = $(id);\n host.hidden = hidden;\n if (hidden) clear(host);\n else renderPager(host, model, { withSize });\n }\n }\n function drawActors() {\n if (!SECTIONS.registry) return;\n if (isConfirming()) return;\n const body = byId("actor-rows");\n clear(body);\n const actors = state.actors;\n $("actors-count").textContent = `${n(actors.length)} shown \xB7 ${n(state.actorsTracked)} tracked`;\n drawActorsPager(actors.length === state.actorsPageSize);\n byId("actors-empty").hidden = actors.length > 0;\n for (const actor of actors) {\n const row = el("tr");\n row.appendChild(el("td", "who", actor.key));\n row.appendChild(el("td", "num tnum", n(actor.requests)));\n row.appendChild(el("td", "num tnum", n(actor.recentRate)));\n row.appendChild(el("td", "num tnum", n(actor.distinctPaths)));\n const cadence = el("td", "num tnum", actor.cadenceCv === void 0 ? "\u2014" : actor.cadenceCv.toFixed(2));\n if (actor.cadenceCv !== void 0 && actor.cadenceCv < 0.15) cadence.className += " warn-text";\n row.appendChild(cadence);\n row.appendChild(el("td", "num tnum", n(actor.priorConfirmations)));\n const unsolved = el("td", "num tnum", n(actor.unsolvedChallenges));\n if (actor.unsolvedChallenges >= 3) unsolved.className += " warn-text";\n row.appendChild(unsolved);\n const stateCell = el("td");\n const tags = [];\n if (actor.cleared) tags.push("cleared as human");\n if (actor.distinctUserAgents > 1) tags.push(`${actor.distinctUserAgents} User-Agents`);\n tags.push(`first seen ${clockStamp(actor.firstSeen)}`);\n stateCell.appendChild(el("div", "tagline", tags.join(" \xB7 ")));\n row.appendChild(stateCell);\n const actions = el("td", "acts");\n const inFeed = el("button", null, "In feed");\n inFeed.title = "Show this actor\'s requests in the live feed";\n inFeed.addEventListener("click", () => {\n setSearch(`actor:${actor.key}`);\n const search = byId("search");\n search.value = state.search;\n app.showTab("live");\n app.syncUrl();\n });\n actions.appendChild(inFeed);\n for (const button of actorActions(actor.key, () => void loadActors())) actions.appendChild(button);\n row.appendChild(actions);\n body.appendChild(row);\n }\n }\n\n // src/dashboard/client/tester.ts\n function initTester() {\n if (!SECTIONS.tester) return;\n $("test-run").addEventListener("click", () => void run());\n byId("test-input").addEventListener("keydown", (event) => {\n if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {\n event.preventDefault();\n void run();\n }\n });\n }\n async function run() {\n const raw2 = byId("test-input").value;\n if (raw2.trim() === "") {\n toast("warn", "Nothing to assess", "Paste a User-Agent, a curl command, or a header block.");\n return;\n }\n const result = await postJson("/api/test", {\n raw: raw2,\n ip: byId("test-ip").value.trim(),\n url: byId("test-url").value.trim()\n });\n const box = $("test-result");\n box.hidden = false;\n clear(box);\n if (!result.ok) {\n box.className = "result bad";\n box.appendChild(el("div", null, result.error ?? "The server could not read that."));\n return;\n }\n const { entry, reason, assumed } = result.data;\n const out = outcome(entry);\n box.className = `result ${out === "deny" ? "bad" : out === "mitigate" ? "warn" : "ok"}`;\n const head = el("div");\n const [badgeClass, badgeLabel] = verdictBadge(entry);\n head.appendChild(el("span", `badge ${badgeClass}`, badgeLabel));\n head.appendChild(document.createTextNode(" "));\n head.appendChild(el("b", null, entry.action ?? "no decision"));\n if (entry.rule !== void 0) head.appendChild(el("span", "ev-meta", ` via ${entry.rule}`));\n box.appendChild(head);\n box.appendChild(el("div", "ev-meta", `${entry.certain ? "proven" : `score ${entry.score}`} \xB7 assessed in ${ms(entry.durationMs)}`));\n if (entry.downgradedFrom !== void 0) {\n box.appendChild(el("div", "guard", `The guard stopped ${entry.downgradedFrom} here.`));\n if (entry.downgradeReason !== void 0) box.appendChild(el("div", "basis", entry.downgradeReason));\n }\n if (entry.evidence.length === 0) {\n box.appendChild(el("div", "ev-meta", entry.bypass !== void 0 ? `Detection was skipped: ${entry.bypass}.` : "No detector produced any evidence."));\n } else {\n const list = el("div", "ev");\n for (const item of entry.evidence) {\n const row = el("div", `ev-item${item.direction === "human" ? " human" : ""}`);\n row.appendChild(el("div", `tier t-${item.certainty}`, item.certainty));\n const detail = el("div");\n detail.appendChild(el("div", null, item.summary));\n detail.appendChild(el("div", "ev-meta", `${item.detector} \xB7 points to ${item.direction}`));\n if (item.deterministicBasis !== void 0) detail.appendChild(el("div", "basis", item.deterministicBasis));\n row.appendChild(detail);\n list.appendChild(row);\n }\n box.appendChild(list);\n }\n box.appendChild(el("div", "ev-meta", reason));\n const notes = [...assumed, "no history: this is assessed as a first request, so cadence and crawl breadth have nothing to read"];\n box.appendChild(el("div", "assumed", `Assumed \u2014 ${notes.join("; ")}.`));\n }\n\n // src/dashboard/client/index.ts\n var TABS = [\n ["tab-live", "live", SECTIONS.feed],\n ["tab-actors", "actors", SECTIONS.registry],\n ["tab-stats", "stats", SECTIONS.statistics],\n ["tab-policy", "policy", SECTIONS.policy]\n ];\n var available = TABS.filter(([, , enabled]) => enabled);\n var FIRST = available[0]?.[1] ?? "live";\n function tabIndexOf(name) {\n const at = available.findIndex(([, tab]) => tab === name);\n return at === -1 ? 0 : at;\n }\n function isTab(value) {\n return available.some(([, name]) => name === value);\n }\n function initSkipLink() {\n if (!isEmbedded()) return;\n const skip = rootNode().querySelector("a.skip");\n if (skip === null) return;\n skip.addEventListener("click", (event) => {\n event.preventDefault();\n const target = rootNode().querySelector(`#view-${state.tab}`) ?? rootNode().querySelector("#view-live");\n if (target === null) return;\n target.tabIndex = -1;\n target.focus();\n target.scrollIntoView({ block: "start" });\n });\n }\n function initHeader() {\n const box = $("links");\n for (const link of BOOT.links) {\n const anchor = el("a", "linkbtn", link.label);\n anchor.href = link.href;\n anchor.rel = "noreferrer noopener";\n box.appendChild(anchor);\n }\n let stored = null;\n try {\n stored = localStorage.getItem("bothandler-dashboard-theme");\n } catch {\n stored = null;\n }\n if (stored === "dark" || stored === "light") themeElement().setAttribute("data-theme", stored);\n $("theme").addEventListener("click", () => {\n let current = themeElement().getAttribute("data-theme");\n if (current === null) current = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";\n const next = current === "dark" ? "light" : "dark";\n themeElement().setAttribute("data-theme", next);\n try {\n localStorage.setItem("bothandler-dashboard-theme", next);\n } catch {\n }\n drawNow();\n });\n const pause = byId("pause");\n pause.hidden = !SECTIONS.feed;\n pause.addEventListener("click", () => {\n state.paused = !state.paused;\n pause.setAttribute("aria-pressed", String(state.paused));\n pause.textContent = state.paused ? `Resume${state.bufferedWhilePaused > 0 ? ` (${state.bufferedWhilePaused})` : ""}` : "Pause";\n if (!state.paused) {\n state.bufferedWhilePaused = 0;\n drawNow();\n }\n });\n if (BOOT.allowReset) {\n const reset = byId("reset");\n reset.hidden = false;\n reset.addEventListener("click", () => {\n reset.disabled = true;\n void fetch(`${API}/api/reset`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }).then(async (response) => {\n if (!response.ok) {\n const body = await response.json().catch(() => ({}));\n toast("bad", "Reset refused", body.error ?? String(response.status));\n return;\n }\n clearFeed();\n resetFeedCache();\n drawNow();\n }).catch(() => {\n }).finally(() => {\n reset.disabled = false;\n });\n });\n }\n const header = rootNode().querySelector("header");\n if (header !== null) {\n const apply = () => {\n themeElement().style.setProperty("--header-h", `${header.getBoundingClientRect().height}px`);\n };\n apply();\n if (typeof ResizeObserver === "function") new ResizeObserver(apply).observe(header);\n else addEventListener("resize", apply);\n }\n }\n function syncUrl(replace = true) {\n if (isEmbedded()) return;\n const params = new URLSearchParams();\n if (state.filter !== "all") params.set("f", state.filter);\n if (state.search !== "") params.set("q", state.search);\n const query = params.toString();\n const hash = `#${state.tab}${query === "" ? "" : `?${query}`}`;\n if (location.hash === hash) return;\n history[replace ? "replaceState" : "pushState"]({ tab: state.tab }, "", hash);\n }\n function readUrl() {\n const raw2 = isEmbedded() ? "" : location.hash.slice(1);\n const split = raw2.indexOf("?");\n const name = split === -1 ? raw2 : raw2.slice(0, split);\n const params = new URLSearchParams(split === -1 ? "" : raw2.slice(split + 1));\n const filter = params.get("f") ?? "all";\n return {\n tab: isTab(name) ? name : FIRST,\n filter,\n search: params.get("q") ?? ""\n };\n }\n function showTab(name, options = {}) {\n const target = isTab(name) ? name : FIRST;\n state.tab = target;\n for (const [id, tab, enabled] of TABS) {\n const selected = tab === target;\n const node = $(id);\n node.hidden = !enabled;\n node.setAttribute("aria-selected", String(selected));\n node.tabIndex = selected ? 0 : -1;\n $(`view-${tab}`).hidden = !selected || !enabled;\n }\n if (options.focus === true) $(available[tabIndexOf(target)]?.[0] ?? "tab-live").focus();\n if (target === "policy" && state.policy === void 0) {\n void loadPolicy();\n void loadRanges();\n }\n trackActors();\n if (options.push !== false) syncUrl(options.replace !== false);\n drawNow();\n }\n function initTabs() {\n available.forEach(([id, name], index) => {\n const tab = $(id);\n tab.addEventListener("click", () => showTab(name, { replace: false }));\n tab.addEventListener("keydown", (event) => {\n let next = -1;\n if (event.key === "ArrowRight") next = (index + 1) % available.length;\n else if (event.key === "ArrowLeft") next = (index - 1 + available.length) % available.length;\n else if (event.key === "Home") next = 0;\n else if (event.key === "End") next = available.length - 1;\n if (next === -1) return;\n event.preventDefault();\n showTab(available[next]?.[1] ?? FIRST, { focus: true, replace: false });\n });\n });\n if (isEmbedded()) return;\n addEventListener("popstate", () => {\n const url = readUrl();\n state.filter = url.filter;\n setSearch(url.search);\n reflectFilterButtons();\n showTab(url.tab, { push: false });\n });\n }\n function initKeyboard() {\n eventTarget().addEventListener("keydown", ((event) => {\n const target = event.target;\n const typing = target !== null && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT");\n if (event.key === "Escape") {\n if (typing && target?.id === "search" && target.value !== "") {\n target.value = "";\n setSearch("");\n syncUrl();\n drawNow();\n return;\n }\n if (typing) {\n target?.blur();\n return;\n }\n if (state.actor !== void 0) {\n state.actor = void 0;\n drawNow();\n return;\n }\n if (state.open.size > 0) {\n state.open.clear();\n drawNow();\n }\n return;\n }\n if (typing || event.metaKey || event.ctrlKey || event.altKey) return;\n if (event.key === "/" && SECTIONS.feed) {\n event.preventDefault();\n showTab("live");\n const search = byId("search");\n search.focus();\n search.select();\n return;\n }\n const digit = ["1", "2", "3", "4"].indexOf(event.key);\n if (digit !== -1 && digit < available.length) {\n event.preventDefault();\n showTab(available[digit]?.[1] ?? FIRST, { focus: true, replace: false });\n }\n }));\n }\n function initRanges() {\n const ranges = [\n ["1m", 6e4],\n ["5m", 3e5],\n ["15m", 9e5],\n ["1h", 36e5]\n ];\n const host = $("traffic-range");\n for (const [label2, value] of ranges) {\n const button = el("button", null, label2);\n button.setAttribute("aria-pressed", String(value === state.rangeMs));\n button.addEventListener("click", () => {\n state.rangeMs = value;\n for (const other of Array.from(host.children)) other.setAttribute("aria-pressed", "false");\n button.setAttribute("aria-pressed", "true");\n drawTraffic();\n });\n host.appendChild(button);\n }\n const scopes = [\n ["since start", "run"],\n ["this window", "window"]\n ];\n const scopeHost = $("score-scope");\n for (const [label2, value] of scopes) {\n const button = el("button", null, label2);\n button.setAttribute("aria-pressed", String(value === state.scoreScope));\n button.addEventListener("click", () => {\n state.scoreScope = value;\n for (const other of Array.from(scopeHost.children)) other.setAttribute("aria-pressed", "false");\n button.setAttribute("aria-pressed", "true");\n drawScores();\n });\n scopeHost.appendChild(button);\n }\n }\n var pending = false;\n function schedule() {\n if (state.paused || pending) return;\n pending = true;\n requestAnimationFrame(() => {\n pending = false;\n draw();\n });\n }\n function draw() {\n drawChips();\n drawTiles();\n drawNoticeBadge();\n updateWindowLabels();\n if (state.tab === "live") {\n drawActor();\n drawFeed();\n drawLivePanels();\n } else if (state.tab === "actors") {\n drawActors();\n } else if (state.tab === "stats") {\n drawTraffic();\n drawLatency();\n drawScores();\n drawStatsPanels();\n if (SECTIONS.audit) drawAudit();\n } else {\n drawPolicyTab();\n if (SECTIONS.notices) drawNotices();\n if (SECTIONS.changes) drawChanges();\n }\n }\n function drawNow() {\n draw();\n }\n function applySections() {\n const gated = [\n ["tiles", SECTIONS.statistics],\n ["tester-panel", SECTIONS.tester],\n ["ranges-panel", SECTIONS.ranges],\n ["changes-panel", SECTIONS.changes],\n ["actor-panel", SECTIONS.actors],\n ["live-actors-panel", SECTIONS.actors],\n ["audit-panel", SECTIONS.audit],\n ["audit-checks-panel", SECTIONS.audit],\n ["notices-panel", SECTIONS.notices],\n ["guard-panel", SECTIONS.guard],\n ["robots-panel", SECTIONS.robots],\n ["identities-panel", SECTIONS.actors],\n ["evidence-legend", SECTIONS.evidence]\n ];\n for (const [id, enabled] of gated) {\n const node = rootNode().querySelector(`#${id}`);\n if (node !== null && !enabled) node.remove();\n }\n }\n function start() {\n app.draw = schedule;\n app.drawNow = drawNow;\n app.showTab = showTab;\n app.syncUrl = () => syncUrl();\n applySections();\n initHeader();\n drawPeers();\n initTabs();\n initSkipLink();\n initKeyboard();\n initRanges();\n if (SECTIONS.feed) initFeed();\n initActor();\n initTester();\n initPolicy();\n let resizeTimer;\n addEventListener("resize", () => {\n if (resizeTimer !== void 0) clearTimeout(resizeTimer);\n resizeTimer = setTimeout(() => {\n if (state.tab === "stats") {\n drawTraffic();\n drawLatency();\n drawScores();\n }\n }, 120);\n });\n const url = readUrl();\n state.filter = url.filter;\n setSearch(url.search);\n if (SECTIONS.feed) reflectFilterButtons();\n showTab(url.tab, { replace: true });\n suspendScrollAnchoring();\n void loadInitialSnapshot().finally(settleScrollAnchoring);\n connectStream();\n }\n function htmlElement() {\n const root2 = rootNode();\n return root2 instanceof Document ? root2.documentElement : void 0;\n }\n function suspendScrollAnchoring() {\n htmlElement()?.classList.add("settling");\n }\n function settleScrollAnchoring() {\n const html = htmlElement();\n if (html === void 0) return;\n requestAnimationFrame(() => {\n requestAnimationFrame(() => {\n html.classList.remove("settling");\n });\n });\n }\n start();\n})();\n';
|
|
5912
6545
|
}
|
|
5913
6546
|
});
|
|
5914
6547
|
|
|
5915
6548
|
// src/dashboard/page.ts
|
|
6549
|
+
function bootFor(options) {
|
|
6550
|
+
return {
|
|
6551
|
+
base: options.basePath === "/" ? "" : options.basePath,
|
|
6552
|
+
title: options.title,
|
|
6553
|
+
allowReset: options.allowReset,
|
|
6554
|
+
allowEdit: options.allowEdit,
|
|
6555
|
+
allowGuardEdit: options.allowGuardEdit,
|
|
6556
|
+
allowActing: options.allowActing,
|
|
6557
|
+
peers: options.peers.map((peer) => ({ label: String(peer.label), href: String(peer.href) })),
|
|
6558
|
+
sections: options.sections,
|
|
6559
|
+
links: options.links.map((link) => ({ label: String(link.label), href: String(link.href) }))
|
|
6560
|
+
};
|
|
6561
|
+
}
|
|
5916
6562
|
function renderDashboardPage(options) {
|
|
5917
|
-
const bootstrap = escapeForScript(
|
|
5918
|
-
JSON.stringify(
|
|
5919
|
-
JSON.stringify({
|
|
5920
|
-
base: options.basePath === "/" ? "" : options.basePath,
|
|
5921
|
-
title: options.title,
|
|
5922
|
-
allowReset: options.allowReset,
|
|
5923
|
-
allowEdit: options.allowEdit,
|
|
5924
|
-
allowGuardEdit: options.allowGuardEdit,
|
|
5925
|
-
allowActing: options.allowActing,
|
|
5926
|
-
peers: options.peers.map((peer) => ({ label: String(peer.label), href: String(peer.href) })),
|
|
5927
|
-
sections: options.sections,
|
|
5928
|
-
links: options.links.map((link) => ({ label: String(link.label), href: String(link.href) }))
|
|
5929
|
-
})
|
|
5930
|
-
)
|
|
5931
|
-
);
|
|
6563
|
+
const bootstrap = escapeForScript(JSON.stringify(JSON.stringify(bootFor(options))));
|
|
5932
6564
|
const html = PAGE.replace("__BOOT_JSON__", () => bootstrap).replace("__SCRIPT__", () => CLIENT_SCRIPT).replace(/__TITLE__/g, () => escapeHtml2(options.title));
|
|
5933
6565
|
return (nonce) => html.replace(/__NONCE__/g, () => nonce);
|
|
5934
6566
|
}
|
|
@@ -5938,20 +6570,12 @@ function escapeForScript(json) {
|
|
|
5938
6570
|
function escapeHtml2(value) {
|
|
5939
6571
|
return value.replace(/[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[character] ?? character);
|
|
5940
6572
|
}
|
|
5941
|
-
var PAGE;
|
|
6573
|
+
var DASHBOARD_CSS, DASHBOARD_MARKUP, PAGE;
|
|
5942
6574
|
var init_page2 = __esm({
|
|
5943
6575
|
"src/dashboard/page.ts"() {
|
|
5944
6576
|
"use strict";
|
|
5945
6577
|
init_client_generated();
|
|
5946
|
-
|
|
5947
|
-
<html lang="en">
|
|
5948
|
-
<head>
|
|
5949
|
-
<meta charset="utf-8">
|
|
5950
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
5951
|
-
<meta name="robots" content="noindex, nofollow">
|
|
5952
|
-
<title>__TITLE__ · bot dashboard</title>
|
|
5953
|
-
<style nonce="__NONCE__">
|
|
5954
|
-
/* ---------------------------------------------------------------------------
|
|
6578
|
+
DASHBOARD_CSS = String.raw`/* ---------------------------------------------------------------------------
|
|
5955
6579
|
Tokens.
|
|
5956
6580
|
|
|
5957
6581
|
The two series colours and the critical status step are the validated data
|
|
@@ -5960,7 +6584,7 @@ var init_page2 = __esm({
|
|
|
5960
6584
|
label, so hue is never the only thing distinguishing anything. Text never wears a
|
|
5961
6585
|
series colour — the ink tokens below are all at least 4.5:1 on their surface.
|
|
5962
6586
|
--------------------------------------------------------------------------- */
|
|
5963
|
-
:root {
|
|
6587
|
+
:root, :host {
|
|
5964
6588
|
color-scheme: light;
|
|
5965
6589
|
--page: #f4f5f7;
|
|
5966
6590
|
--surface: #ffffff;
|
|
@@ -5993,7 +6617,7 @@ var init_page2 = __esm({
|
|
|
5993
6617
|
--focus: #2a78d6;
|
|
5994
6618
|
}
|
|
5995
6619
|
@media (prefers-color-scheme: dark) {
|
|
5996
|
-
:root:not([data-theme="light"]) {
|
|
6620
|
+
:root:not([data-theme="light"]), :host(:not([data-theme="light"])) {
|
|
5997
6621
|
color-scheme: dark;
|
|
5998
6622
|
--page: #0d0f12;
|
|
5999
6623
|
--surface: #16181d;
|
|
@@ -6018,7 +6642,7 @@ var init_page2 = __esm({
|
|
|
6018
6642
|
--focus: #86b6ef;
|
|
6019
6643
|
}
|
|
6020
6644
|
}
|
|
6021
|
-
:root[data-theme="dark"] {
|
|
6645
|
+
:root[data-theme="dark"], :host([data-theme="dark"]) {
|
|
6022
6646
|
color-scheme: dark;
|
|
6023
6647
|
--page: #0d0f12;
|
|
6024
6648
|
--surface: #16181d;
|
|
@@ -6045,6 +6669,8 @@ var init_page2 = __esm({
|
|
|
6045
6669
|
|
|
6046
6670
|
* { box-sizing: border-box; }
|
|
6047
6671
|
html, body { height: 100%; }
|
|
6672
|
+
/* Restored by the client once the first render has settled. See the note on .tiles. */
|
|
6673
|
+
html.settling { overflow-anchor: none; }
|
|
6048
6674
|
body {
|
|
6049
6675
|
margin: 0; background: var(--page); color: var(--ink);
|
|
6050
6676
|
font: 14px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
@@ -6137,14 +6763,68 @@ button[disabled] { opacity: .5; cursor: default; }
|
|
|
6137
6763
|
/* --- layout ------------------------------------------------------------- */
|
|
6138
6764
|
main { padding: 18px 20px 64px; max-width: 1680px; margin: 0 auto; }
|
|
6139
6765
|
.stack { display: grid; gap: 16px; }
|
|
6140
|
-
|
|
6766
|
+
/* Everything above the feed is drawn by script once the first snapshot arrives, which
|
|
6767
|
+
inserts a block of content above what is already laid out. The browser's scroll
|
|
6768
|
+
anchoring compensates for that by scrolling down by its height — so on any window narrow
|
|
6769
|
+
enough for the counter row to wrap, the dashboard opened with its own counters already
|
|
6770
|
+
off the top of the screen, every time.
|
|
6771
|
+
|
|
6772
|
+
Anchoring is switched off for the first render and switched back on once it has settled
|
|
6773
|
+
(see settleScrollAnchoring in the client). It is not simply left off: the feed puts new requests at
|
|
6774
|
+
the top, and anchoring is exactly what keeps somebody's place while they read a screen
|
|
6775
|
+
that grows above them. */
|
|
6776
|
+
.tiles { display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(136px, 1fr)); }
|
|
6141
6777
|
.tile {
|
|
6142
|
-
background: var(--surface); border: 1px solid var(--line); border-radius:
|
|
6143
|
-
padding: 12px
|
|
6144
|
-
}
|
|
6145
|
-
.tile .v { font-size:
|
|
6146
|
-
.tile .k { font-size:
|
|
6147
|
-
|
|
6778
|
+
background: var(--surface); border: 1px solid var(--line); border-radius: 10px;
|
|
6779
|
+
padding: 8px 12px 9px; box-shadow: var(--shadow);
|
|
6780
|
+
}
|
|
6781
|
+
.tile .v { font-size: 21px; font-weight: 620; letter-spacing: -.025em; line-height: 1.15; }
|
|
6782
|
+
.tile .k { font-size: 10.5px; color: var(--muted); text-transform: uppercase; letter-spacing: .055em; margin-top: 2px; font-weight: 560; }
|
|
6783
|
+
/* One line, always. These are grid items, so the tallest sets the height of all nine —
|
|
6784
|
+
two captions wrapping to a second line was costing every tile forty pixels of nothing.
|
|
6785
|
+
The full text stays available on hover rather than being cut from the page. */
|
|
6786
|
+
.tile .s { font-size: 11px; color: var(--muted); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
6787
|
+
/* A tile that navigates is a real button, so it arrives carrying the browser's own
|
|
6788
|
+
font, centring and chrome. Reset to match its inert neighbours exactly, then given
|
|
6789
|
+
back the one thing a div must not have: something that says it can be pressed. */
|
|
6790
|
+
button.tile {
|
|
6791
|
+
font: inherit; color: inherit; text-align: left; width: 100%; display: block;
|
|
6792
|
+
cursor: pointer; appearance: none; transition: border-color .12s, box-shadow .12s;
|
|
6793
|
+
}
|
|
6794
|
+
button.tile:hover { border-color: var(--focus); }
|
|
6795
|
+
/* The badge's companion: fetches the entries the stream skipped. Sits inline with the
|
|
6796
|
+
heading, so it is styled to read as part of the sentence rather than as a form control. */
|
|
6797
|
+
.load-skipped {
|
|
6798
|
+
font: inherit; margin-left: 6px; padding: 1px 8px; border-radius: 6px; cursor: pointer;
|
|
6799
|
+
border: 1px solid var(--warn-text); background: transparent; color: var(--warn-text);
|
|
6800
|
+
}
|
|
6801
|
+
.load-skipped:hover { background: color-mix(in srgb, var(--warn-text) 12%, transparent); }
|
|
6802
|
+
.load-skipped:disabled { opacity: .5; cursor: default; }
|
|
6803
|
+
|
|
6804
|
+
/* Prev/next under a table. Quiet: it is navigation for a list, not an action on it. */
|
|
6805
|
+
.pager {
|
|
6806
|
+
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
|
6807
|
+
padding: 9px 2px 2px; font-size: 12px; color: var(--muted);
|
|
6808
|
+
}
|
|
6809
|
+
.pager button {
|
|
6810
|
+
font: inherit; padding: 4px 11px; border-radius: 7px;
|
|
6811
|
+
border: 1px solid var(--line); background: var(--surface); color: var(--ink); cursor: pointer;
|
|
6812
|
+
}
|
|
6813
|
+
.pager button:hover:not(:disabled) { border-color: var(--focus); }
|
|
6814
|
+
.pager button:disabled { opacity: .45; cursor: default; }
|
|
6815
|
+
.pager .where { font-variant-numeric: tabular-nums; }
|
|
6816
|
+
.pager.pager-top { padding: 2px 2px 9px; border-bottom: 1px solid var(--line); margin-bottom: 9px; }
|
|
6817
|
+
/* The feed's upper pager rides in the toolbar rather than owning a row of its own, which
|
|
6818
|
+
was thirty-six pixels of mostly empty rule above every screenful of requests. */
|
|
6819
|
+
.pager.pager-inline { padding: 0; margin-left: auto; gap: 8px; }
|
|
6820
|
+
.pager.pager-inline .size { margin-left: 0; }
|
|
6821
|
+
.pager .step { min-width: 30px; font-size: 15px; line-height: 1; padding: 3px 8px 5px; }
|
|
6822
|
+
.pager .size { display: flex; align-items: center; gap: 6px; margin-left: auto; }
|
|
6823
|
+
.pager .size select {
|
|
6824
|
+
font: inherit; padding: 3px 6px; border-radius: 6px;
|
|
6825
|
+
border: 1px solid var(--line); background: var(--surface); color: var(--ink);
|
|
6826
|
+
}
|
|
6827
|
+
.pager .held { color: var(--warn-text); }
|
|
6148
6828
|
.tile.good .v { color: var(--good-text); }
|
|
6149
6829
|
.tile.warn .v { color: var(--warn-text); }
|
|
6150
6830
|
.tile.crit .v { color: var(--crit-text); }
|
|
@@ -6159,14 +6839,32 @@ main { padding: 18px 20px 64px; max-width: 1680px; margin: 0 auto; }
|
|
|
6159
6839
|
the page header, where they were always meant to. */
|
|
6160
6840
|
.panel { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; box-shadow: var(--shadow); overflow: clip; }
|
|
6161
6841
|
.panel > h2 {
|
|
6162
|
-
margin: 0; padding:
|
|
6842
|
+
margin: 0; padding: 9px 14px; font-size: 11.5px; font-weight: 620; color: var(--muted);
|
|
6163
6843
|
text-transform: uppercase; letter-spacing: .055em; border-bottom: 1px solid var(--line);
|
|
6164
6844
|
display: flex; align-items: center; gap: 10px;
|
|
6165
6845
|
}
|
|
6166
6846
|
.panel > h2 .sub { font-weight: 400; text-transform: none; letter-spacing: 0; font-size: 11.5px; margin-left: auto; }
|
|
6847
|
+
/* A container, so the two-column grid below can ask how much room it actually has.
|
|
6848
|
+
|
|
6849
|
+
It used to ask the viewport, which is the same mistake the feed table's breakpoints
|
|
6850
|
+
made one level down: the constraint is the width of this column, not of the window.
|
|
6851
|
+
Embedded in a 320px sidebar on a 1280px screen the media query never fired, the second
|
|
6852
|
+
column held its 280px minimum, and the feed was squeezed to twenty-two pixels. */
|
|
6853
|
+
.stack { container: dash / inline-size; }
|
|
6854
|
+
/* A grid item's min-width is auto, so a panel wrapping a wide table refuses to shrink and
|
|
6855
|
+
pushes the whole document sideways instead — which is what the Actors screen did under
|
|
6856
|
+
about 600px, where the table is widest and the viewport narrowest. The .two grid already
|
|
6857
|
+
says minmax(0, ...) for this reason; .stack needs the same permission. With it the panel
|
|
6858
|
+
shrinks and the scroller inside it does its job. */
|
|
6859
|
+
.stack > * { min-width: 0; }
|
|
6860
|
+
/* Numeric columns shrink to their contents, so the width goes to the two columns that
|
|
6861
|
+
carry text — the actor and what is known about it. Six counters of one or two digits
|
|
6862
|
+
were each taking about a hundred pixels while the State column was squeezed against the
|
|
6863
|
+
buttons. Same trick the feed's time column already uses. */
|
|
6864
|
+
#view-actors th.num, #view-actors td.num { width: 1%; white-space: nowrap; }
|
|
6167
6865
|
.two { display: grid; gap: 16px; grid-template-columns: minmax(0, 1.9fr) minmax(280px, 1fr); align-items: start; }
|
|
6168
6866
|
.grid3 { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); }
|
|
6169
|
-
@
|
|
6867
|
+
@container dash (max-width: 1080px) { .two { grid-template-columns: minmax(0, 1fr); } }
|
|
6170
6868
|
|
|
6171
6869
|
/* The feed table has a floor — six columns of request text will not go below about
|
|
6172
6870
|
940px — and under it the panel was simply amputating the right-hand columns.
|
|
@@ -6243,7 +6941,7 @@ thead th {
|
|
|
6243
6941
|
text-align: left; font-size: 11px; font-weight: 600; color: var(--muted);
|
|
6244
6942
|
text-transform: uppercase; letter-spacing: .05em; padding: 9px 15px; border-bottom: 1px solid var(--line);
|
|
6245
6943
|
}
|
|
6246
|
-
tbody td { padding:
|
|
6944
|
+
tbody td { padding: 7px 14px; border-bottom: 1px solid var(--line-soft); vertical-align: top; }
|
|
6247
6945
|
/* Narrow, quiet, and never the reason a row wraps: the time is for scanning down, not
|
|
6248
6946
|
for reading across. */
|
|
6249
6947
|
tbody td.when { color: var(--muted); font-size: 11.5px; white-space: nowrap; width: 1%; padding-right: 4px; }
|
|
@@ -6564,10 +7262,20 @@ input::placeholder { color: color-mix(in srgb, var(--muted) 80%, transparent); }
|
|
|
6564
7262
|
.toast span { color: var(--muted); }
|
|
6565
7263
|
@keyframes toast-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
|
6566
7264
|
@media (prefers-reduced-motion: reduce) { .toast { animation: none; } * { transition: none !important; } }
|
|
6567
|
-
</style>
|
|
6568
|
-
</head>
|
|
6569
|
-
<body>
|
|
6570
7265
|
|
|
7266
|
+
/* Forced colours — Windows High Contrast and the like.
|
|
7267
|
+
The browser repaints text, backgrounds and borders from the user's palette but leaves
|
|
7268
|
+
SVG fill and stroke alone, which is the right outcome for the two series: they stay
|
|
7269
|
+
tellable apart instead of collapsing into one system colour. The gridlines are the
|
|
7270
|
+
casualty. They are drawn in --grid, a colour picked to recede against *this* dashboard's
|
|
7271
|
+
background, and against a forced black one they recede to 1.4:1 — measured, on the
|
|
7272
|
+
statistics charts — which is not recessive, it is gone. GrayText is the palette's own
|
|
7273
|
+
answer to "present but secondary", so the grid follows the user's colours while the data
|
|
7274
|
+
keeps the ones that carry meaning. */
|
|
7275
|
+
@media (forced-colors: active) {
|
|
7276
|
+
.gridline { stroke: GrayText; }
|
|
7277
|
+
}`;
|
|
7278
|
+
DASHBOARD_MARKUP = String.raw`
|
|
6571
7279
|
<a class="skip" href="#view-live">Skip to the feed</a>
|
|
6572
7280
|
|
|
6573
7281
|
<header>
|
|
@@ -6613,7 +7321,7 @@ input::placeholder { color: color-mix(in srgb, var(--muted) 80%, transparent); }
|
|
|
6613
7321
|
|
|
6614
7322
|
<div class="two">
|
|
6615
7323
|
<section class="panel feed-panel">
|
|
6616
|
-
<h2>Requests <span class="sub" id="feed-count"></span><span class="sub win" id="feed-window"></span><span class="sub warn-text" id="feed-skipped" hidden></span></h2>
|
|
7324
|
+
<h2>Requests <span class="sub" id="feed-count"></span><span class="sub win" id="feed-window"></span><span class="sub warn-text" id="feed-skipped" hidden></span><button class="sub load-skipped" id="feed-load-skipped" type="button" hidden>Load them</button></h2>
|
|
6617
7325
|
<div class="toolbar">
|
|
6618
7326
|
<div class="filters" id="filters"></div>
|
|
6619
7327
|
<div class="search">
|
|
@@ -6621,6 +7329,7 @@ input::placeholder { color: color-mix(in srgb, var(--muted) 80%, transparent); }
|
|
|
6621
7329
|
<kbd aria-hidden="true">/</kbd>
|
|
6622
7330
|
</div>
|
|
6623
7331
|
<button id="feed-export" title="Download every request matching this filter as replay JSONL">Export</button>
|
|
7332
|
+
<div class="pager pager-inline" id="feed-pager-top" hidden></div>
|
|
6624
7333
|
</div>
|
|
6625
7334
|
<div class="feed-scroll">
|
|
6626
7335
|
<table>
|
|
@@ -6632,6 +7341,7 @@ input::placeholder { color: color-mix(in srgb, var(--muted) 80%, transparent); }
|
|
|
6632
7341
|
<tbody id="rows"></tbody>
|
|
6633
7342
|
</table>
|
|
6634
7343
|
</div>
|
|
7344
|
+
<div class="pager" id="feed-pager" hidden></div>
|
|
6635
7345
|
<div class="empty" id="empty">Nothing assessed yet. Send some traffic through the handler and it appears here within a moment.</div>
|
|
6636
7346
|
</section>
|
|
6637
7347
|
|
|
@@ -6680,6 +7390,7 @@ input::placeholder { color: color-mix(in srgb, var(--muted) 80%, transparent); }
|
|
|
6680
7390
|
<div class="note" id="actors-note">Everyone the engine is currently remembering, busiest first — a far larger
|
|
6681
7391
|
population than the feed's ring, which holds requests rather than clients. This is
|
|
6682
7392
|
what <code>cadence</code>, <code>crawl-breadth</code> and <code>rate-anomaly</code> are reading.</div>
|
|
7393
|
+
<div class="pager pager-top" id="actors-pager-top" hidden></div>
|
|
6683
7394
|
<div class="feed-scroll">
|
|
6684
7395
|
<table>
|
|
6685
7396
|
<thead>
|
|
@@ -6691,6 +7402,7 @@ input::placeholder { color: color-mix(in srgb, var(--muted) 80%, transparent); }
|
|
|
6691
7402
|
<tbody id="actor-rows"></tbody>
|
|
6692
7403
|
</table>
|
|
6693
7404
|
</div>
|
|
7405
|
+
<div class="pager" id="actors-pager" hidden></div>
|
|
6694
7406
|
<div class="empty" id="actors-empty" hidden>Nothing in the registry yet.</div>
|
|
6695
7407
|
</section>
|
|
6696
7408
|
</div>
|
|
@@ -6854,7 +7566,20 @@ input::placeholder { color: color-mix(in srgb, var(--muted) 80%, transparent); }
|
|
|
6854
7566
|
</main>
|
|
6855
7567
|
|
|
6856
7568
|
<div class="toasts" id="toasts" aria-live="polite"></div>
|
|
6857
|
-
|
|
7569
|
+
`;
|
|
7570
|
+
PAGE = String.raw`<!doctype html>
|
|
7571
|
+
<html lang="en">
|
|
7572
|
+
<head>
|
|
7573
|
+
<meta charset="utf-8">
|
|
7574
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
7575
|
+
<meta name="robots" content="noindex, nofollow">
|
|
7576
|
+
<title>__TITLE__ · bot dashboard</title>
|
|
7577
|
+
<style nonce="__NONCE__">
|
|
7578
|
+
${DASHBOARD_CSS}
|
|
7579
|
+
</style>
|
|
7580
|
+
</head>
|
|
7581
|
+
<body>
|
|
7582
|
+
${DASHBOARD_MARKUP}
|
|
6858
7583
|
<script nonce="__NONCE__">
|
|
6859
7584
|
window.__BOOTSTRAP__ = JSON.parse(__BOOT_JSON__);
|
|
6860
7585
|
__SCRIPT__
|
|
@@ -7225,7 +7950,7 @@ function buildDashboard(handler, options, host) {
|
|
|
7225
7950
|
const notices = new DashboardNotices(handler);
|
|
7226
7951
|
const changes = new DashboardChanges(handler);
|
|
7227
7952
|
const instance = options.instance ?? (0, import_node_os.hostname)();
|
|
7228
|
-
const
|
|
7953
|
+
const pageOptions = {
|
|
7229
7954
|
title: options.title ?? "bothandlerjs",
|
|
7230
7955
|
basePath,
|
|
7231
7956
|
links: options.links ?? [],
|
|
@@ -7235,7 +7960,9 @@ function buildDashboard(handler, options, host) {
|
|
|
7235
7960
|
allowActing,
|
|
7236
7961
|
sections,
|
|
7237
7962
|
peers: options.peers ?? []
|
|
7238
|
-
}
|
|
7963
|
+
};
|
|
7964
|
+
const page = renderDashboardPage(pageOptions);
|
|
7965
|
+
const bootstrap = JSON.stringify(bootFor(pageOptions));
|
|
7239
7966
|
const streams = /* @__PURE__ */ new Set();
|
|
7240
7967
|
let statsTimer;
|
|
7241
7968
|
function startStatsTimer() {
|
|
@@ -7348,6 +8075,8 @@ function buildDashboard(handler, options, host) {
|
|
|
7348
8075
|
switch (route2) {
|
|
7349
8076
|
case "/":
|
|
7350
8077
|
return sendPage(response);
|
|
8078
|
+
case "/api/bootstrap":
|
|
8079
|
+
return send(response, 200, "application/json; charset=utf-8", bootstrap);
|
|
7351
8080
|
case "/api/stats":
|
|
7352
8081
|
return send(response, 200, "application/json; charset=utf-8", JSON.stringify(snapshot()));
|
|
7353
8082
|
case "/api/feed":
|
|
@@ -7365,7 +8094,8 @@ function buildDashboard(handler, options, host) {
|
|
|
7365
8094
|
case "/api/actors": {
|
|
7366
8095
|
if (!sections.registry) return sectionOff(response, "registry");
|
|
7367
8096
|
const limit = Math.min(MAX_ACTORS_LISTED, Math.max(1, Number(url.searchParams.get("limit") ?? 50) || 50));
|
|
7368
|
-
const
|
|
8097
|
+
const offset = Math.max(0, Math.floor(Number(url.searchParams.get("offset") ?? 0) || 0));
|
|
8098
|
+
const actors = handler.registry.top(limit, handler.config.clock.now(), offset).map((actor) => ({
|
|
7369
8099
|
...actor,
|
|
7370
8100
|
key: maskIp ? networkKey(actor.key) ?? actor.key : actor.key
|
|
7371
8101
|
}));
|
|
@@ -8726,8 +9456,16 @@ var init_core = __esm({
|
|
|
8726
9456
|
const actorKey = this.actorKeyFor(facts);
|
|
8727
9457
|
const outcome = await this.challenge.verifySolution(actorKey, body2);
|
|
8728
9458
|
this.meter?.recordChallenge(outcome.ok ? "solved" : "rejected");
|
|
9459
|
+
if (outcome.ok) this.meter?.recordClearance(outcome.level);
|
|
9460
|
+
else this.meter?.recordChallengeRejection(outcome.reason);
|
|
9461
|
+
if (outcome.interactionScore !== void 0) this.meter?.recordInteractionScore(outcome.interactionScore);
|
|
8729
9462
|
if (outcome.ok) this.audit?.recordChallengeSolved(this.config.clock.now());
|
|
8730
|
-
this.events.emit("challenge", {
|
|
9463
|
+
this.events.emit("challenge", {
|
|
9464
|
+
phase: outcome.ok ? "solved" : "rejected",
|
|
9465
|
+
actorKey,
|
|
9466
|
+
...outcome.ok ? { level: outcome.level } : { reason: outcome.reason },
|
|
9467
|
+
...outcome.interactionScore === void 0 ? {} : { score: outcome.interactionScore }
|
|
9468
|
+
});
|
|
8731
9469
|
if (outcome.ok) {
|
|
8732
9470
|
const solver = this.registry.peek(actorKey);
|
|
8733
9471
|
if (solver !== void 0) solver.unsolvedChallenges = 0;
|
|
@@ -11973,6 +12711,26 @@ var init_reputation = __esm({
|
|
|
11973
12711
|
requests: [browser("chromeWindows")],
|
|
11974
12712
|
expect: { verdict: "human", certain: true, detectors: ["clearance"], action: ["allow", "log", "tag"] }
|
|
11975
12713
|
}),
|
|
12714
|
+
human({
|
|
12715
|
+
id: "cleared-by-interaction",
|
|
12716
|
+
title: "A person who ticked the box on the interaction challenge",
|
|
12717
|
+
category: "clearance",
|
|
12718
|
+
provenance: "A signed clearance cookie granted after a trusted activation on a browser that passed the capability probes",
|
|
12719
|
+
notes: "`strong` human evidence rather than `certain`, and the gap is the whole point. A trusted gesture in a rendering browser is a real cost imposed and it is still not proof of a person: a browser driven through the DevTools protocol dispatches genuine input events and renders genuine CSS. It outranks a bare proof of work because it costs more, and it stops short of `operator` because that assertion comes from the application and this one comes from the client.",
|
|
12720
|
+
clearance: "interaction",
|
|
12721
|
+
requests: [browser("chromeWindows")],
|
|
12722
|
+
expect: { detectors: ["clearance"], neverAction: ["block", "drop", "redirect"] }
|
|
12723
|
+
}),
|
|
12724
|
+
human({
|
|
12725
|
+
id: "cleared-by-interaction-on-a-phone",
|
|
12726
|
+
title: "A person who tapped the box on a phone",
|
|
12727
|
+
category: "clearance",
|
|
12728
|
+
provenance: "A tap emits almost no pointermove, so the report carries no path at all",
|
|
12729
|
+
notes: "Kept because the absence of a pointer path used to be scored as a mark against the client, which graded every phone \u2014 and every screen reader, switch and voice-control user \u2014 down to the weaker clearance for the way they use a computer. A tap is reported as touch and graded on its capabilities.",
|
|
12730
|
+
clearance: "interaction",
|
|
12731
|
+
requests: [browser("safariIos")],
|
|
12732
|
+
expect: { detectors: ["clearance"], neverAction: ["block", "drop", "redirect"] }
|
|
12733
|
+
}),
|
|
11976
12734
|
bot({
|
|
11977
12735
|
id: "cleared-but-proven-bot",
|
|
11978
12736
|
title: "A proven bot presenting a valid clearance token",
|