@osqd/bothandlerjs 0.3.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 +516 -0
- package/LICENSE +102 -0
- package/README.md +308 -0
- package/bin/bothandlerjs.mjs +14 -0
- package/dist/actions/index.d.ts +22 -0
- package/dist/actions/types.d.ts +59 -0
- package/dist/adapters/fastify.d.ts +43 -0
- package/dist/adapters/fetch.d.ts +51 -0
- package/dist/adapters/index.cjs +723 -0
- package/dist/adapters/index.cjs.map +1 -0
- package/dist/adapters/index.d.ts +8 -0
- package/dist/adapters/index.js +692 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/koa.d.ts +27 -0
- package/dist/adapters/node.d.ts +29 -0
- package/dist/adapters/shared.d.ts +39 -0
- package/dist/audit.d.ts +172 -0
- package/dist/challenge/index.d.ts +176 -0
- package/dist/challenge/language.d.ts +57 -0
- package/dist/challenge/page.d.ts +42 -0
- package/dist/challenge/pow.d.ts +33 -0
- package/dist/challenge/token.d.ts +72 -0
- package/dist/cli.cjs +13388 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +13358 -0
- package/dist/cli.js.map +1 -0
- package/dist/client/index.cjs +139 -0
- package/dist/client/index.cjs.map +1 -0
- package/dist/client/index.d.ts +63 -0
- package/dist/client/index.js +112 -0
- package/dist/client/index.js.map +1 -0
- package/dist/config.d.ts +316 -0
- package/dist/core.d.ts +443 -0
- package/dist/corpus/adversarial.d.ts +2 -0
- package/dist/corpus/advertising-email.d.ts +2 -0
- package/dist/corpus/ai-crawlers.d.ts +2 -0
- package/dist/corpus/benign-bots.d.ts +2 -0
- package/dist/corpus/cdn-gateways.d.ts +2 -0
- package/dist/corpus/cookies.d.ts +49 -0
- package/dist/corpus/crawlers-regional.d.ts +2 -0
- package/dist/corpus/crawlers-vertical.d.ts +2 -0
- package/dist/corpus/headers.d.ts +403 -0
- package/dist/corpus/humans-apps.d.ts +2 -0
- package/dist/corpus/humans-browsers.d.ts +2 -0
- package/dist/corpus/humans.d.ts +2 -0
- package/dist/corpus/index.cjs +4196 -0
- package/dist/corpus/index.cjs.map +1 -0
- package/dist/corpus/index.d.ts +37 -0
- package/dist/corpus/index.js +4132 -0
- package/dist/corpus/index.js.map +1 -0
- package/dist/corpus/infrastructure.d.ts +17 -0
- package/dist/corpus/libraries-extended.d.ts +2 -0
- package/dist/corpus/ranges.d.ts +31 -0
- package/dist/corpus/reputation.d.ts +21 -0
- package/dist/corpus/runner.d.ts +130 -0
- package/dist/corpus/schema.d.ts +185 -0
- package/dist/corpus/tooling.d.ts +2 -0
- package/dist/corpus/unwanted.d.ts +2 -0
- package/dist/crawler-ranges.d.ts +113 -0
- package/dist/dashboard/client.generated.d.ts +7 -0
- package/dist/dashboard/feed.d.ts +186 -0
- package/dist/dashboard/index.d.ts +5 -0
- package/dist/dashboard/page.d.ts +51 -0
- package/dist/dashboard/parse-request.d.ts +28 -0
- package/dist/dashboard/preview.d.ts +45 -0
- package/dist/dashboard/server.d.ts +70 -0
- package/dist/dashboard/types.d.ts +714 -0
- package/dist/detectors/accept-signature.d.ts +6 -0
- package/dist/detectors/browsing-coherence.d.ts +44 -0
- package/dist/detectors/cadence.d.ts +28 -0
- package/dist/detectors/clearance.d.ts +25 -0
- package/dist/detectors/client-hints.d.ts +19 -0
- package/dist/detectors/client-signals.d.ts +27 -0
- package/dist/detectors/crawl-breadth.d.ts +28 -0
- package/dist/detectors/crawler-verification.d.ts +39 -0
- package/dist/detectors/fetch-metadata.d.ts +20 -0
- package/dist/detectors/header-integrity.d.ts +26 -0
- package/dist/detectors/header-order.d.ts +27 -0
- package/dist/detectors/identity-rotation.d.ts +34 -0
- package/dist/detectors/index.d.ts +56 -0
- package/dist/detectors/ip-intelligence.d.ts +30 -0
- package/dist/detectors/known-bots.d.ts +94 -0
- package/dist/detectors/probe-signature.d.ts +50 -0
- package/dist/detectors/rate-anomaly.d.ts +36 -0
- package/dist/detectors/self-identified.d.ts +33 -0
- package/dist/detectors/session-integrity.d.ts +24 -0
- package/dist/detectors/tls-fingerprint.d.ts +34 -0
- package/dist/detectors/trap.d.ts +98 -0
- package/dist/detectors/types.d.ts +70 -0
- package/dist/detectors/ua-coherence.d.ts +32 -0
- package/dist/evidence.d.ts +45 -0
- package/dist/facts.d.ts +46 -0
- package/dist/index.cjs +9600 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.js +9444 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/async.d.ts +16 -0
- package/dist/internal/clock.d.ts +17 -0
- package/dist/internal/crypto.d.ts +26 -0
- package/dist/internal/dns.d.ts +100 -0
- package/dist/internal/emitter.d.ts +18 -0
- package/dist/internal/http.d.ts +22 -0
- package/dist/internal/ip.d.ts +73 -0
- package/dist/internal/lru.d.ts +34 -0
- package/dist/internal/matcher.d.ts +41 -0
- package/dist/internal/pattern.d.ts +14 -0
- package/dist/internal/ua.d.ts +92 -0
- package/dist/metrics.d.ts +120 -0
- package/dist/notify/hub.d.ts +70 -0
- package/dist/notify/index.d.ts +7 -0
- package/dist/notify/redact.d.ts +48 -0
- package/dist/notify/sinks.d.ts +62 -0
- package/dist/notify/types.d.ts +56 -0
- package/dist/policy/index.d.ts +7 -0
- package/dist/policy/match.d.ts +20 -0
- package/dist/policy/policy.d.ts +123 -0
- package/dist/policy/presets.d.ts +161 -0
- package/dist/policy/types.d.ts +155 -0
- package/dist/robots.d.ts +83 -0
- package/dist/state.d.ts +161 -0
- package/dist/stores/index.d.ts +5 -0
- package/dist/stores/memory.d.ts +33 -0
- package/dist/stores/redis.d.ts +59 -0
- package/dist/stores/types.d.ts +37 -0
- package/dist/types.d.ts +250 -0
- package/docs/challenge/index.md +152 -0
- package/docs/challenge/localisation.md +105 -0
- package/docs/concepts/actors.md +113 -0
- package/docs/concepts/evidence.md +133 -0
- package/docs/concepts/the-guard.md +121 -0
- package/docs/concepts/threat-model.md +120 -0
- package/docs/concepts/verdicts.md +108 -0
- package/docs/course/01-first-assessment.md +178 -0
- package/docs/course/02-proof-and-suspicion.md +164 -0
- package/docs/course/03-verdicts-and-scores.md +160 -0
- package/docs/course/04-the-guard.md +200 -0
- package/docs/course/05-detectors.md +178 -0
- package/docs/course/06-identity.md +161 -0
- package/docs/course/07-actors.md +178 -0
- package/docs/course/08-traps.md +183 -0
- package/docs/course/09-rules.md +224 -0
- package/docs/course/10-actions-and-presets.md +216 -0
- package/docs/course/11-the-challenge.md +227 -0
- package/docs/course/12-going-live.md +211 -0
- package/docs/course/13-operating-it.md +238 -0
- package/docs/course/14-scaling.md +195 -0
- package/docs/course/15-extending.md +259 -0
- package/docs/course/16-proving-it.md +266 -0
- package/docs/course/index.md +112 -0
- package/docs/design/decisions.md +216 -0
- package/docs/detection/client-signals.md +96 -0
- package/docs/detection/detectors.md +376 -0
- package/docs/detection/index.md +134 -0
- package/docs/detection/signatures.md +111 -0
- package/docs/detection/verification.md +123 -0
- package/docs/detection/writing-a-detector.md +145 -0
- package/docs/index.md +100 -0
- package/docs/integration/adapters.md +138 -0
- package/docs/integration/client-ip.md +114 -0
- package/docs/integration/index.md +55 -0
- package/docs/integration/stores.md +76 -0
- package/docs/operations/audit.md +116 -0
- package/docs/operations/dashboard.md +725 -0
- package/docs/operations/index.md +101 -0
- package/docs/operations/metrics.md +74 -0
- package/docs/operations/notifications.md +72 -0
- package/docs/operations/runtime-changes.md +118 -0
- package/docs/policy/actions.md +196 -0
- package/docs/policy/index.md +84 -0
- package/docs/policy/presets.md +191 -0
- package/docs/policy/robots.md +128 -0
- package/docs/policy/rules.md +134 -0
- package/docs/reference/api.md +126 -0
- package/docs/reference/configuration.md +175 -0
- package/docs/start/choosing-a-policy.md +127 -0
- package/docs/start/first-integration.md +127 -0
- package/docs/start/installation.md +63 -0
- package/docs/start/upgrading.md +72 -0
- package/docs/testing/cli.md +119 -0
- package/docs/testing/corpus.md +129 -0
- package/docs/testing/index.md +61 -0
- package/docs/testing/replay.md +102 -0
- package/docs/testing/try-it.md +189 -0
- package/package.json +145 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generated by `npm run client:build` from `src/dashboard/client/`. Do not edit.
|
|
3
|
+
*
|
|
4
|
+
* The dashboard's browser code, bundled into the string `page.ts` stamps into its
|
|
5
|
+
* one nonced <script>. See scripts/build-client.mjs for why this exists.
|
|
6
|
+
*/
|
|
7
|
+
export declare const 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 \u00B7 ${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} \u00B7 ${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 \u00B7 empty\";\n const span = oldestAt2 === void 0 ? 0 : Math.max(0, now - oldestAt2);\n return `last ${n(count)} requests \u00B7 ${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} \u00B7 ${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, \"\u00D7\");\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 ? ` \u00B7 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(` \u00B7 ${entry.userAgent}`));\n } else {\n ua.appendChild(document.createTextNode(`${entry.actor} \u00B7 ${entry.userAgent}`));\n }\n ua.title = `${entry.actor} \u00B7 ${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} \u00B7 points to ${item.direction}`;\n if (item.family !== void 0) meta += ` \u00B7 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)} \u00B7 ${detector.cost} \u00B7 ${detector.stage}`;\n if (timing !== void 0 && timing.count > 0) right += ` \u00B7 ${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()} \u00B7 ${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()} \u00B7 ${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)} \u00B7`));\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)} \u00B7`));\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 \u00B7 ${n(over)} at or over the threshold \u00B7 ${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, \"\u00B7 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 \u00B7 mean ${ms(mean)} \u00B7 p95 ${ms(p95)} \u00B7 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 \u00B7 ${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(\" \u00B7 \")));\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}`} \u00B7 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} \u00B7 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";
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type { BotHandler } from "../core.js";
|
|
2
|
+
import type { DashboardChange, DashboardEntry, DashboardNotice, DashboardRedaction } from "./types.js";
|
|
3
|
+
import type { Assessment } from "../types.js";
|
|
4
|
+
/**
|
|
5
|
+
* The live feed: a bounded ring of recent assessments, and whoever is watching.
|
|
6
|
+
*
|
|
7
|
+
* Two engine events describe one request. `assess()` emits `assessment`; `decide()`
|
|
8
|
+
* emits `decision` a moment later, and a caller who only ever assesses — a monitor
|
|
9
|
+
* deployment, a log replay — never emits the second one at all. Showing a row per
|
|
10
|
+
* event would double every request; waiting for the decision would show nothing at
|
|
11
|
+
* all in monitor mode.
|
|
12
|
+
*
|
|
13
|
+
* So an entry is created when the assessment lands and *published* one turn of the
|
|
14
|
+
* event loop later, by which time the decision has usually arrived and filled in the
|
|
15
|
+
* action. A decision that arrives after publication sends a small update frame
|
|
16
|
+
* instead. The result is one row per request, whichever way the engine is being used.
|
|
17
|
+
*/
|
|
18
|
+
/** How the stream is throttled. See {@link DashboardOptions.maxEventsPerSecond}. */
|
|
19
|
+
export interface FeedLimits {
|
|
20
|
+
maxEventsPerSecond: number;
|
|
21
|
+
/** Oldest an entry may be before it is evicted, whatever the count. `0` disables it. */
|
|
22
|
+
ttlMs: number;
|
|
23
|
+
}
|
|
24
|
+
export declare class DashboardFeed {
|
|
25
|
+
private readonly entries;
|
|
26
|
+
private readonly pending;
|
|
27
|
+
/**
|
|
28
|
+
* The frame each entry was last written on, by request id.
|
|
29
|
+
*
|
|
30
|
+
* Kept beside the ring rather than on the entry because it describes the *stream*,
|
|
31
|
+
* not the request, and a viewer has no use for it. It is what makes a resume exact:
|
|
32
|
+
* see {@link since}.
|
|
33
|
+
*/
|
|
34
|
+
private readonly frames;
|
|
35
|
+
private readonly subscribers;
|
|
36
|
+
private readonly unsubscribe;
|
|
37
|
+
private readonly limit;
|
|
38
|
+
private readonly maskIp;
|
|
39
|
+
private readonly truncateUserAgent;
|
|
40
|
+
private readonly maskQuery;
|
|
41
|
+
private readonly showHeaders;
|
|
42
|
+
private sequence;
|
|
43
|
+
/**
|
|
44
|
+
* A monotonic counter over everything ever *sent*, which is a different thing from
|
|
45
|
+
* {@link DashboardEntry.seq}.
|
|
46
|
+
*
|
|
47
|
+
* `seq` numbers requests; this numbers frames. One request can be sent twice — once
|
|
48
|
+
* when it is assessed and again when the decision lands — and a viewer that
|
|
49
|
+
* reconnects between the two has to be told about the second. Numbering requests
|
|
50
|
+
* cannot express that; numbering frames can, so this is what rides on the SSE `id:`
|
|
51
|
+
* field and what {@link since} is asked about. It never resets, including across
|
|
52
|
+
* {@link clear}, because a cursor a viewer is still holding must never point into a
|
|
53
|
+
* reused range.
|
|
54
|
+
*/
|
|
55
|
+
private frame;
|
|
56
|
+
private closed;
|
|
57
|
+
/**
|
|
58
|
+
* The stream's rate cap, and what it has cost.
|
|
59
|
+
*
|
|
60
|
+
* The cap is on *publishing*, never on recording. A thinned stream is a cosmetic
|
|
61
|
+
* decision about how much a browser is asked to render; a thinned ring would be a
|
|
62
|
+
* silent change to what `previewPolicy` runs over, which is the one thing on this
|
|
63
|
+
* page that has to be exact.
|
|
64
|
+
*/
|
|
65
|
+
private readonly maxPerSecond;
|
|
66
|
+
/**
|
|
67
|
+
* How long an entry may sit here. See {@link DashboardOptions.feedTtlMs}.
|
|
68
|
+
*
|
|
69
|
+
* The ring's other bound is a count, and a count is the wrong shape for this
|
|
70
|
+
* question: on a quiet service five hundred requests can be a fortnight, and every
|
|
71
|
+
* one of them holds an address, a User-Agent and a header set belonging to somebody.
|
|
72
|
+
* "We keep the last five hundred requests" is a capacity statement; "we keep nothing
|
|
73
|
+
* older than an hour" is a promise you can make to a person.
|
|
74
|
+
*/
|
|
75
|
+
private readonly ttlMs;
|
|
76
|
+
private windowStart;
|
|
77
|
+
private publishedThisSecond;
|
|
78
|
+
private skippedTotal;
|
|
79
|
+
constructor(handler: BotHandler, limit: number, redact?: DashboardRedaction, limits?: FeedLimits);
|
|
80
|
+
get size(): number;
|
|
81
|
+
/** The backlog, oldest first, so a dashboard opened late still sees the run so far. */
|
|
82
|
+
backlog(): readonly DashboardEntry[];
|
|
83
|
+
/**
|
|
84
|
+
* Drops everything older than the TTL.
|
|
85
|
+
*
|
|
86
|
+
* Called when a request is recorded and on the dashboard's own timer, so the promise
|
|
87
|
+
* holds on an idle process too — which is exactly the process where it matters, since
|
|
88
|
+
* a busy one evicts by count long before anything reaches this age.
|
|
89
|
+
*/
|
|
90
|
+
prune(now: number): number;
|
|
91
|
+
/** The frame most recently sent. A viewer's cursor after it has been sent the backlog. */
|
|
92
|
+
get cursor(): number;
|
|
93
|
+
/** Entries the rate cap kept off the stream since start. They are still in the ring. */
|
|
94
|
+
get skipped(): number;
|
|
95
|
+
/**
|
|
96
|
+
* Everything that has changed since a viewer's cursor, oldest change first.
|
|
97
|
+
*
|
|
98
|
+
* What a reconnecting browser gets instead of the whole ring. `EventSource` resends
|
|
99
|
+
* the last `id:` it saw as `Last-Event-ID` without being asked, so a laptop lid, a
|
|
100
|
+
* proxy timeout or a blip costs a handful of frames rather than several hundred
|
|
101
|
+
* entries with their headers, evidence and actor history attached.
|
|
102
|
+
*
|
|
103
|
+
* `undefined` means the cursor cannot be honoured — it points before the oldest
|
|
104
|
+
* frame still described by the ring, or past the newest one this server has issued
|
|
105
|
+
* (a restarted process, a cleared feed) — and the caller should send the whole
|
|
106
|
+
* backlog instead. Answering a stale cursor with "nothing changed" would leave a
|
|
107
|
+
* viewer looking at a feed that has quietly stopped being true.
|
|
108
|
+
*/
|
|
109
|
+
since(cursor: number): DashboardEntry[] | undefined;
|
|
110
|
+
subscribe(listener: (event: string, entry: DashboardEntry | undefined, frame: number) => void): () => void;
|
|
111
|
+
get clients(): number;
|
|
112
|
+
clear(): void;
|
|
113
|
+
close(): void;
|
|
114
|
+
private record;
|
|
115
|
+
/**
|
|
116
|
+
* One assessment as the page shows it.
|
|
117
|
+
*
|
|
118
|
+
* Split out of `record` because the request tester needs exactly this and must not go
|
|
119
|
+
* anywhere near the ring: a dry run is not traffic, so it is shaped like a feed entry
|
|
120
|
+
* and then handed straight back to whoever asked, never stored and never published.
|
|
121
|
+
*/
|
|
122
|
+
entryFor(assessment: Assessment, seq: number): DashboardEntry;
|
|
123
|
+
private decide;
|
|
124
|
+
/**
|
|
125
|
+
* Whether this entry goes out on the stream.
|
|
126
|
+
*
|
|
127
|
+
* A plain per-second counter rather than a smoothed rate: the front of a burst is
|
|
128
|
+
* the part worth seeing, so a second's whole allowance is spendable at once. What is
|
|
129
|
+
* dropped is counted and reported, because a feed that thins itself silently looks
|
|
130
|
+
* like traffic that stopped.
|
|
131
|
+
*/
|
|
132
|
+
private allowedToPublish;
|
|
133
|
+
private publish;
|
|
134
|
+
/**
|
|
135
|
+
* Headers in wire order, with credentials removed.
|
|
136
|
+
*
|
|
137
|
+
* `facts.headerOrder` is the order they arrived in and is itself a fingerprint —
|
|
138
|
+
* showing them alphabetically would hide the very thing `header-order` reads. The
|
|
139
|
+
* map is consulted for values, and anything in {@link CREDENTIAL_HEADERS} is
|
|
140
|
+
* replaced rather than omitted, so the reader can see that a cookie was sent
|
|
141
|
+
* without seeing the cookie.
|
|
142
|
+
*/
|
|
143
|
+
private headers;
|
|
144
|
+
private query;
|
|
145
|
+
private userAgent;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Warnings and errors the engine raised, kept for the notices panel.
|
|
149
|
+
*
|
|
150
|
+
* Startup warnings are the ones that matter most and the ones most easily missed:
|
|
151
|
+
* `resolveConfig` collects them at construction, they go to `onWarning`, and on a
|
|
152
|
+
* busy boot they scroll past. A dashboard that shows a live feed of traffic while a
|
|
153
|
+
* warning saying "two rules share an id" sits unread in a log file is showing you the
|
|
154
|
+
* wrong half of the picture, so the panel is seeded with those and then follows the
|
|
155
|
+
* `warning` and `error` events for the life of the process.
|
|
156
|
+
*/
|
|
157
|
+
export declare class DashboardNotices {
|
|
158
|
+
private readonly limit;
|
|
159
|
+
private readonly notices;
|
|
160
|
+
private readonly unsubscribe;
|
|
161
|
+
constructor(handler: BotHandler, limit?: number);
|
|
162
|
+
list(): readonly DashboardNotice[];
|
|
163
|
+
close(): void;
|
|
164
|
+
private add;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Runtime changes, kept for the timeline markers.
|
|
168
|
+
*
|
|
169
|
+
* The traffic chart draws what happened; this is what somebody *did*, on the same axis.
|
|
170
|
+
* A preview says "44 of 151 requests would be treated differently", which is a
|
|
171
|
+
* prediction — and until the moment of the change is drawn against the traffic either
|
|
172
|
+
* side of it, nothing on the page ever tells you whether the prediction held.
|
|
173
|
+
*
|
|
174
|
+
* Deliberately separate from the notices, which are the engine talking about itself.
|
|
175
|
+
* These are people talking about the engine, and they are the shorter and more
|
|
176
|
+
* interesting list.
|
|
177
|
+
*/
|
|
178
|
+
export declare class DashboardChanges {
|
|
179
|
+
private readonly limit;
|
|
180
|
+
private readonly changes;
|
|
181
|
+
private readonly unsubscribe;
|
|
182
|
+
constructor(handler: BotHandler, limit?: number);
|
|
183
|
+
list(): readonly DashboardChange[];
|
|
184
|
+
close(): void;
|
|
185
|
+
private add;
|
|
186
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { startDashboard, createDashboardHandler } from "./server.js";
|
|
2
|
+
export { DashboardFeed } from "./feed.js";
|
|
3
|
+
export { renderDashboardPage } from "./page.js";
|
|
4
|
+
export type { DashboardPageOptions } from "./page.js";
|
|
5
|
+
export type { DashboardAuth, DashboardControls, DashboardEntry, DashboardEvidence, DashboardOptions, DashboardHandlerOptions, DashboardRedaction, DashboardRefusal, DashboardRequestHandler, DashboardSections, DashboardServer, DashboardSnapshot } from "./types.js";
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { DashboardSections } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The dashboard page.
|
|
4
|
+
*
|
|
5
|
+
* Shipped as a template rather than as a file on disk: a bundled library cannot assume
|
|
6
|
+
* anything about what sits next to its own JavaScript, and a dashboard that works from
|
|
7
|
+
* `src` and 404s from `dist` is the worst kind of bug to find. The markup and the CSS
|
|
8
|
+
* are the template below; the behaviour is a real TypeScript module under `client/`,
|
|
9
|
+
* bundled into {@link CLIENT_SCRIPT} at build time by `scripts/build-client.mjs` and
|
|
10
|
+
* stamped in here. It used to be two thousand lines of JavaScript inside this string,
|
|
11
|
+
* where the type-checker could not see it, Biome did not lint it and no test could
|
|
12
|
+
* import a function out of it — which is how a call to a function nobody had written
|
|
13
|
+
* shipped and blanked the whole Statistics tab.
|
|
14
|
+
*
|
|
15
|
+
* Three rules govern everything here, and all three are about the fact that this page
|
|
16
|
+
* renders **attacker-supplied text**. A User-Agent is written by the client. So is the
|
|
17
|
+
* path. So is anything a detector quoted into an evidence summary.
|
|
18
|
+
*
|
|
19
|
+
* 1. **Nothing is ever assembled into HTML.** Every value reaches the document through
|
|
20
|
+
* `textContent`. There is no `innerHTML` in the page or in the bundle, and the
|
|
21
|
+
* build refuses to produce one.
|
|
22
|
+
* 2. **No `style` attributes.** The page is served under a nonce-based CSP, which
|
|
23
|
+
* blocks inline styles; geometry is set through the CSSOM (`el.style.width`), which
|
|
24
|
+
* is not a style attribute and is allowed.
|
|
25
|
+
* 3. **No network access of any kind.** `default-src 'none'` and no external font,
|
|
26
|
+
* script or image. Everything is here.
|
|
27
|
+
*/
|
|
28
|
+
export interface DashboardPageOptions {
|
|
29
|
+
title: string;
|
|
30
|
+
basePath: string;
|
|
31
|
+
links: ReadonlyArray<{
|
|
32
|
+
label: string;
|
|
33
|
+
href: string;
|
|
34
|
+
}>;
|
|
35
|
+
allowReset: boolean;
|
|
36
|
+
allowEdit: boolean;
|
|
37
|
+
allowGuardEdit: boolean;
|
|
38
|
+
allowActing: boolean;
|
|
39
|
+
peers: ReadonlyArray<{
|
|
40
|
+
label: string;
|
|
41
|
+
href: string;
|
|
42
|
+
}>;
|
|
43
|
+
/** Which parts of the page this listener has. The client removes the rest of them. */
|
|
44
|
+
sections: Required<DashboardSections>;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Builds the page once and returns a function that stamps a per-response nonce into
|
|
48
|
+
* it. The HTML is a few tens of kilobytes and identical on every request; rebuilding
|
|
49
|
+
* it per view would be pure waste.
|
|
50
|
+
*/
|
|
51
|
+
export declare function renderDashboardPage(options: DashboardPageOptions): (nonce: string) => string;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns whatever somebody pasted into a request the engine can assess.
|
|
3
|
+
*
|
|
4
|
+
* Three shapes, because those are the three things people have to hand when they want
|
|
5
|
+
* to ask "why is this client being challenged?": a `curl` command copied out of a
|
|
6
|
+
* terminal or a browser's devtools, a raw header block copied out of a log, or just a
|
|
7
|
+
* User-Agent string copied out of a support ticket. Guessing between them is safe —
|
|
8
|
+
* they are unambiguous — and asking somebody to pick would be asking them to know.
|
|
9
|
+
*
|
|
10
|
+
* Everything here is pure and DOM-free so it can be tested by calling it. The parsing
|
|
11
|
+
* is deliberately forgiving: this is a paste box for a person in a hurry, not a
|
|
12
|
+
* protocol parser, and a header it cannot make sense of is dropped rather than fatal.
|
|
13
|
+
*/
|
|
14
|
+
export interface ParsedRequest {
|
|
15
|
+
method: string;
|
|
16
|
+
url: string;
|
|
17
|
+
headers: Record<string, string>;
|
|
18
|
+
ip: string;
|
|
19
|
+
/** Fields the paste did not carry, which had to be invented to make a request at all. */
|
|
20
|
+
assumed: string[];
|
|
21
|
+
}
|
|
22
|
+
export interface ParseOptions {
|
|
23
|
+
/** Overrides for what the paste did not say. The tester's own fields. */
|
|
24
|
+
method?: string | undefined;
|
|
25
|
+
url?: string | undefined;
|
|
26
|
+
ip?: string | undefined;
|
|
27
|
+
}
|
|
28
|
+
export declare function parseRequest(input: string, options?: ParseOptions): ParsedRequest;
|