@osqd/bothandlerjs 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +227 -1
- package/README.md +18 -10
- package/dist/adapters/fastify.d.ts +10 -0
- package/dist/adapters/index.cjs +38 -13
- package/dist/adapters/index.cjs.map +1 -1
- package/dist/adapters/index.js +38 -13
- package/dist/adapters/index.js.map +1 -1
- package/dist/challenge/index.d.ts +40 -0
- package/dist/cli.cjs +1783 -83
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +1783 -83
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +55 -0
- package/dist/core.d.ts +33 -21
- package/dist/corpus/index.cjs +282 -8
- package/dist/corpus/index.cjs.map +1 -1
- package/dist/corpus/index.js +282 -8
- package/dist/corpus/index.js.map +1 -1
- package/dist/corpus/schema.d.ts +25 -0
- package/dist/dashboard/client/actions.d.ts +1 -1
- package/dist/dashboard/client/app.d.ts +9 -2
- package/dist/dashboard/client/boot.d.ts +32 -3
- package/dist/dashboard/client/query.d.ts +72 -12
- package/dist/dashboard/client/registry.d.ts +25 -0
- package/dist/dashboard/client/saved.d.ts +29 -0
- package/dist/dashboard/client/store.d.ts +16 -2
- package/dist/dashboard/client/types.d.ts +2 -0
- package/dist/dashboard/client.generated.d.ts +1 -1
- package/dist/dashboard/types.d.ts +15 -0
- package/dist/detectors/blended-identity.d.ts +34 -0
- package/dist/detectors/challenge-integrity.d.ts +26 -0
- package/dist/detectors/challenge-reaction.d.ts +39 -0
- package/dist/detectors/clearance.d.ts +1 -23
- package/dist/detectors/index.d.ts +16 -1
- package/dist/detectors/marker.d.ts +106 -0
- package/dist/detectors/probe-signature.d.ts +27 -0
- package/dist/detectors/site-baseline.d.ts +135 -0
- package/dist/detectors/target-integrity.d.ts +16 -0
- package/dist/detectors/trap.d.ts +10 -3
- package/dist/detectors/types.d.ts +17 -0
- package/dist/element/index.cjs +726 -79
- package/dist/element/index.cjs.map +1 -1
- package/dist/element/index.js +726 -79
- package/dist/element/index.js.map +1 -1
- package/dist/index.cjs +1657 -125
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +1643 -125
- package/dist/index.js.map +1 -1
- package/dist/internal/async.d.ts +0 -3
- package/dist/internal/ip.d.ts +18 -0
- package/dist/internal/text.d.ts +28 -0
- package/dist/metrics.d.ts +18 -0
- package/dist/probe/index.d.ts +153 -0
- package/dist/probe/marker.d.ts +119 -0
- package/dist/site/index.d.ts +122 -0
- package/dist/state.d.ts +130 -0
- package/dist/stores/redis.d.ts +24 -1
- package/dist/types.d.ts +70 -0
- package/docs/course/05-detectors.md +6 -5
- package/docs/course/06-identity.md +1 -1
- package/docs/course/16-proving-it.md +15 -9
- package/docs/course/index.md +1 -1
- package/docs/design/decisions.md +1 -1
- package/docs/detection/correlation.md +284 -0
- package/docs/detection/detectors.md +139 -3
- package/docs/detection/index.md +2 -1
- package/docs/detection/shadow-mode.md +147 -0
- package/docs/detection/signatures.md +1 -1
- package/docs/index.md +3 -2
- package/docs/integration/client-ip.md +16 -0
- package/docs/operations/dashboard.md +40 -1
- package/docs/operations/filters.md +143 -0
- package/docs/operations/index.md +1 -0
- package/docs/operations/metrics.md +18 -0
- package/docs/policy/presets.md +1 -1
- package/docs/start/choosing-a-policy.md +1 -1
- package/docs/start/first-integration.md +1 -1
- package/docs/start/installation.md +2 -2
- package/docs/testing/cli.md +7 -1
- package/docs/testing/corpus.md +12 -8
- package/docs/testing/index.md +1 -1
- package/docs/testing/try-it.md +1 -1
- package/package.json +4 -1
package/dist/index.cjs
CHANGED
|
@@ -135,6 +135,45 @@ var init_lru = __esm({
|
|
|
135
135
|
}
|
|
136
136
|
});
|
|
137
137
|
|
|
138
|
+
// src/internal/text.ts
|
|
139
|
+
function safeSummary(text) {
|
|
140
|
+
let flawed = text.length > SUMMARY_CHARS;
|
|
141
|
+
if (!flawed) {
|
|
142
|
+
for (let i = 0; i < text.length; i++) {
|
|
143
|
+
const code = text.charCodeAt(i);
|
|
144
|
+
if (code < 32 || code >= 127 && code <= 159 || code >= 55296 && code <= 57343) {
|
|
145
|
+
flawed = true;
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (!flawed) return text;
|
|
151
|
+
let out = "";
|
|
152
|
+
const limit = Math.min(text.length, SUMMARY_CHARS);
|
|
153
|
+
for (let i = 0; i < limit; i++) {
|
|
154
|
+
const code = text.charCodeAt(i);
|
|
155
|
+
if (code < 32 || code >= 127 && code <= 159) {
|
|
156
|
+
out += "\uFFFD";
|
|
157
|
+
} else if (code >= 55296 && code <= 56319) {
|
|
158
|
+
const next = text.charCodeAt(i + 1);
|
|
159
|
+
if (next >= 56320 && next <= 57343) {
|
|
160
|
+
out += text[i] + text[i + 1];
|
|
161
|
+
i++;
|
|
162
|
+
} else out += "\uFFFD";
|
|
163
|
+
} else if (code >= 56320 && code <= 57343) {
|
|
164
|
+
out += "\uFFFD";
|
|
165
|
+
} else out += text[i];
|
|
166
|
+
}
|
|
167
|
+
return text.length > SUMMARY_CHARS ? `${out}\u2026` : out;
|
|
168
|
+
}
|
|
169
|
+
var SUMMARY_CHARS;
|
|
170
|
+
var init_text = __esm({
|
|
171
|
+
"src/internal/text.ts"() {
|
|
172
|
+
"use strict";
|
|
173
|
+
SUMMARY_CHARS = 512;
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
138
177
|
// src/state.ts
|
|
139
178
|
function hashString(value) {
|
|
140
179
|
let hash = 2166136261;
|
|
@@ -144,16 +183,46 @@ function hashString(value) {
|
|
|
144
183
|
}
|
|
145
184
|
return hash >>> 0;
|
|
146
185
|
}
|
|
147
|
-
|
|
186
|
+
function walkStepOf(path) {
|
|
187
|
+
if (path.length > MAX_WALK_PATH_CHARS) return void 0;
|
|
188
|
+
let depth = 0;
|
|
189
|
+
for (let i = 0; i < path.length; i++) {
|
|
190
|
+
if (path.charCodeAt(i) === 47 && ++depth > MAX_WALK_SEGMENTS) return void 0;
|
|
191
|
+
}
|
|
192
|
+
let value;
|
|
193
|
+
const parts = [];
|
|
194
|
+
for (const segment of path.split("/")) {
|
|
195
|
+
if (segment === "") continue;
|
|
196
|
+
if (DIGITS.test(segment)) {
|
|
197
|
+
const parsed = Number(segment);
|
|
198
|
+
if (Number.isSafeInteger(parsed) && parsed <= 1e7) value = parsed;
|
|
199
|
+
parts.push("#");
|
|
200
|
+
} else {
|
|
201
|
+
parts.push(segment);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (value === void 0) return void 0;
|
|
205
|
+
let template = `/${parts.join("/")}`;
|
|
206
|
+
if (template.length > TEMPLATE_CHARS) template = `${template.slice(0, TEMPLATE_CHARS)}\u2026`;
|
|
207
|
+
return { template, id: value };
|
|
208
|
+
}
|
|
209
|
+
var TIMESTAMP_RING, PATH_CAP, QUERY_CAP, METHOD_CAP, WALK_CAP, IDENTITY_CAP, TEMPLATE_CHARS, MAX_WALK_SEGMENTS, MAX_WALK_PATH_CHARS, MAX_QUERY_KEYS, DIGITS, UA_CAP, MAX_TRACKED_ARRIVALS, MAX_TRACKED_PATHS, MAX_TRACKED_QUERIES, MAX_TRACKED_USER_AGENTS, ActorState, ActorRegistry;
|
|
148
210
|
var init_state = __esm({
|
|
149
211
|
"src/state.ts"() {
|
|
150
212
|
"use strict";
|
|
151
213
|
init_lru();
|
|
214
|
+
init_text();
|
|
152
215
|
TIMESTAMP_RING = 32;
|
|
153
216
|
PATH_CAP = 64;
|
|
154
217
|
QUERY_CAP = 64;
|
|
155
218
|
METHOD_CAP = 12;
|
|
156
219
|
WALK_CAP = 4;
|
|
220
|
+
IDENTITY_CAP = 12;
|
|
221
|
+
TEMPLATE_CHARS = 120;
|
|
222
|
+
MAX_WALK_SEGMENTS = 24;
|
|
223
|
+
MAX_WALK_PATH_CHARS = 512;
|
|
224
|
+
MAX_QUERY_KEYS = 24;
|
|
225
|
+
DIGITS = /^\d+$/;
|
|
157
226
|
UA_CAP = 4;
|
|
158
227
|
MAX_TRACKED_ARRIVALS = TIMESTAMP_RING;
|
|
159
228
|
MAX_TRACKED_PATHS = PATH_CAP;
|
|
@@ -216,6 +285,38 @@ var init_state = __esm({
|
|
|
216
285
|
* long visit is checking what exists rather than reading it, and that is a fact about
|
|
217
286
|
* the actor rather than about any one of its requests — which is why it is kept here.
|
|
218
287
|
*/
|
|
288
|
+
/**
|
|
289
|
+
* What has happened with this actor's marker cookie.
|
|
290
|
+
*
|
|
291
|
+
* Counted rather than listed: the useful questions are all "how often", and a list of
|
|
292
|
+
* marker ids would grow with a client's cookie jar for no benefit. The three drift
|
|
293
|
+
* flags are sticky — once a client has been seen claiming two different browsers under
|
|
294
|
+
* one marker it has done so, and a later request that looks tidy again does not undo
|
|
295
|
+
* it. That is the point of correlating a series rather than judging a request.
|
|
296
|
+
*/
|
|
297
|
+
/**
|
|
298
|
+
* When this actor was last challenged, and how it described itself at that moment.
|
|
299
|
+
*
|
|
300
|
+
* Kept so that what a client does *in response* to being challenged can be read. That
|
|
301
|
+
* reaction is better evidence than anything observed passively, because the stimulus
|
|
302
|
+
* was ours: we chose the moment, so a change of identity that follows it within
|
|
303
|
+
* seconds is a reaction to it rather than a coincidence we went looking for.
|
|
304
|
+
*/
|
|
305
|
+
/**
|
|
306
|
+
* Answers to challenges that were valid in form but wrong in a way only the series
|
|
307
|
+
* shows: a solution already spent, or one returned faster than the puzzle allows.
|
|
308
|
+
*/
|
|
309
|
+
/** Requests for a path no other client had ever asked this site for. */
|
|
310
|
+
novelPaths = 0;
|
|
311
|
+
replayedSolutions = 0;
|
|
312
|
+
implausibleSolves = 0;
|
|
313
|
+
challengedAt = 0;
|
|
314
|
+
challengeShape;
|
|
315
|
+
markerIssues = 0;
|
|
316
|
+
markerReturns = 0;
|
|
317
|
+
markerForgeries = 0;
|
|
318
|
+
driftSeen = { browser: false, platform: false, language: false };
|
|
319
|
+
driftEvents = 0;
|
|
219
320
|
methods = /* @__PURE__ */ new Set();
|
|
220
321
|
/**
|
|
221
322
|
* Numeric walks in progress, by path shape: `/user/#` against the ids requested under it.
|
|
@@ -228,6 +329,25 @@ var init_state = __esm({
|
|
|
228
329
|
* the span survive an actor asking for ten thousand of them.
|
|
229
330
|
*/
|
|
230
331
|
walks = /* @__PURE__ */ new Map();
|
|
332
|
+
/**
|
|
333
|
+
* Every named identity this actor has claimed, and what kind each was.
|
|
334
|
+
*
|
|
335
|
+
* Kept because the interesting question is not what one request said but what the *set*
|
|
336
|
+
* of them says. One address claiming sqlmap and nikto is a scan; one claiming Googlebot
|
|
337
|
+
* and Bingbot is a forgery, since at most one of those can be true of an address. Neither
|
|
338
|
+
* observation exists inside a single request.
|
|
339
|
+
*/
|
|
340
|
+
identities = /* @__PURE__ */ new Map();
|
|
341
|
+
/**
|
|
342
|
+
* A name somebody gave this actor.
|
|
343
|
+
*
|
|
344
|
+
* Nothing in detection reads it. It exists because an address is not a memory: the
|
|
345
|
+
* person who worked out that `198.51.100.4` is the partner's price feed should be able
|
|
346
|
+
* to write that down where the next person will see it, rather than in a ticket.
|
|
347
|
+
*/
|
|
348
|
+
actorLabel;
|
|
349
|
+
/** Requests from this actor that carried a scanner payload or target. */
|
|
350
|
+
probePayloads = 0;
|
|
231
351
|
/**
|
|
232
352
|
* What the application answered, for the requests anybody bothered to tell us about.
|
|
233
353
|
*
|
|
@@ -257,8 +377,9 @@ var init_state = __esm({
|
|
|
257
377
|
this.pathsOverflowed = true;
|
|
258
378
|
this.pathsSaturatedAtTotal = this.total;
|
|
259
379
|
}
|
|
260
|
-
const keys = Object.keys(facts.query)
|
|
261
|
-
if (keys.length > 0) {
|
|
380
|
+
const keys = Object.keys(facts.query);
|
|
381
|
+
if (keys.length > 0 && keys.length <= MAX_QUERY_KEYS) {
|
|
382
|
+
keys.sort();
|
|
262
383
|
const signature = `${facts.path}?${keys.map((key) => `${key}=${facts.query[key] ?? ""}`).join("&")}`;
|
|
263
384
|
const queryHash = hashString(signature);
|
|
264
385
|
if (this.queries.size < QUERY_CAP) this.queries.add(queryHash);
|
|
@@ -302,6 +423,82 @@ var init_state = __esm({
|
|
|
302
423
|
get misses() {
|
|
303
424
|
return this.missesSeen;
|
|
304
425
|
}
|
|
426
|
+
/** Names this actor, or clears the name when given nothing. Trimmed and bounded. */
|
|
427
|
+
setLabel(label) {
|
|
428
|
+
const trimmed = label === void 0 ? void 0 : safeSummary(label).trim().slice(0, 120);
|
|
429
|
+
this.actorLabel = trimmed === void 0 || trimmed === "" ? void 0 : trimmed;
|
|
430
|
+
}
|
|
431
|
+
get label() {
|
|
432
|
+
return this.actorLabel;
|
|
433
|
+
}
|
|
434
|
+
/** Records a named identity this actor claimed. Called once per matching signature. */
|
|
435
|
+
noteIdentity(id, category, verifiable) {
|
|
436
|
+
if (this.identities.has(id) || this.identities.size >= IDENTITY_CAP) return;
|
|
437
|
+
this.identities.set(id, { category, verifiable });
|
|
438
|
+
}
|
|
439
|
+
/** Records that this request was for a path the site had never served to anybody. */
|
|
440
|
+
noteNovelPath() {
|
|
441
|
+
if (this.novelPaths < 1e6) this.novelPaths++;
|
|
442
|
+
}
|
|
443
|
+
/** How many of this actor's requests were for a path nobody else had ever asked for. */
|
|
444
|
+
get novelPathCount() {
|
|
445
|
+
return this.novelPaths;
|
|
446
|
+
}
|
|
447
|
+
/** Records something wrong with a submitted solution that only its history reveals. */
|
|
448
|
+
noteChallengeAnomaly(kind) {
|
|
449
|
+
if (kind === "replay") {
|
|
450
|
+
if (this.replayedSolutions < 1e6) this.replayedSolutions++;
|
|
451
|
+
} else if (this.implausibleSolves < 1e6) this.implausibleSolves++;
|
|
452
|
+
}
|
|
453
|
+
/** Solutions this actor submitted that had already been spent, and ones returned too fast. */
|
|
454
|
+
get challengeAnomalies() {
|
|
455
|
+
return { replays: this.replayedSolutions, implausible: this.implausibleSolves };
|
|
456
|
+
}
|
|
457
|
+
/** Records that a challenge went out, and the identity claimed as it did. */
|
|
458
|
+
noteChallengeIssued(at, shape) {
|
|
459
|
+
this.challengedAt = at;
|
|
460
|
+
this.challengeShape = shape;
|
|
461
|
+
}
|
|
462
|
+
/** The moment of the last challenge, and the identity claimed then. `at` is 0 for none. */
|
|
463
|
+
get lastChallenge() {
|
|
464
|
+
return { at: this.challengedAt, shape: this.challengeShape };
|
|
465
|
+
}
|
|
466
|
+
/** Records that a marker was handed to this actor on the way out. */
|
|
467
|
+
noteMarkerIssued() {
|
|
468
|
+
if (this.markerIssues < 1e6) this.markerIssues++;
|
|
469
|
+
}
|
|
470
|
+
/** Records what this request's marker cookie turned out to be. */
|
|
471
|
+
noteMarker(returned, forged, drift) {
|
|
472
|
+
if (returned && this.markerReturns < 1e6) this.markerReturns++;
|
|
473
|
+
if (forged && this.markerForgeries < 1e6) this.markerForgeries++;
|
|
474
|
+
if (drift === void 0) return;
|
|
475
|
+
if (drift.browser || drift.platform || drift.language) {
|
|
476
|
+
if (this.driftEvents < 1e6) this.driftEvents++;
|
|
477
|
+
}
|
|
478
|
+
this.driftSeen.browser ||= drift.browser;
|
|
479
|
+
this.driftSeen.platform ||= drift.platform;
|
|
480
|
+
this.driftSeen.language ||= drift.language;
|
|
481
|
+
}
|
|
482
|
+
/** Markers handed to this actor, and how many came back. */
|
|
483
|
+
get markers() {
|
|
484
|
+
return { issued: this.markerIssues, returned: this.markerReturns, forged: this.markerForgeries };
|
|
485
|
+
}
|
|
486
|
+
/** Which parts of a claimed identity have ever changed under one marker. */
|
|
487
|
+
get identityDrift() {
|
|
488
|
+
return { ...this.driftSeen, events: this.driftEvents };
|
|
489
|
+
}
|
|
490
|
+
/** Records that this request carried a scanner payload, so later requests can know. */
|
|
491
|
+
notePayloadProbe() {
|
|
492
|
+
this.probePayloads++;
|
|
493
|
+
}
|
|
494
|
+
/** Every identity claimed so far, by id. */
|
|
495
|
+
get claimedIdentities() {
|
|
496
|
+
return this.identities;
|
|
497
|
+
}
|
|
498
|
+
/** How many of this actor's requests carried a scanner payload or target. */
|
|
499
|
+
get payloadProbes() {
|
|
500
|
+
return this.probePayloads;
|
|
501
|
+
}
|
|
305
502
|
/**
|
|
306
503
|
* Files a request under the shape of its path, if that path carries a number.
|
|
307
504
|
*
|
|
@@ -309,19 +506,9 @@ var init_state = __esm({
|
|
|
309
506
|
* the version is part of the shape and the order id is what is being walked.
|
|
310
507
|
*/
|
|
311
508
|
noteWalk(path) {
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
for (const segment of segments) {
|
|
316
|
-
if (segment !== "" && /^\d+$/.test(segment)) {
|
|
317
|
-
const parsed = Number(segment);
|
|
318
|
-
if (Number.isSafeInteger(parsed) && parsed <= 1e7) value = parsed;
|
|
319
|
-
template += "/#";
|
|
320
|
-
} else if (segment !== "") {
|
|
321
|
-
template += `/${segment}`;
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
if (value === void 0) return;
|
|
509
|
+
const step = walkStepOf(path);
|
|
510
|
+
if (step === void 0) return;
|
|
511
|
+
const { template, id: value } = step;
|
|
325
512
|
const existing = this.walks.get(template);
|
|
326
513
|
if (existing !== void 0) {
|
|
327
514
|
existing.count++;
|
|
@@ -439,6 +626,7 @@ var init_state = __esm({
|
|
|
439
626
|
distinctPaths: this.distinctPaths,
|
|
440
627
|
distinctQueries: this.distinctQueries,
|
|
441
628
|
methodsSeen: this.methodsSeen,
|
|
629
|
+
...this.actorLabel === void 0 ? {} : { label: this.actorLabel },
|
|
442
630
|
walk: this.densestWalk(),
|
|
443
631
|
responses: this.responses,
|
|
444
632
|
misses: this.misses,
|
|
@@ -571,6 +759,58 @@ var init_crypto = __esm({
|
|
|
571
759
|
}
|
|
572
760
|
});
|
|
573
761
|
|
|
762
|
+
// src/challenge/token.ts
|
|
763
|
+
function issueToken(payload, secrets) {
|
|
764
|
+
const secret = secrets[0];
|
|
765
|
+
if (secret === void 0) throw new Error("At least one signing secret is required to issue a token");
|
|
766
|
+
const body = base64UrlEncode(JSON.stringify(payload));
|
|
767
|
+
return `${body}.${sign(body, secret)}`;
|
|
768
|
+
}
|
|
769
|
+
function verifyToken(token, secrets, now, expectedSubject) {
|
|
770
|
+
if (token.length === 0 || token.length > MAX_TOKEN_LENGTH) return { ok: false, reason: "malformed" };
|
|
771
|
+
const separator = token.lastIndexOf(".");
|
|
772
|
+
if (separator <= 0) return { ok: false, reason: "malformed" };
|
|
773
|
+
const body = token.slice(0, separator);
|
|
774
|
+
const signature = token.slice(separator + 1);
|
|
775
|
+
let valid = false;
|
|
776
|
+
for (const secret of secrets) {
|
|
777
|
+
if (constantTimeEqual(signature, sign(body, secret))) valid = true;
|
|
778
|
+
}
|
|
779
|
+
if (!valid) return { ok: false, reason: "bad-signature" };
|
|
780
|
+
let payload;
|
|
781
|
+
try {
|
|
782
|
+
const decoded = base64UrlDecode(body).toString("utf8");
|
|
783
|
+
payload = JSON.parse(decoded);
|
|
784
|
+
} catch {
|
|
785
|
+
return { ok: false, reason: "malformed" };
|
|
786
|
+
}
|
|
787
|
+
if (typeof payload !== "object" || payload === null) return { ok: false, reason: "malformed" };
|
|
788
|
+
if (typeof payload.exp !== "number" || typeof payload.sub !== "string") return { ok: false, reason: "malformed" };
|
|
789
|
+
if (payload.exp <= now) return { ok: false, reason: "expired" };
|
|
790
|
+
if (expectedSubject !== void 0) {
|
|
791
|
+
let bound = false;
|
|
792
|
+
for (const candidate of typeof expectedSubject === "string" ? [expectedSubject] : expectedSubject) {
|
|
793
|
+
if (constantTimeEqual(payload.sub, candidate)) bound = true;
|
|
794
|
+
}
|
|
795
|
+
if (!bound) return { ok: false, reason: "wrong-actor" };
|
|
796
|
+
}
|
|
797
|
+
return { ok: true, payload };
|
|
798
|
+
}
|
|
799
|
+
function newChallenge(subject, difficulty, ttlMs, now) {
|
|
800
|
+
return { v: 1, sub: subject, iat: now, exp: now + ttlMs, nonce: randomId(12), diff: difficulty };
|
|
801
|
+
}
|
|
802
|
+
function newClearance(subject, level, ttlMs, now) {
|
|
803
|
+
return { v: 1, sub: subject, iat: now, exp: now + ttlMs, jti: randomId(9), lvl: level };
|
|
804
|
+
}
|
|
805
|
+
var MAX_TOKEN_LENGTH;
|
|
806
|
+
var init_token = __esm({
|
|
807
|
+
"src/challenge/token.ts"() {
|
|
808
|
+
"use strict";
|
|
809
|
+
init_crypto();
|
|
810
|
+
MAX_TOKEN_LENGTH = 2048;
|
|
811
|
+
}
|
|
812
|
+
});
|
|
813
|
+
|
|
574
814
|
// src/internal/http.ts
|
|
575
815
|
function parseCookies(header) {
|
|
576
816
|
const cookies = /* @__PURE__ */ Object.create(null);
|
|
@@ -685,6 +925,11 @@ function toPrometheus(snapshot, options = {}) {
|
|
|
685
925
|
counter("downgrades_total", "Terminal actions the safety guard replaced for lack of proof.", [["", snapshot.downgrades]]);
|
|
686
926
|
counter("proven_total", "Assessments resting on proven evidence.", [["", snapshot.proven]]);
|
|
687
927
|
counter("detector_firings_total", "Evidence produced, by detector.", Object.entries(snapshot.detectorFirings).map(([detector, value]) => [`{detector="${escapeLabel(detector)}"}`, value]));
|
|
928
|
+
const shadowFirings = Object.entries(snapshot.shadowFirings);
|
|
929
|
+
if (shadowFirings.length > 0) {
|
|
930
|
+
counter("shadow_firings_total", "Evidence produced by shadowed detectors, which decided nothing.", shadowFirings.map(([detector, value]) => [`{detector="${escapeLabel(detector)}"}`, value]));
|
|
931
|
+
counter("shadow_verdict_changes_total", "Assessments the shadowed detectors would have moved, by the verdict they would have produced.", Object.entries(snapshot.shadowChanges).map(([verdict, value]) => [`{verdict="${verdict}"}`, value]));
|
|
932
|
+
}
|
|
688
933
|
counter("detector_failures_total", "Detector errors and timeouts.", Object.entries(snapshot.detectorFailures).map(([detector, value]) => [`{detector="${escapeLabel(detector)}"}`, value]));
|
|
689
934
|
const timings = Object.entries(snapshot.detectorTimings);
|
|
690
935
|
if (timings.length > 0) {
|
|
@@ -752,6 +997,8 @@ var init_metrics = __esm({
|
|
|
752
997
|
detectorFirings = /* @__PURE__ */ new Map();
|
|
753
998
|
detectorFailures = /* @__PURE__ */ new Map();
|
|
754
999
|
detectorTimings = /* @__PURE__ */ new Map();
|
|
1000
|
+
shadowFirings = /* @__PURE__ */ new Map();
|
|
1001
|
+
shadowChanges = zeroed(VERDICTS);
|
|
755
1002
|
challengesIssued = 0;
|
|
756
1003
|
challengesSolved = 0;
|
|
757
1004
|
challengesRejected = 0;
|
|
@@ -785,6 +1032,10 @@ var init_metrics = __esm({
|
|
|
785
1032
|
}
|
|
786
1033
|
for (const item of assessment.evidence) bump(this.detectorFirings, item.detector);
|
|
787
1034
|
for (const item of assessment.humanEvidence) bump(this.detectorFirings, item.detector);
|
|
1035
|
+
for (const item of assessment.shadowEvidence) bump(this.shadowFirings, item.detector);
|
|
1036
|
+
if (assessment.shadowVerdict !== void 0 && assessment.shadowVerdict.verdict !== assessment.verdict) {
|
|
1037
|
+
this.shadowChanges[assessment.shadowVerdict.verdict]++;
|
|
1038
|
+
}
|
|
788
1039
|
for (const failure of assessment.failures) bump(this.detectorFailures, failure.detector);
|
|
789
1040
|
const ms = assessment.durationMs;
|
|
790
1041
|
this.durationCount++;
|
|
@@ -851,6 +1102,8 @@ var init_metrics = __esm({
|
|
|
851
1102
|
detectorFirings: Object.fromEntries(this.detectorFirings),
|
|
852
1103
|
detectorFailures: Object.fromEntries(this.detectorFailures),
|
|
853
1104
|
detectorTimings: Object.fromEntries([...this.detectorTimings].map(([id, timing]) => [id, { ...timing }])),
|
|
1105
|
+
shadowFirings: Object.fromEntries(this.shadowFirings),
|
|
1106
|
+
shadowChanges: { ...this.shadowChanges },
|
|
854
1107
|
challenges: { issued: this.challengesIssued, solved: this.challengesSolved, rejected: this.challengesRejected },
|
|
855
1108
|
clearances: Object.fromEntries(this.clearances),
|
|
856
1109
|
challengeRejections: Object.fromEntries(this.challengeRejections),
|
|
@@ -875,8 +1128,21 @@ __export(ip_exports, {
|
|
|
875
1128
|
networkKey: () => networkKey,
|
|
876
1129
|
normalizeIp: () => normalizeIp,
|
|
877
1130
|
parseCidr: () => parseCidr,
|
|
878
|
-
parseIp: () => parseIp
|
|
1131
|
+
parseIp: () => parseIp,
|
|
1132
|
+
stripPort: () => stripPort
|
|
879
1133
|
});
|
|
1134
|
+
function stripPort(value) {
|
|
1135
|
+
const input = value.trim();
|
|
1136
|
+
if (input.startsWith("[")) {
|
|
1137
|
+
const close = input.indexOf("]");
|
|
1138
|
+
if (close > 0) return input.slice(1, close);
|
|
1139
|
+
return input;
|
|
1140
|
+
}
|
|
1141
|
+
const colon = input.indexOf(":");
|
|
1142
|
+
if (colon === -1 || input.indexOf(":", colon + 1) !== -1) return input;
|
|
1143
|
+
const host = input.slice(0, colon);
|
|
1144
|
+
return parseIpv4(host) !== null ? host : input;
|
|
1145
|
+
}
|
|
880
1146
|
function parseIp(value) {
|
|
881
1147
|
const input = value.trim();
|
|
882
1148
|
if (input.length === 0 || input.length > 45) return null;
|
|
@@ -889,11 +1155,11 @@ function parseIpv4(value) {
|
|
|
889
1155
|
if (parts.length !== 4) return null;
|
|
890
1156
|
const bytes = new Uint8Array(4);
|
|
891
1157
|
for (let i = 0; i < 4; i++) {
|
|
892
|
-
const
|
|
893
|
-
if (
|
|
894
|
-
if (!/^\d+$/.test(
|
|
895
|
-
if (
|
|
896
|
-
const n = Number(
|
|
1158
|
+
const part2 = parts[i];
|
|
1159
|
+
if (part2.length === 0 || part2.length > 3) return null;
|
|
1160
|
+
if (!/^\d+$/.test(part2)) return null;
|
|
1161
|
+
if (part2.length > 1 && part2[0] === "0") return null;
|
|
1162
|
+
const n = Number(part2);
|
|
897
1163
|
if (n > 255) return null;
|
|
898
1164
|
bytes[i] = n;
|
|
899
1165
|
}
|
|
@@ -1160,9 +1426,13 @@ function redactAssessment(assessment, options) {
|
|
|
1160
1426
|
removed,
|
|
1161
1427
|
assessment: {
|
|
1162
1428
|
...assessment,
|
|
1429
|
+
...assessment.marker === void 0 ? {} : { marker: reduceMarker(assessment.marker, options) },
|
|
1163
1430
|
actor: options.maskIp ? { ...assessment.actor, key: maskActorKey(assessment.actor.key) } : assessment.actor,
|
|
1164
1431
|
evidence: scrubEvidence(assessment.evidence, removed),
|
|
1165
1432
|
humanEvidence: scrubEvidence(assessment.humanEvidence, removed),
|
|
1433
|
+
// Scrubbed on the same terms: a shadowed detector reads the same request as every
|
|
1434
|
+
// other one, so its summary can quote the same secret out of it.
|
|
1435
|
+
shadowEvidence: scrubEvidence(assessment.shadowEvidence, removed),
|
|
1166
1436
|
facts: {
|
|
1167
1437
|
...assessment.facts,
|
|
1168
1438
|
ip: options.maskIp ? maskIpValue(assessment.facts.ip) : assessment.facts.ip,
|
|
@@ -1214,6 +1484,15 @@ function maskActorKey(key) {
|
|
|
1214
1484
|
if (separator === -1) return networkKey(key);
|
|
1215
1485
|
return `${networkKey(key.slice(0, separator))}|${key.slice(separator + 1)}`;
|
|
1216
1486
|
}
|
|
1487
|
+
function reduceMarker(marker, options) {
|
|
1488
|
+
return {
|
|
1489
|
+
...marker,
|
|
1490
|
+
reading: { kind: marker.reading.kind },
|
|
1491
|
+
// The shape is three coarse parts of the User-Agent. If the User-Agent itself is
|
|
1492
|
+
// being withheld, the parts of it must go too, or the setting only half applies.
|
|
1493
|
+
...options.dropUserAgent === true ? { shape: { b: "", o: "", l: "" } } : {}
|
|
1494
|
+
};
|
|
1495
|
+
}
|
|
1217
1496
|
var REDACTED, CREDENTIAL_HEADERS, ALWAYS_STRIP, MIN_SCRUB_LENGTH;
|
|
1218
1497
|
var init_redact = __esm({
|
|
1219
1498
|
"src/notify/redact.ts"() {
|
|
@@ -1836,37 +2115,50 @@ var init_dns = __esm({
|
|
|
1836
2115
|
});
|
|
1837
2116
|
|
|
1838
2117
|
// src/detectors/clearance.ts
|
|
1839
|
-
function
|
|
2118
|
+
function withShared(shared, primary) {
|
|
2119
|
+
return shared === void 0 ? primary : [primary, shared];
|
|
2120
|
+
}
|
|
2121
|
+
function clearanceDetector(service, sharingThreshold = DEFAULT_SHARING_THRESHOLD) {
|
|
1840
2122
|
return {
|
|
1841
2123
|
id: "clearance",
|
|
1842
2124
|
description: "Reads a signed clearance token proving the client previously passed a check",
|
|
1843
2125
|
cost: "cheap",
|
|
1844
2126
|
stage: "always",
|
|
1845
2127
|
inspect(ctx) {
|
|
1846
|
-
const claims = service.
|
|
1847
|
-
|
|
2128
|
+
const { claims, boundElsewhere, presentedBy } = service.inspect(ctx.actor.key, ctx.facts.cookies);
|
|
2129
|
+
const shared = presentedBy >= sharingThreshold ? {
|
|
2130
|
+
detector: "clearance",
|
|
2131
|
+
summary: `The clearance token presented here has now been presented by ${presentedBy} different clients`,
|
|
2132
|
+
direction: "bot",
|
|
2133
|
+
certainty: "moderate",
|
|
2134
|
+
botClass: "scraper"
|
|
2135
|
+
} : void 0;
|
|
2136
|
+
if (!claims) {
|
|
2137
|
+
void boundElsewhere;
|
|
2138
|
+
return shared;
|
|
2139
|
+
}
|
|
1848
2140
|
const ageMs = ctx.facts.timestamp - claims.iat;
|
|
1849
2141
|
if (claims.lvl === "operator") {
|
|
1850
|
-
return {
|
|
2142
|
+
return withShared(shared, {
|
|
1851
2143
|
detector: "clearance",
|
|
1852
2144
|
summary: "Client holds an operator-granted clearance token",
|
|
1853
2145
|
direction: "human",
|
|
1854
2146
|
certainty: "certain",
|
|
1855
2147
|
deterministicBasis: "The application issued this clearance itself, on evidence it holds and this library cannot see. It is an assertion by the operator, not an inference from the request, and the token's signature binds it to this actor.",
|
|
1856
2148
|
metadata: { level: claims.lvl, ageMs }
|
|
1857
|
-
};
|
|
2149
|
+
});
|
|
1858
2150
|
}
|
|
1859
2151
|
if (claims.lvl === "interaction") {
|
|
1860
|
-
return {
|
|
2152
|
+
return withShared(shared, {
|
|
1861
2153
|
detector: "clearance",
|
|
1862
2154
|
summary: "Client holds a clearance token granted after a trusted input event",
|
|
1863
2155
|
direction: "human",
|
|
1864
2156
|
certainty: "strong",
|
|
1865
2157
|
weight: 0.7,
|
|
1866
2158
|
metadata: { level: claims.lvl, ageMs }
|
|
1867
|
-
};
|
|
2159
|
+
});
|
|
1868
2160
|
}
|
|
1869
|
-
return {
|
|
2161
|
+
return withShared(shared, {
|
|
1870
2162
|
detector: "clearance",
|
|
1871
2163
|
summary: "Client holds a clearance token granted for a completed proof of work",
|
|
1872
2164
|
direction: "human",
|
|
@@ -1877,12 +2169,388 @@ function clearanceDetector(service) {
|
|
|
1877
2169
|
ageMs,
|
|
1878
2170
|
note: "Proof of work demonstrates a JavaScript engine and spent CPU. It does not demonstrate a person."
|
|
1879
2171
|
}
|
|
2172
|
+
});
|
|
2173
|
+
}
|
|
2174
|
+
};
|
|
2175
|
+
}
|
|
2176
|
+
var DEFAULT_SHARING_THRESHOLD;
|
|
2177
|
+
var init_clearance = __esm({
|
|
2178
|
+
"src/detectors/clearance.ts"() {
|
|
2179
|
+
"use strict";
|
|
2180
|
+
DEFAULT_SHARING_THRESHOLD = 12;
|
|
2181
|
+
}
|
|
2182
|
+
});
|
|
2183
|
+
|
|
2184
|
+
// src/probe/marker.ts
|
|
2185
|
+
function identityShape(facts, ua) {
|
|
2186
|
+
const platform = facts.headers["sec-ch-ua-platform"]?.replace(/"/g, "").trim().toLowerCase();
|
|
2187
|
+
const language = facts.headers["accept-language"]?.split(",")[0]?.split("-")[0]?.trim().toLowerCase();
|
|
2188
|
+
return {
|
|
2189
|
+
// A client that names no browser is its own category, and an empty User-Agent must
|
|
2190
|
+
// not read as equal to every other empty one by accident — it reads as "none", which
|
|
2191
|
+
// is exactly what it is, and changing away from it is a real change.
|
|
2192
|
+
b: part(ua.browser ?? (ua.raw.length === 0 ? "none" : `t:${withoutVersions(ua.raw)}`)),
|
|
2193
|
+
o: part(ua.os ?? platform ?? "none"),
|
|
2194
|
+
l: part(language ?? "none")
|
|
2195
|
+
};
|
|
2196
|
+
}
|
|
2197
|
+
function part(value) {
|
|
2198
|
+
const trimmed = value.length > 40 ? value.slice(0, 40) : value;
|
|
2199
|
+
return trimmed.toLowerCase();
|
|
2200
|
+
}
|
|
2201
|
+
function withoutVersions(raw) {
|
|
2202
|
+
return raw.replace(VERSION_NUMBERS, "#");
|
|
2203
|
+
}
|
|
2204
|
+
function driftBetween(issued, now) {
|
|
2205
|
+
return { browser: issued.b !== now.b, platform: issued.o !== now.o, language: issued.l !== now.l };
|
|
2206
|
+
}
|
|
2207
|
+
function newMarker(shape, ttlMs, now) {
|
|
2208
|
+
return { v: 1, sub: randomId(9), iat: now, exp: now + ttlMs, ...shape };
|
|
2209
|
+
}
|
|
2210
|
+
function markerCookie(name, claims, secrets, options) {
|
|
2211
|
+
return serializeCookie(name, issueToken(claims, secrets), {
|
|
2212
|
+
maxAgeMs: claims.exp - claims.iat,
|
|
2213
|
+
sameSite: options.sameSite ?? "Lax",
|
|
2214
|
+
secure: options.secure ?? true,
|
|
2215
|
+
// Nothing in a page needs to read this, and a marker readable by script is one a
|
|
2216
|
+
// cross-site script can lift.
|
|
2217
|
+
httpOnly: true,
|
|
2218
|
+
...options.domain === void 0 ? {} : { domain: options.domain }
|
|
2219
|
+
});
|
|
2220
|
+
}
|
|
2221
|
+
function readMarker(value, secrets, now) {
|
|
2222
|
+
if (value === void 0 || value.length === 0) return { kind: "absent" };
|
|
2223
|
+
if (!TOKEN_SHAPE.test(value)) return { kind: "absent" };
|
|
2224
|
+
const verified = verifyToken(value, secrets, now);
|
|
2225
|
+
if (verified.ok) return { kind: "valid", claims: verified.payload };
|
|
2226
|
+
return verified.reason === "expired" ? { kind: "expired" } : { kind: "forged" };
|
|
2227
|
+
}
|
|
2228
|
+
var VERSION_NUMBERS, TOKEN_SHAPE;
|
|
2229
|
+
var init_marker = __esm({
|
|
2230
|
+
"src/probe/marker.ts"() {
|
|
2231
|
+
"use strict";
|
|
2232
|
+
init_token();
|
|
2233
|
+
init_crypto();
|
|
2234
|
+
init_http();
|
|
2235
|
+
VERSION_NUMBERS = /\d+(?:[._]\d+)*/g;
|
|
2236
|
+
TOKEN_SHAPE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
|
|
2237
|
+
}
|
|
2238
|
+
});
|
|
2239
|
+
|
|
2240
|
+
// src/detectors/challenge-reaction.ts
|
|
2241
|
+
function challengeReactionDetector(options = {}) {
|
|
2242
|
+
const windowMs = options.windowMs ?? 6e4;
|
|
2243
|
+
const minUnsolved = options.minUnsolved ?? 4;
|
|
2244
|
+
return {
|
|
2245
|
+
id: "challenge-reaction",
|
|
2246
|
+
description: "Reads how a client responded to being challenged: a changed identity, or never answering at all",
|
|
2247
|
+
cost: "cheap",
|
|
2248
|
+
stage: "always",
|
|
2249
|
+
inspect(ctx) {
|
|
2250
|
+
const found = [];
|
|
2251
|
+
const { at, shape } = ctx.state.lastChallenge;
|
|
2252
|
+
const since = at === 0 ? Number.POSITIVE_INFINITY : ctx.facts.timestamp - at;
|
|
2253
|
+
if (shape !== void 0 && since >= 0 && since <= windowMs) {
|
|
2254
|
+
const now = ctx.marker?.shape ?? identityShape(ctx.facts, ctx.ua);
|
|
2255
|
+
if (now.b !== shape.b) {
|
|
2256
|
+
const proven = ctx.marker?.reading.kind === "valid";
|
|
2257
|
+
found.push({
|
|
2258
|
+
detector: "challenge-reaction",
|
|
2259
|
+
summary: proven ? `Client changed the browser it claims to be within ${Math.round(since / 1e3)}s of being challenged, holding the same marker throughout` : `A client at this address changed the browser it claims to be within ${Math.round(since / 1e3)}s of being challenged`,
|
|
2260
|
+
direction: "bot",
|
|
2261
|
+
certainty: proven ? "strong" : "moderate",
|
|
2262
|
+
botClass: "impersonator",
|
|
2263
|
+
// One cause with `identity-drift`: this client changed what it claims to be.
|
|
2264
|
+
// Both fire together whenever a challenge is what prompted the change.
|
|
2265
|
+
family: "identity-change"
|
|
2266
|
+
});
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
if (ctx.state.unsolvedChallenges >= minUnsolved) {
|
|
2270
|
+
found.push({
|
|
2271
|
+
detector: "challenge-reaction",
|
|
2272
|
+
summary: `Challenged ${ctx.state.unsolvedChallenges} times and has never returned a solution`,
|
|
2273
|
+
direction: "bot",
|
|
2274
|
+
certainty: "moderate",
|
|
2275
|
+
botClass: "unknown"
|
|
2276
|
+
});
|
|
2277
|
+
}
|
|
2278
|
+
return found.length > 0 ? found : void 0;
|
|
2279
|
+
}
|
|
2280
|
+
};
|
|
2281
|
+
}
|
|
2282
|
+
var init_challenge_reaction = __esm({
|
|
2283
|
+
"src/detectors/challenge-reaction.ts"() {
|
|
2284
|
+
"use strict";
|
|
2285
|
+
init_marker();
|
|
2286
|
+
}
|
|
2287
|
+
});
|
|
2288
|
+
|
|
2289
|
+
// src/detectors/challenge-integrity.ts
|
|
2290
|
+
function challengeIntegrityDetector(options = {}) {
|
|
2291
|
+
const minReplays = options.minReplays ?? 3;
|
|
2292
|
+
const minImplausible = options.minImplausible ?? 1;
|
|
2293
|
+
return {
|
|
2294
|
+
id: "challenge-integrity",
|
|
2295
|
+
description: "Reports solutions that were replayed, or returned faster than the proof of work allows",
|
|
2296
|
+
cost: "cheap",
|
|
2297
|
+
stage: "always",
|
|
2298
|
+
inspect(ctx) {
|
|
2299
|
+
const { replays, implausible } = ctx.state.challengeAnomalies;
|
|
2300
|
+
const found = [];
|
|
2301
|
+
if (replays >= minReplays) {
|
|
2302
|
+
found.push({
|
|
2303
|
+
detector: "challenge-integrity",
|
|
2304
|
+
summary: `Submitted ${replays} solutions for challenges that had already been solved`,
|
|
2305
|
+
direction: "bot",
|
|
2306
|
+
certainty: "moderate",
|
|
2307
|
+
botClass: "unknown"
|
|
2308
|
+
});
|
|
2309
|
+
}
|
|
2310
|
+
if (implausible >= minImplausible) {
|
|
2311
|
+
found.push({
|
|
2312
|
+
detector: "challenge-integrity",
|
|
2313
|
+
summary: implausible > 1 ? `Returned ${implausible} solutions faster than the proof of work can be computed in a browser` : "Returned a solution faster than the proof of work can be computed in a browser",
|
|
2314
|
+
direction: "bot",
|
|
2315
|
+
certainty: "moderate",
|
|
2316
|
+
botClass: "automation"
|
|
2317
|
+
});
|
|
2318
|
+
}
|
|
2319
|
+
return found.length > 0 ? found : void 0;
|
|
2320
|
+
}
|
|
2321
|
+
};
|
|
2322
|
+
}
|
|
2323
|
+
var init_challenge_integrity = __esm({
|
|
2324
|
+
"src/detectors/challenge-integrity.ts"() {
|
|
2325
|
+
"use strict";
|
|
2326
|
+
}
|
|
2327
|
+
});
|
|
2328
|
+
|
|
2329
|
+
// src/detectors/site-baseline.ts
|
|
2330
|
+
function distributedWalkDetector(options = {}) {
|
|
2331
|
+
const minActors = options.minActors ?? 8;
|
|
2332
|
+
const minIds = options.minIds ?? 150;
|
|
2333
|
+
const minCoverage = options.minCoverage ?? 0.6;
|
|
2334
|
+
const maxRevisitRatio = options.maxRevisitRatio ?? 1.3;
|
|
2335
|
+
const minRevisitRatio = options.minRevisitRatio ?? 0.7;
|
|
2336
|
+
return {
|
|
2337
|
+
id: "distributed-walk",
|
|
2338
|
+
description: "Reports a numeric range being walked across many clients, none of which walks enough of it alone",
|
|
2339
|
+
cost: "cheap",
|
|
2340
|
+
stage: "always",
|
|
2341
|
+
inspect(ctx) {
|
|
2342
|
+
if (ctx.site === void 0 || !ctx.site.warm) return void 0;
|
|
2343
|
+
const step = walkStepOf(ctx.facts.path);
|
|
2344
|
+
if (step === void 0) return void 0;
|
|
2345
|
+
const spread = ctx.site.spreadOf(step.template);
|
|
2346
|
+
if (spread === void 0) return void 0;
|
|
2347
|
+
if (spread.actors < minActors || spread.ids < minIds) return void 0;
|
|
2348
|
+
if (spread.coverage < minCoverage) return void 0;
|
|
2349
|
+
const revisits = spread.visits / spread.ids;
|
|
2350
|
+
if (revisits > maxRevisitRatio || revisits < minRevisitRatio) return void 0;
|
|
2351
|
+
return {
|
|
2352
|
+
detector: "distributed-walk",
|
|
2353
|
+
summary: `${spread.actors} clients between them have requested ${spread.ids} ids under ${step.template}, covering ${(spread.coverage * 100).toFixed(0)}% of the range and almost none of them twice`,
|
|
2354
|
+
direction: "bot",
|
|
2355
|
+
certainty: "moderate",
|
|
2356
|
+
botClass: "scraper"
|
|
2357
|
+
};
|
|
2358
|
+
}
|
|
2359
|
+
};
|
|
2360
|
+
}
|
|
2361
|
+
function pathNoveltyDetector(options = {}) {
|
|
2362
|
+
const minRequests = options.minRequests ?? 30;
|
|
2363
|
+
const minNovelShare = options.minNovelShare ?? 0.95;
|
|
2364
|
+
return {
|
|
2365
|
+
id: "path-novelty",
|
|
2366
|
+
description: "Reports a client whose requests are almost all for paths this site has never been asked for",
|
|
2367
|
+
cost: "cheap",
|
|
2368
|
+
stage: "always",
|
|
2369
|
+
inspect(ctx) {
|
|
2370
|
+
if (ctx.site === void 0 || !ctx.site.warm) return void 0;
|
|
2371
|
+
const total = ctx.state.total;
|
|
2372
|
+
if (total < minRequests) return void 0;
|
|
2373
|
+
const share = ctx.state.novelPathCount / total;
|
|
2374
|
+
if (share < minNovelShare) return void 0;
|
|
2375
|
+
return {
|
|
2376
|
+
detector: "path-novelty",
|
|
2377
|
+
summary: `${(share * 100).toFixed(0)}% of this client's ${total} requests were for paths no other client has ever asked this site for`,
|
|
2378
|
+
direction: "bot",
|
|
2379
|
+
certainty: "moderate",
|
|
2380
|
+
botClass: "scanner",
|
|
2381
|
+
// The same cause `probe-signature` names when a path is on a list it ships: this
|
|
2382
|
+
// client is walking a list rather than reading a site.
|
|
2383
|
+
family: "wordlist-probe"
|
|
2384
|
+
};
|
|
2385
|
+
}
|
|
2386
|
+
};
|
|
2387
|
+
}
|
|
2388
|
+
function missBaselineDetector(options = {}) {
|
|
2389
|
+
const minResponses = options.minResponses ?? 20;
|
|
2390
|
+
const minRatio = options.minRatio ?? 5;
|
|
2391
|
+
const floor = options.floor ?? 0.5;
|
|
2392
|
+
return {
|
|
2393
|
+
id: "miss-baseline",
|
|
2394
|
+
description: 'Compares how often a client is answered "not found" with how often this site answers that at all',
|
|
2395
|
+
cost: "cheap",
|
|
2396
|
+
stage: "always",
|
|
2397
|
+
inspect(ctx) {
|
|
2398
|
+
const siteRate = ctx.site?.missRate;
|
|
2399
|
+
if (siteRate === void 0) return void 0;
|
|
2400
|
+
const { responses, misses } = ctx.state;
|
|
2401
|
+
if (responses < minResponses) return void 0;
|
|
2402
|
+
const rate = misses / responses;
|
|
2403
|
+
if (rate < floor) return void 0;
|
|
2404
|
+
if (siteRate > 0 && rate / siteRate < minRatio) return void 0;
|
|
2405
|
+
return {
|
|
2406
|
+
detector: "miss-baseline",
|
|
2407
|
+
summary: siteRate > 0 ? `${(rate * 100).toFixed(0)}% of this client's requests were answered "not found", against ${(siteRate * 100).toFixed(1)}% across the site` : `${(rate * 100).toFixed(0)}% of this client's requests were answered "not found", on a site that otherwise never answers that`,
|
|
2408
|
+
direction: "bot",
|
|
2409
|
+
certainty: "moderate",
|
|
2410
|
+
botClass: "scanner",
|
|
2411
|
+
// `probe-volume` reads the same misses against a fixed threshold. Two readings
|
|
2412
|
+
// of one cause, so the stronger stands and they do not sum.
|
|
2413
|
+
family: "misses"
|
|
2414
|
+
};
|
|
2415
|
+
}
|
|
2416
|
+
};
|
|
2417
|
+
}
|
|
2418
|
+
function pathCampaignDetector(options = {}) {
|
|
2419
|
+
const minClients = options.minClients ?? 12;
|
|
2420
|
+
const minMissShare = options.minMissShare ?? 0.9;
|
|
2421
|
+
const minAnswered = options.minAnswered ?? 10;
|
|
2422
|
+
return {
|
|
2423
|
+
id: "path-campaign",
|
|
2424
|
+
description: "Reports a path this site never served that many unrelated clients have suddenly begun requesting",
|
|
2425
|
+
cost: "cheap",
|
|
2426
|
+
stage: "always",
|
|
2427
|
+
inspect(ctx) {
|
|
2428
|
+
const surge = ctx.site?.surgeOf(ctx.facts.path);
|
|
2429
|
+
if (surge === void 0) return void 0;
|
|
2430
|
+
if (surge.clients < minClients || surge.answered < minAnswered) return void 0;
|
|
2431
|
+
if (surge.misses / surge.answered < minMissShare) return void 0;
|
|
2432
|
+
return {
|
|
2433
|
+
detector: "path-campaign",
|
|
2434
|
+
summary: `${surge.clients} unrelated clients have requested ${ctx.facts.path} since it first appeared ${Math.round(surge.ageMs / 6e4)} minutes ago, and the site has answered "not found" to almost all of them`,
|
|
2435
|
+
direction: "bot",
|
|
2436
|
+
certainty: "moderate",
|
|
2437
|
+
botClass: "scanner"
|
|
2438
|
+
};
|
|
2439
|
+
}
|
|
2440
|
+
};
|
|
2441
|
+
}
|
|
2442
|
+
var init_site_baseline = __esm({
|
|
2443
|
+
"src/detectors/site-baseline.ts"() {
|
|
2444
|
+
"use strict";
|
|
2445
|
+
init_state();
|
|
2446
|
+
}
|
|
2447
|
+
});
|
|
2448
|
+
|
|
2449
|
+
// src/detectors/marker.ts
|
|
2450
|
+
function identityDriftDetector(options = {}) {
|
|
2451
|
+
const reportSoft = options.reportSoftDrift ?? true;
|
|
2452
|
+
return {
|
|
2453
|
+
id: "identity-drift",
|
|
2454
|
+
description: "Compares the identity a client claims now with the one it claimed when it was given its marker",
|
|
2455
|
+
cost: "cheap",
|
|
2456
|
+
stage: "always",
|
|
2457
|
+
inspect(ctx) {
|
|
2458
|
+
const drift = ctx.marker?.drift;
|
|
2459
|
+
if (drift === void 0) return void 0;
|
|
2460
|
+
if (drift.browser) {
|
|
2461
|
+
return {
|
|
2462
|
+
detector: "identity-drift",
|
|
2463
|
+
summary: "Client is holding a marker this server issued to a different browser, so one of the two identities it has claimed is false",
|
|
2464
|
+
direction: "bot",
|
|
2465
|
+
certainty: "strong",
|
|
2466
|
+
botClass: "impersonator"
|
|
2467
|
+
};
|
|
2468
|
+
}
|
|
2469
|
+
if (!reportSoft || !(drift.platform || drift.language)) return void 0;
|
|
2470
|
+
const what = drift.platform && drift.language ? "platform and language" : drift.platform ? "platform" : "language";
|
|
2471
|
+
return {
|
|
2472
|
+
detector: "identity-drift",
|
|
2473
|
+
// Named precisely, because the operator reading this needs to know it is the
|
|
2474
|
+
// soft case: a person switching to the desktop site produces exactly this.
|
|
2475
|
+
summary: `Client's claimed ${what} changed while holding one marker, which a person can also do deliberately`,
|
|
2476
|
+
direction: "bot",
|
|
2477
|
+
certainty: "moderate",
|
|
2478
|
+
botClass: "unknown"
|
|
2479
|
+
};
|
|
2480
|
+
}
|
|
2481
|
+
};
|
|
2482
|
+
}
|
|
2483
|
+
function markerIntegrityDetector(options = {}) {
|
|
2484
|
+
const minForgeries = options.minForgeries ?? 1;
|
|
2485
|
+
return {
|
|
2486
|
+
id: "marker-integrity",
|
|
2487
|
+
description: "Reports a marker cookie presented with a signature this server could not have produced",
|
|
2488
|
+
cost: "cheap",
|
|
2489
|
+
stage: "always",
|
|
2490
|
+
inspect(ctx) {
|
|
2491
|
+
if (ctx.marker?.reading.kind !== "forged") return void 0;
|
|
2492
|
+
const { forged } = ctx.state.markers;
|
|
2493
|
+
if (forged < minForgeries) return void 0;
|
|
2494
|
+
return {
|
|
2495
|
+
detector: "marker-integrity",
|
|
2496
|
+
summary: forged > 1 ? `Presented a marker cookie this server never signed, ${forged} times` : "Presented a marker cookie this server never signed",
|
|
2497
|
+
direction: "bot",
|
|
2498
|
+
certainty: "strong",
|
|
2499
|
+
botClass: "scanner"
|
|
2500
|
+
};
|
|
2501
|
+
}
|
|
2502
|
+
};
|
|
2503
|
+
}
|
|
2504
|
+
function markerPersistenceDetector(options = {}) {
|
|
2505
|
+
const minIssued = options.minIssued ?? 5;
|
|
2506
|
+
return {
|
|
2507
|
+
id: "marker-persistence",
|
|
2508
|
+
description: "Reports a client that has been handed a marker repeatedly and has never returned one",
|
|
2509
|
+
cost: "cheap",
|
|
2510
|
+
stage: "always",
|
|
2511
|
+
inspect(ctx) {
|
|
2512
|
+
if (ctx.marker === void 0) return void 0;
|
|
2513
|
+
if (ctx.facts.headers["cookie"] === void 0) return void 0;
|
|
2514
|
+
const { issued, returned } = ctx.state.markers;
|
|
2515
|
+
if (returned > 0 || issued < minIssued) return void 0;
|
|
2516
|
+
return {
|
|
2517
|
+
detector: "marker-persistence",
|
|
2518
|
+
summary: `Sends cookies but has never returned the one this server set, across ${issued} responses that offered it`,
|
|
2519
|
+
direction: "bot",
|
|
2520
|
+
certainty: "moderate",
|
|
2521
|
+
botClass: "http-client",
|
|
2522
|
+
// The same cause `session-integrity` reports when it sees no cookie at all: one
|
|
2523
|
+
// client that does not keep state. Without this they are two moderate signals
|
|
2524
|
+
// for one observation, and the population that produces it is people who block
|
|
2525
|
+
// cookies — so the double count landed squarely on them. Measured on the corpus:
|
|
2526
|
+
// it took `cookies-blocked` from 21 to 38 before the family was named.
|
|
2527
|
+
family: "no-session"
|
|
2528
|
+
};
|
|
2529
|
+
}
|
|
2530
|
+
};
|
|
2531
|
+
}
|
|
2532
|
+
function markerFanoutDetector(options = {}) {
|
|
2533
|
+
const minNetworks = options.minNetworks ?? 16;
|
|
2534
|
+
return {
|
|
2535
|
+
id: "marker-fanout",
|
|
2536
|
+
description: "Counts the distinct networks one marker cookie has been presented from",
|
|
2537
|
+
cost: "cheap",
|
|
2538
|
+
stage: "always",
|
|
2539
|
+
inspect(ctx) {
|
|
2540
|
+
const networks = ctx.marker?.networks ?? 0;
|
|
2541
|
+
if (networks < minNetworks) return void 0;
|
|
2542
|
+
return {
|
|
2543
|
+
detector: "marker-fanout",
|
|
2544
|
+
summary: `One client has presented the same marker from ${networks} different networks`,
|
|
2545
|
+
direction: "bot",
|
|
2546
|
+
certainty: "moderate",
|
|
2547
|
+
botClass: "scraper"
|
|
1880
2548
|
};
|
|
1881
2549
|
}
|
|
1882
2550
|
};
|
|
1883
2551
|
}
|
|
1884
|
-
var
|
|
1885
|
-
"src/detectors/
|
|
2552
|
+
var init_marker2 = __esm({
|
|
2553
|
+
"src/detectors/marker.ts"() {
|
|
1886
2554
|
"use strict";
|
|
1887
2555
|
}
|
|
1888
2556
|
});
|
|
@@ -3095,6 +3763,9 @@ function probeVolumeDetector(options = {}) {
|
|
|
3095
3763
|
direction: "bot",
|
|
3096
3764
|
certainty: "moderate",
|
|
3097
3765
|
botClass: "scanner",
|
|
3766
|
+
// `miss-baseline` reads the same misses relative to the site's own rate. One
|
|
3767
|
+
// cause, so the stronger reading stands rather than the two summing.
|
|
3768
|
+
family: "misses",
|
|
3098
3769
|
metadata: { responses, misses, missRatio: Number(ratio.toFixed(3)) }
|
|
3099
3770
|
};
|
|
3100
3771
|
}
|
|
@@ -3138,6 +3809,74 @@ var init_id_enumeration = __esm({
|
|
|
3138
3809
|
}
|
|
3139
3810
|
});
|
|
3140
3811
|
|
|
3812
|
+
// src/detectors/blended-identity.ts
|
|
3813
|
+
function blendedIdentityDetector(options = {}) {
|
|
3814
|
+
const scannerFloor = options.scannerIdentities ?? 2;
|
|
3815
|
+
const crawlerFloor = options.crawlerIdentities ?? 2;
|
|
3816
|
+
return {
|
|
3817
|
+
id: "blended-identity",
|
|
3818
|
+
description: "Reads the set of identities one actor has claimed across requests for combinations that cannot all be true",
|
|
3819
|
+
cost: "cheap",
|
|
3820
|
+
stage: "always",
|
|
3821
|
+
inspect(ctx) {
|
|
3822
|
+
const claimed = ctx.state.claimedIdentities;
|
|
3823
|
+
if (claimed.size === 0) return void 0;
|
|
3824
|
+
const scanners = [];
|
|
3825
|
+
const crawlers = [];
|
|
3826
|
+
const benignCrawlers = [];
|
|
3827
|
+
for (const [id, what] of claimed) {
|
|
3828
|
+
if (what.category === "security") scanners.push(id);
|
|
3829
|
+
if (what.verifiable) crawlers.push(id);
|
|
3830
|
+
if (what.category === "search" || what.category === "ai" || what.category === "social") benignCrawlers.push(id);
|
|
3831
|
+
}
|
|
3832
|
+
const results = [];
|
|
3833
|
+
if (scanners.length >= scannerFloor) {
|
|
3834
|
+
results.push({
|
|
3835
|
+
detector: "blended-identity",
|
|
3836
|
+
summary: `One client has arrived as ${scanners.length} different security tools: ${scanners.join(", ")}`,
|
|
3837
|
+
direction: "bot",
|
|
3838
|
+
certainty: "strong",
|
|
3839
|
+
weight: 0.7,
|
|
3840
|
+
botClass: "scanner",
|
|
3841
|
+
metadata: { identities: scanners }
|
|
3842
|
+
});
|
|
3843
|
+
}
|
|
3844
|
+
if (crawlers.length >= crawlerFloor) {
|
|
3845
|
+
results.push({
|
|
3846
|
+
detector: "blended-identity",
|
|
3847
|
+
summary: `One client has claimed ${crawlers.length} crawler identities that publish address proofs: ${crawlers.join(", ")}`,
|
|
3848
|
+
direction: "bot",
|
|
3849
|
+
certainty: "strong",
|
|
3850
|
+
weight: 0.7,
|
|
3851
|
+
botClass: "impersonator",
|
|
3852
|
+
// Not `certain`, and the line is worth holding. Each operator publishes a proof
|
|
3853
|
+
// tied to addresses it controls, so at most one claim can be true — but a shared
|
|
3854
|
+
// egress in front of two genuinely different clients would produce the same set,
|
|
3855
|
+
// and this library refuses to deny anybody on an inference.
|
|
3856
|
+
metadata: { identities: crawlers }
|
|
3857
|
+
});
|
|
3858
|
+
}
|
|
3859
|
+
if (ctx.state.payloadProbes > 0 && benignCrawlers.length > 0) {
|
|
3860
|
+
results.push({
|
|
3861
|
+
detector: "blended-identity",
|
|
3862
|
+
summary: `Client claims to be ${benignCrawlers.join(", ")} and has sent ${ctx.state.payloadProbes} scanner payload(s)`,
|
|
3863
|
+
direction: "bot",
|
|
3864
|
+
certainty: "strong",
|
|
3865
|
+
weight: 0.75,
|
|
3866
|
+
botClass: "impersonator",
|
|
3867
|
+
metadata: { identities: benignCrawlers, payloadProbes: ctx.state.payloadProbes }
|
|
3868
|
+
});
|
|
3869
|
+
}
|
|
3870
|
+
return results.length > 0 ? results : void 0;
|
|
3871
|
+
}
|
|
3872
|
+
};
|
|
3873
|
+
}
|
|
3874
|
+
var init_blended_identity = __esm({
|
|
3875
|
+
"src/detectors/blended-identity.ts"() {
|
|
3876
|
+
"use strict";
|
|
3877
|
+
}
|
|
3878
|
+
});
|
|
3879
|
+
|
|
3141
3880
|
// src/detectors/crawler-verification.ts
|
|
3142
3881
|
function crawlerVerificationDetector(options = {}) {
|
|
3143
3882
|
const missingPtrIsForgery = options.treatMissingPtrAsForgery ?? true;
|
|
@@ -3718,14 +4457,16 @@ function findPayload(path, query) {
|
|
|
3718
4457
|
for (const { pattern, what, tier } of PAYLOADS) {
|
|
3719
4458
|
if (pattern.test(path)) return { what, where: "path", sample: path, tier };
|
|
3720
4459
|
}
|
|
3721
|
-
for (const
|
|
4460
|
+
for (const key in query) {
|
|
4461
|
+
const value = query[key];
|
|
4462
|
+
if (!PAYLOAD_GATE.test(value)) continue;
|
|
3722
4463
|
for (const { pattern, what, tier } of PAYLOADS) {
|
|
3723
4464
|
if (pattern.test(value)) return { what, where: `query parameter "${key.slice(0, 40)}"`, sample: value, tier };
|
|
3724
4465
|
}
|
|
3725
4466
|
}
|
|
3726
4467
|
return void 0;
|
|
3727
4468
|
}
|
|
3728
|
-
var EXPLOIT_PATHS, EXPLOIT_SUFFIXES, PLATFORM_PATHS, PAYLOADS, INJECTION_PUNCTUATION, PROBE_METHODS;
|
|
4469
|
+
var EXPLOIT_PATHS, EXPLOIT_SUFFIXES, PLATFORM_PATHS, PAYLOADS, PAYLOAD_GATE, INJECTION_PUNCTUATION, PROBE_METHODS;
|
|
3729
4470
|
var init_probe_signature = __esm({
|
|
3730
4471
|
"src/detectors/probe-signature.ts"() {
|
|
3731
4472
|
"use strict";
|
|
@@ -3799,11 +4540,74 @@ var init_probe_signature = __esm({
|
|
|
3799
4540
|
{ pattern: /<script[\s>]/i, what: "an inline script tag", tier: "markup" },
|
|
3800
4541
|
{ pattern: /\bon(?:error|load|mouseover)\s*=/i, what: "an inline event handler", tier: "markup" }
|
|
3801
4542
|
];
|
|
4543
|
+
PAYLOAD_GATE = /[$`:(<=\s]/;
|
|
3802
4544
|
INJECTION_PUNCTUATION = /['"]|--\s|\/\*|;|%27|%22/;
|
|
3803
4545
|
PROBE_METHODS = /* @__PURE__ */ new Set(["TRACE", "TRACK", "DEBUG", "CONNECT"]);
|
|
3804
4546
|
}
|
|
3805
4547
|
});
|
|
3806
4548
|
|
|
4549
|
+
// src/detectors/target-integrity.ts
|
|
4550
|
+
function targetIntegrityDetector(options = {}) {
|
|
4551
|
+
const reportPlain = options.reportPlainTraversal ?? true;
|
|
4552
|
+
return {
|
|
4553
|
+
id: "target-integrity",
|
|
4554
|
+
description: "Reports a request target spelled to get past something rather than to fetch something",
|
|
4555
|
+
cost: "cheap",
|
|
4556
|
+
stage: "always",
|
|
4557
|
+
inspect(ctx) {
|
|
4558
|
+
const raw = ctx.facts.rawPath;
|
|
4559
|
+
if (raw === void 0) return void 0;
|
|
4560
|
+
const findings = [];
|
|
4561
|
+
if (ABSOLUTE_FORM.test(raw)) {
|
|
4562
|
+
findings.push({ what: "asked this server to fetch a URL elsewhere, which is a request addressed to a proxy", certainty: "strong" });
|
|
4563
|
+
}
|
|
4564
|
+
if (DOUBLE_ENCODED.test(raw)) {
|
|
4565
|
+
findings.push({ what: "encoded its own encoding, so one round of decoding leaves it still encoded", certainty: "strong" });
|
|
4566
|
+
}
|
|
4567
|
+
if (ENCODED_CONTROL.test(raw)) {
|
|
4568
|
+
findings.push({ what: "carried a control character in the target", certainty: "strong" });
|
|
4569
|
+
}
|
|
4570
|
+
if (TRAVERSAL.test(raw)) {
|
|
4571
|
+
if (ENCODED_SEPARATOR.test(raw)) {
|
|
4572
|
+
findings.push({ what: "spelled the dots and slashes of a directory traversal in percent-encoding", certainty: "strong" });
|
|
4573
|
+
} else if (reportPlain) {
|
|
4574
|
+
findings.push({ what: "walked up out of the site root", certainty: "moderate" });
|
|
4575
|
+
}
|
|
4576
|
+
} else if (ENCODED_SLASH.test(raw)) {
|
|
4577
|
+
findings.push({ what: "hid a path separator inside a segment by encoding it", certainty: "moderate" });
|
|
4578
|
+
}
|
|
4579
|
+
if (findings.length === 0) return void 0;
|
|
4580
|
+
const certainty = findings.some((finding) => finding.certainty === "strong") ? "strong" : "moderate";
|
|
4581
|
+
const what = findings.map((finding) => finding.what);
|
|
4582
|
+
const listed = what.length === 1 ? what[0] : `${what.slice(0, -1).join(", ")}, and ${what[what.length - 1]}`;
|
|
4583
|
+
return {
|
|
4584
|
+
detector: "target-integrity",
|
|
4585
|
+
summary: `The request target ${listed}`,
|
|
4586
|
+
direction: "bot",
|
|
4587
|
+
certainty,
|
|
4588
|
+
botClass: "scanner",
|
|
4589
|
+
// One act, however many ways it shows. A traversal is usually encoded and an
|
|
4590
|
+
// encoded traversal is often double-encoded; compounding them would turn one
|
|
4591
|
+
// request into three independent reasons to be suspicious.
|
|
4592
|
+
family: "evasive-target",
|
|
4593
|
+
metadata: { target: raw.length > 200 ? `${raw.slice(0, 200)}\u2026` : raw }
|
|
4594
|
+
};
|
|
4595
|
+
}
|
|
4596
|
+
};
|
|
4597
|
+
}
|
|
4598
|
+
var ENCODED_SEPARATOR, ENCODED_SLASH, DOUBLE_ENCODED, ENCODED_CONTROL, TRAVERSAL, ABSOLUTE_FORM;
|
|
4599
|
+
var init_target_integrity = __esm({
|
|
4600
|
+
"src/detectors/target-integrity.ts"() {
|
|
4601
|
+
"use strict";
|
|
4602
|
+
ENCODED_SEPARATOR = /%2e|%2f|%5c/i;
|
|
4603
|
+
ENCODED_SLASH = /%2f|%5c/i;
|
|
4604
|
+
DOUBLE_ENCODED = /%25[0-9a-f]{2}/i;
|
|
4605
|
+
ENCODED_CONTROL = /%0[0-9a-f]|%1[0-9a-f]|%7f/i;
|
|
4606
|
+
TRAVERSAL = /\.\.|%2e%2e|%2e\.|\.%2e/i;
|
|
4607
|
+
ABSOLUTE_FORM = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
4608
|
+
}
|
|
4609
|
+
});
|
|
4610
|
+
|
|
3807
4611
|
// src/detectors/rate-anomaly.ts
|
|
3808
4612
|
function rateAnomalyDetector(options = {}) {
|
|
3809
4613
|
const windowMs = options.windowMs ?? 1e4;
|
|
@@ -4085,7 +4889,10 @@ function submittedFields(extra) {
|
|
|
4085
4889
|
const source = extra?.[TRAP_FIELD_SOURCE];
|
|
4086
4890
|
return typeof source === "object" && source !== null ? source : void 0;
|
|
4087
4891
|
}
|
|
4088
|
-
function renderTrapLink(path, options = {}) {
|
|
4892
|
+
function renderTrapLink(path = DEFAULT_TRAP_PATHS[0], options = {}) {
|
|
4893
|
+
if (!path.startsWith("/")) {
|
|
4894
|
+
throw new TypeError(`A trap path must begin with "/" \u2014 it is matched against the request path. Received: ${JSON.stringify(path.slice(0, 60))}`);
|
|
4895
|
+
}
|
|
4089
4896
|
const label = escapeHtml2(options.label ?? "Archive index");
|
|
4090
4897
|
const href = escapeHtml2(path);
|
|
4091
4898
|
return `<a href="${href}" rel="nofollow noindex" aria-hidden="true" tabindex="-1" style="position:absolute;left:-10000px;top:auto;width:1px;height:1px;overflow:hidden">${label}</a>`;
|
|
@@ -4506,9 +5313,11 @@ function defaultDetectors(options = {}) {
|
|
|
4506
5313
|
// Identity first: a self-declaration or a verified crawler settles the question
|
|
4507
5314
|
// outright, and the engine can then skip everything that would only add nuance.
|
|
4508
5315
|
selfIdentifiedDetector(),
|
|
5316
|
+
blendedIdentityDetector(),
|
|
4509
5317
|
trapDetector(),
|
|
4510
5318
|
ipIntelligenceDetector(),
|
|
4511
5319
|
probeSignatureDetector(),
|
|
5320
|
+
targetIntegrityDetector(),
|
|
4512
5321
|
// Single-request consistency.
|
|
4513
5322
|
headerIntegrityDetector(),
|
|
4514
5323
|
uaCoherenceDetector(),
|
|
@@ -4543,12 +5352,14 @@ var init_detectors = __esm({
|
|
|
4543
5352
|
init_transport_coherence();
|
|
4544
5353
|
init_probe_volume();
|
|
4545
5354
|
init_id_enumeration();
|
|
5355
|
+
init_blended_identity();
|
|
4546
5356
|
init_crawler_verification();
|
|
4547
5357
|
init_fetch_metadata();
|
|
4548
5358
|
init_header_integrity();
|
|
4549
5359
|
init_header_order();
|
|
4550
5360
|
init_ip_intelligence();
|
|
4551
5361
|
init_probe_signature();
|
|
5362
|
+
init_target_integrity();
|
|
4552
5363
|
init_rate_anomaly();
|
|
4553
5364
|
init_self_identified();
|
|
4554
5365
|
init_session_integrity();
|
|
@@ -4569,6 +5380,11 @@ var init_detectors = __esm({
|
|
|
4569
5380
|
init_transport_coherence();
|
|
4570
5381
|
init_probe_volume();
|
|
4571
5382
|
init_id_enumeration();
|
|
5383
|
+
init_blended_identity();
|
|
5384
|
+
init_challenge_reaction();
|
|
5385
|
+
init_challenge_integrity();
|
|
5386
|
+
init_site_baseline();
|
|
5387
|
+
init_marker2();
|
|
4572
5388
|
init_session_integrity();
|
|
4573
5389
|
init_identity_rotation();
|
|
4574
5390
|
init_trap();
|
|
@@ -4576,6 +5392,7 @@ var init_detectors = __esm({
|
|
|
4576
5392
|
init_tls_fingerprint();
|
|
4577
5393
|
init_clearance();
|
|
4578
5394
|
init_ua_coherence();
|
|
5395
|
+
init_target_integrity();
|
|
4579
5396
|
init_probe_signature();
|
|
4580
5397
|
init_browsing_coherence();
|
|
4581
5398
|
init_client_signals();
|
|
@@ -4830,6 +5647,7 @@ function resolveConfig(config = {}) {
|
|
|
4830
5647
|
}
|
|
4831
5648
|
seen.add(detector.id);
|
|
4832
5649
|
}
|
|
5650
|
+
const shadowDetectors = new Set(config.shadowDetectors ?? []);
|
|
4833
5651
|
const rules = [...config.rules ?? []];
|
|
4834
5652
|
if (config.preset !== void 0) {
|
|
4835
5653
|
const preset = PRESETS[config.preset];
|
|
@@ -4858,6 +5676,36 @@ function resolveConfig(config = {}) {
|
|
|
4858
5676
|
if (proxyConfig.trustProxy !== true && (trustedProxies !== void 0 || proxyConfig.hops !== void 0)) {
|
|
4859
5677
|
warnings.push("proxy.trustedProxies / proxy.hops are configured but proxy.trustProxy is not enabled, so the forwarded header is ignored and the socket address is used.");
|
|
4860
5678
|
}
|
|
5679
|
+
if (config.probe !== void 0) {
|
|
5680
|
+
if (config.probe.cookieName !== void 0 && config.probe.cookieName === config.challenge?.cookieName) {
|
|
5681
|
+
throw new ConfigError(
|
|
5682
|
+
`probe.cookieName and challenge.cookieName are both "${config.probe.cookieName}". One would overwrite the other on every response, so clearance and the marker would each destroy the other.`
|
|
5683
|
+
);
|
|
5684
|
+
}
|
|
5685
|
+
if (config.probe.secure === false) {
|
|
5686
|
+
warnings.push(
|
|
5687
|
+
"probe.secure is false, so the marker cookie will travel over plain HTTP and can be read by anything on the path. It is meant for local development; leave it unset in production."
|
|
5688
|
+
);
|
|
5689
|
+
}
|
|
5690
|
+
if (config.probe.domain !== void 0 && config.probe.domain.startsWith(".") === false && config.probe.domain.includes(".") === false) {
|
|
5691
|
+
warnings.push(`probe.domain is "${config.probe.domain}", which is not a domain a browser will accept, so the marker will never be stored or returned.`);
|
|
5692
|
+
}
|
|
5693
|
+
if (config.probe.ttlMs !== void 0 && config.probe.ttlMs < 6e4) {
|
|
5694
|
+
warnings.push(
|
|
5695
|
+
`probe.ttlMs is ${config.probe.ttlMs}ms. A marker that expires within a minute is re-issued on almost every request, which makes responses uncacheable and leaves nothing long enough to correlate.`
|
|
5696
|
+
);
|
|
5697
|
+
}
|
|
5698
|
+
}
|
|
5699
|
+
if (config.site !== void 0 && config.site.warmupRequests !== void 0 && config.site.warmupRequests < 500) {
|
|
5700
|
+
warnings.push(
|
|
5701
|
+
`site.warmupRequests is ${config.site.warmupRequests}. A baseline drawn from so little traffic is not one \u2014 every path is rare when nothing has been seen \u2014 so the site detectors will report ordinary visitors until real traffic arrives.`
|
|
5702
|
+
);
|
|
5703
|
+
}
|
|
5704
|
+
if (config.actorKey === void 0 && detectors.some((detector) => detector.id === "identity-rotation")) {
|
|
5705
|
+
warnings.push(
|
|
5706
|
+
"identity-rotation is enabled while `actorKey` is left as the client address, so one actor means one address. A NAT gateway \u2014 an office, a campus, a mobile carrier \u2014 presents many people's browsers under a single address, which is this detector's exact signature and is entirely innocent. Give `actorKey` something narrower than an address (a session cookie, an authenticated user id, or an address combined with a TLS fingerprint), or take the detector out."
|
|
5707
|
+
);
|
|
5708
|
+
}
|
|
4861
5709
|
const strictEvidence = config.strictEvidence ?? process.env["NODE_ENV"] !== "production";
|
|
4862
5710
|
const falsePositivePolicy = config.falsePositivePolicy ?? "strict";
|
|
4863
5711
|
if (falsePositivePolicy === "aggressive") {
|
|
@@ -4872,6 +5720,7 @@ function resolveConfig(config = {}) {
|
|
|
4872
5720
|
}
|
|
4873
5721
|
return {
|
|
4874
5722
|
detectors,
|
|
5723
|
+
shadowDetectors,
|
|
4875
5724
|
rules,
|
|
4876
5725
|
ranges,
|
|
4877
5726
|
signatures,
|
|
@@ -4909,11 +5758,11 @@ function clamp(value, min, max) {
|
|
|
4909
5758
|
return Math.min(max, Math.max(min, value));
|
|
4910
5759
|
}
|
|
4911
5760
|
function resolveClientIp(socketAddress, headers, proxy) {
|
|
4912
|
-
const direct = socketAddress !== void 0 ? normalizeIp(socketAddress) ?? socketAddress : "";
|
|
5761
|
+
const direct = socketAddress !== void 0 ? normalizeIp(stripPort(socketAddress)) ?? socketAddress : "";
|
|
4913
5762
|
if (!proxy.trustProxy) return direct;
|
|
4914
5763
|
const header = headers[proxy.header];
|
|
4915
5764
|
if (header === void 0) return direct;
|
|
4916
|
-
const chain = header.slice(0, 2048).split(",").map((entry) => entry
|
|
5765
|
+
const chain = header.slice(0, 2048).split(",").map((entry) => stripPort(entry)).filter((entry) => entry.length > 0 && parseIp(entry) !== null).map((entry) => normalizeIp(entry));
|
|
4917
5766
|
if (chain.length === 0) return direct;
|
|
4918
5767
|
if (proxy.trustedProxies) {
|
|
4919
5768
|
if (direct !== "" && !proxy.trustedProxies.contains(direct)) return direct;
|
|
@@ -5146,11 +5995,15 @@ var init_feed = __esm({
|
|
|
5146
5995
|
certain: assessment.certain,
|
|
5147
5996
|
durationMs: Number(assessment.durationMs.toFixed(3)),
|
|
5148
5997
|
bypass: assessment.bypass,
|
|
5149
|
-
|
|
5998
|
+
// Shadowed findings ride in the same list, flagged. They belong on the same screen
|
|
5999
|
+
// as the evidence that did decide — the comparison is the point — and the flag is
|
|
6000
|
+
// what stops the page, and `previewAssessment`, from treating them as such.
|
|
6001
|
+
evidence: [...assessment.evidence, ...assessment.humanEvidence, ...assessment.shadowEvidence].map((item) => ({
|
|
5150
6002
|
detector: item.detector,
|
|
5151
6003
|
summary: item.summary,
|
|
5152
6004
|
certainty: item.certainty,
|
|
5153
6005
|
direction: item.direction,
|
|
6006
|
+
...item.shadow === true ? { shadow: true } : {},
|
|
5154
6007
|
family: item.family,
|
|
5155
6008
|
deterministicBasis: item.deterministicBasis,
|
|
5156
6009
|
identity: item.identity,
|
|
@@ -5159,6 +6012,7 @@ var init_feed = __esm({
|
|
|
5159
6012
|
category: typeof item.metadata?.["category"] === "string" ? item.metadata["category"] : void 0,
|
|
5160
6013
|
weight: item.weight
|
|
5161
6014
|
})),
|
|
6015
|
+
...assessment.shadowVerdict === void 0 ? {} : { shadowVerdict: assessment.shadowVerdict },
|
|
5162
6016
|
failures: assessment.failures.map((failure) => ({ detector: failure.detector, reason: failure.reason, message: failure.message })),
|
|
5163
6017
|
actorStats: {
|
|
5164
6018
|
requests: assessment.actor.requests,
|
|
@@ -5368,6 +6222,7 @@ function isDenial(action) {
|
|
|
5368
6222
|
function assessmentFromEntry(entry) {
|
|
5369
6223
|
const evidence2 = [];
|
|
5370
6224
|
const humanEvidence = [];
|
|
6225
|
+
const shadowEvidence = [];
|
|
5371
6226
|
for (const item of entry.evidence) {
|
|
5372
6227
|
const rebuilt = {
|
|
5373
6228
|
detector: item.detector,
|
|
@@ -5378,9 +6233,11 @@ function assessmentFromEntry(entry) {
|
|
|
5378
6233
|
...item.identity !== void 0 ? { identity: item.identity } : {},
|
|
5379
6234
|
...item.family !== void 0 ? { family: item.family } : {},
|
|
5380
6235
|
// `category` is read off metadata by the matcher, so it has to go back there.
|
|
5381
|
-
...item.category !== void 0 ? { metadata: { category: item.category } } : {}
|
|
6236
|
+
...item.category !== void 0 ? { metadata: { category: item.category } } : {},
|
|
6237
|
+
...item.shadow === true ? { shadow: true } : {}
|
|
5382
6238
|
};
|
|
5383
|
-
(item.
|
|
6239
|
+
if (item.shadow === true) shadowEvidence.push(rebuilt);
|
|
6240
|
+
else (item.direction === "human" ? humanEvidence : evidence2).push(rebuilt);
|
|
5384
6241
|
}
|
|
5385
6242
|
return {
|
|
5386
6243
|
requestId: entry.requestId,
|
|
@@ -5392,6 +6249,8 @@ function assessmentFromEntry(entry) {
|
|
|
5392
6249
|
certain: entry.certain,
|
|
5393
6250
|
evidence: evidence2,
|
|
5394
6251
|
humanEvidence,
|
|
6252
|
+
shadowEvidence,
|
|
6253
|
+
...entry.shadowVerdict === void 0 ? {} : { shadowVerdict: entry.shadowVerdict },
|
|
5395
6254
|
actor: {
|
|
5396
6255
|
key: entry.actor,
|
|
5397
6256
|
requests: entry.actorStats.requests,
|
|
@@ -5449,7 +6308,7 @@ var CLIENT_SCRIPT;
|
|
|
5449
6308
|
var init_client_generated = __esm({
|
|
5450
6309
|
"src/dashboard/client.generated.ts"() {
|
|
5451
6310
|
"use strict";
|
|
5452
|
-
CLIENT_SCRIPT = '"use strict";\n(() => {\n // src/dashboard/client/css.ts\n function cssEscape(value) {\n return typeof CSS !== "undefined" && typeof CSS.escape === "function" ? CSS.escape(value) : String(value).replace(/[^\\w-]/g, "\\\\$&");\n }\n\n // src/dashboard/client/dom.ts\n function el(tag, className, value) {\n const node = document.createElement(tag);\n if (className !== void 0 && className !== null && className !== "") node.className = className;\n if (value !== void 0 && value !== null) node.textContent = String(value);\n return node;\n }\n function svgEl(name, attributes = {}) {\n const node = document.createElementNS("http://www.w3.org/2000/svg", name);\n for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, String(value));\n return node;\n }\n function svgText(attributes, text) {\n const node = svgEl("text", attributes);\n node.textContent = String(text);\n return node;\n }\n function clear(node) {\n while (node.firstChild) node.removeChild(node.firstChild);\n }\n var root = typeof document === "undefined" ? void 0 : document;\n var themeHost = typeof document === "undefined" ? void 0 : document.documentElement;\n var embedded = false;\n function isEmbedded() {\n return embedded;\n }\n function eventTarget() {\n return embedded ? root : globalThis;\n }\n function rootNode() {\n return root;\n }\n function themeElement() {\n return themeHost;\n }\n function $(id) {\n const node = root.querySelector(`#${cssEscape(id)}`);\n if (node === null || node === void 0) throw new Error(`dashboard: no element #${id}`);\n return node;\n }\n function byId(id) {\n return $(id);\n }\n function css(name) {\n return getComputedStyle(themeHost).getPropertyValue(name).trim();\n }\n var sequence = 0;\n function label(text, control) {\n const node = document.createElement("label");\n node.textContent = text;\n const single = Array.isArray(control) ? control.length === 1 ? control[0] : void 0 : control;\n if (single !== void 0 && /^(input|select|textarea)$/i.test(single.tagName)) {\n if (single.id === "") single.id = `field-${++sequence}`;\n node.htmlFor = single.id;\n } else {\n const group = Array.isArray(control) ? control : [control];\n for (const node_ of group) if (!node_.hasAttribute("aria-label")) node_.setAttribute("aria-label", text);\n }\n return node;\n }\n\n // src/dashboard/client/boot.ts\n var raw = globalThis.__BOOTSTRAP__;\n var BOOT = raw ?? {\n base: "",\n title: "bothandlerjs",\n allowReset: false,\n allowEdit: false,\n allowGuardEdit: false,\n allowActing: false,\n peers: [],\n sections: { feed: true, evidence: true, actors: true, registry: true, tester: true, statistics: true, audit: true, notices: true, changes: true, policy: true, guard: true, robots: true, ranges: true },\n links: []\n };\n var API = BOOT.base;\n var SECTIONS = BOOT.sections;\n\n // src/dashboard/client/app.ts\n var app = {\n draw: () => {\n },\n drawNow: () => {\n },\n showTab: () => {\n },\n syncUrl: () => {\n }\n };\n function toast(kind, title, detail = "") {\n const node = el("div", `toast ${kind}`);\n node.appendChild(el("b", null, title));\n if (detail !== "") node.appendChild(el("span", null, detail));\n const host = rootNode().querySelector("#toasts");\n if (host === null) return;\n host.appendChild(node);\n setTimeout(() => node.remove(), 6e3);\n }\n function download(text, filename, type) {\n const blob = new Blob([text], { type });\n const url = URL.createObjectURL(blob);\n const anchor = el("a");\n anchor.href = url;\n anchor.download = filename;\n anchor.click();\n setTimeout(() => URL.revokeObjectURL(url), 1e3);\n }\n function today() {\n return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);\n }\n\n // src/dashboard/client/outcome.ts\n var DENY = /* @__PURE__ */ new Set(["block", "drop", "redirect"]);\n var MITIGATE = /* @__PURE__ */ new Set(["challenge", "rate-limit", "delay"]);\n function outcome(entry) {\n return actionKind(entry.action);\n }\n function actionKind(action) {\n if (action === void 0) return "pending";\n if (DENY.has(action)) return "deny";\n if (MITIGATE.has(action)) return "mitigate";\n return "allow";\n }\n function verdictBadge(entry) {\n if (entry.verdict === "verified-bot" || entry.verdict === "confirmed-bot") return ["b-proven", entry.verdict];\n if (entry.verdict === "suspected-bot") return ["b-suspected", "suspected-bot"];\n if (entry.verdict === "human") return ["b-human", "human"];\n return ["b-unknown", entry.bypass !== void 0 ? `skipped \xB7 ${entry.bypass}` : "unknown"];\n }\n function provenBots(verdicts) {\n return (verdicts["confirmed-bot"] ?? 0) + (verdicts["verified-bot"] ?? 0);\n }\n\n // src/dashboard/client/query.ts\n var FIELDS = {\n path: "path",\n url: "path",\n actor: "actor",\n ip: "actor",\n ua: "userAgent",\n useragent: "userAgent",\n agent: "userAgent",\n verdict: "verdict",\n action: "action",\n rule: "rule",\n detector: "detector",\n identity: "identity",\n method: "method",\n class: "botClass",\n botclass: "botClass",\n id: "requestId",\n request: "requestId",\n bypass: "bypass",\n score: "score",\n outcome: "outcome",\n certain: "certain"\n };\n var NUMERIC = /* @__PURE__ */ new Set(["score"]);\n function tokenize(input) {\n const tokens = [];\n let current = "";\n let quoted = false;\n for (const character of input) {\n if (character === \'"\') {\n quoted = !quoted;\n continue;\n }\n if (!quoted && /\\s/.test(character)) {\n if (current !== "") tokens.push(current);\n current = "";\n continue;\n }\n current += character;\n }\n if (current !== "") tokens.push(current);\n return tokens;\n }\n function parseQuery(input) {\n const terms = [];\n for (const token of tokenize(input.trim())) {\n const negated = token.startsWith("-") || token.startsWith("!");\n const body = negated ? token.slice(1) : token;\n if (body === "") continue;\n const colon = body.indexOf(":");\n const name = colon === -1 ? "" : body.slice(0, colon).toLowerCase();\n const field2 = FIELDS[name];\n if (colon === -1 || field2 === void 0) {\n terms.push({ field: void 0, value: body.toLowerCase(), negated });\n continue;\n }\n let value = body.slice(colon + 1).toLowerCase();\n let compare;\n if (NUMERIC.has(field2)) {\n compare = value.startsWith(">") ? ">" : value.startsWith("<") ? "<" : "=";\n if (compare !== "=") value = value.slice(1);\n }\n if (value === "") continue;\n terms.push({ field: field2, value, negated, compare });\n }\n return terms;\n }\n function searchableText(entry) {\n const parts = [\n entry.method,\n entry.path,\n entry.actor,\n entry.userAgent,\n entry.verdict,\n entry.botClass,\n entry.identity ?? "",\n entry.action ?? "",\n entry.rule ?? "",\n entry.requestId\n ];\n for (const item of entry.evidence) parts.push(item.detector, item.summary);\n return parts.join(" ").toLowerCase();\n }\n function fieldValue(entry, field2) {\n switch (field2) {\n case "path":\n return entry.path;\n case "actor":\n return entry.actor;\n case "userAgent":\n return entry.userAgent;\n case "verdict":\n return entry.verdict;\n case "action":\n return entry.action ?? "";\n case "rule":\n return entry.rule ?? "";\n case "identity":\n return entry.identity ?? "";\n case "method":\n return entry.method;\n case "botClass":\n return entry.botClass;\n case "requestId":\n return entry.requestId;\n case "bypass":\n return entry.bypass ?? "";\n case "outcome":\n return outcome(entry);\n case "certain":\n return String(entry.certain);\n case "detector":\n return entry.evidence.map((item) => item.detector).join(" ");\n default:\n return "";\n }\n }\n function matchesTerm(term, entry, haystack) {\n if (term.field === void 0) return haystack.includes(term.value);\n if (term.field === "score") {\n const wanted = Number(term.value);\n if (Number.isNaN(wanted)) return false;\n if (term.compare === ">") return entry.score > wanted;\n if (term.compare === "<") return entry.score < wanted;\n return entry.score === wanted;\n }\n return fieldValue(entry, term.field).toLowerCase().includes(term.value);\n }\n function matchesQuery(terms, entry, haystack) {\n for (const term of terms) {\n if (matchesTerm(term, entry, haystack) === term.negated) return false;\n }\n return true;\n }\n function matchesFilter(filter, entry) {\n switch (filter) {\n case "proven":\n return entry.certain;\n case "suspected":\n return entry.verdict === "suspected-bot";\n case "human":\n return entry.verdict === "human";\n case "guard":\n return entry.downgradedFrom !== void 0;\n case "deny":\n return outcome(entry) === "deny";\n case "mitigate":\n return outcome(entry) === "mitigate";\n case "allow":\n return outcome(entry) === "allow";\n default:\n return true;\n }\n }\n\n // src/dashboard/client/store.ts\n var MAX_ROWS = 1e3;\n var state = {\n rows: [],\n byId: /* @__PURE__ */ new Map(),\n snapshot: void 0,\n policy: void 0,\n actors: [],\n actorsTracked: 0,\n paused: false,\n filter: "all",\n search: "",\n terms: [],\n tab: "live",\n open: /* @__PURE__ */ new Set(),\n actor: void 0,\n rangeMs: 3e5,\n scoreScope: "run",\n editorRules: [],\n editorDirty: false,\n editorMode: "gui",\n guardDirty: false,\n bufferedWhilePaused: 0,\n laggedDrops: 0,\n feedPage: 0,\n feedFrozen: void 0,\n actorsPage: 0,\n feedPageSize: 50,\n actorsPageSize: 25,\n caughtUp: 0\n };\n function ingest(entry) {\n const existing = state.byId.get(entry.requestId);\n if (existing !== void 0) {\n existing.entry = entry;\n existing.rev++;\n existing.text = void 0;\n return;\n }\n const row = { entry, rev: 0 };\n state.byId.set(entry.requestId, row);\n state.rows.push(row);\n if (state.rows.length > MAX_ROWS) {\n for (const dropped of state.rows.splice(0, state.rows.length - MAX_ROWS)) {\n state.byId.delete(dropped.entry.requestId);\n state.open.delete(dropped.entry.requestId);\n }\n }\n }\n function clearFeed() {\n state.rows = [];\n state.byId = /* @__PURE__ */ new Map();\n state.open.clear();\n state.actor = void 0;\n state.bufferedWhilePaused = 0;\n state.laggedDrops = 0;\n resetPaging();\n }\n function setSearch(value) {\n state.search = value;\n state.terms = parseQuery(value);\n resetPaging();\n }\n function resetPaging() {\n state.feedPage = 0;\n state.feedFrozen = void 0;\n }\n function textOf(row) {\n if (row.text === void 0) row.text = searchableText(row.entry);\n return row.text;\n }\n function sortRows() {\n state.rows.sort((a, b) => a.entry.at - b.entry.at);\n }\n function matches(row) {\n return matchesFilter(state.filter, row.entry) && matchesQuery(state.terms, row.entry, textOf(row));\n }\n function matchingRows(limit = Number.POSITIVE_INFINITY) {\n const shown = [];\n for (let i = state.rows.length - 1; i >= 0 && shown.length < limit; i--) {\n const row = state.rows[i];\n if (row !== void 0 && matches(row)) shown.push(row);\n }\n return shown;\n }\n function feedPage(size) {\n const all = state.feedPage === 0 || state.feedFrozen === void 0 ? matchingRows() : state.feedFrozen;\n const pages = Math.max(1, Math.ceil(all.length / size));\n const page = Math.min(Math.max(0, state.feedPage), pages - 1);\n if (page !== state.feedPage) state.feedPage = page;\n return { rows: all.slice(page * size, page * size + size), page, pages, total: all.length };\n }\n function goToFeedPage(page) {\n const next = Math.max(0, page);\n if (next === 0) {\n state.feedFrozen = void 0;\n } else if (state.feedFrozen === void 0) {\n state.feedFrozen = matchingRows();\n }\n state.feedPage = next;\n }\n function matchingCount() {\n let count = 0;\n for (const row of state.rows) if (matches(row)) count++;\n return count;\n }\n function oldestAt() {\n return state.rows[0]?.entry.at;\n }\n function bump(counter, key) {\n counter.set(key, (counter.get(key) ?? 0) + 1);\n }\n function aggregate(rows) {\n const totals = {\n detectors: /* @__PURE__ */ new Map(),\n actors: /* @__PURE__ */ new Map(),\n identities: /* @__PURE__ */ new Map(),\n paths: /* @__PURE__ */ new Map(),\n deniedPaths: /* @__PURE__ */ new Map(),\n guardStops: /* @__PURE__ */ new Map(),\n ruleHits: /* @__PURE__ */ new Map(),\n bypassed: /* @__PURE__ */ new Map()\n };\n for (const { entry } of rows) {\n for (const item of entry.evidence) bump(totals.detectors, item.detector);\n bump(totals.actors, entry.actor);\n if (entry.bypass !== void 0) {\n bump(totals.bypassed, `${entry.path} (${entry.bypass})`);\n continue;\n }\n bump(totals.paths, entry.path);\n if (entry.identity !== void 0 && entry.identity !== "") {\n bump(totals.identities, `${entry.identity} \xB7 ${entry.verdict === "verified-bot" ? "verified" : "claimed"}`);\n }\n if (outcome(entry) === "deny") bump(totals.deniedPaths, entry.path);\n if (entry.downgradedFrom !== void 0 && entry.rule !== void 0) bump(totals.guardStops, `${entry.rule} \u2192 ${entry.downgradedFrom}`);\n if (entry.rule !== void 0) bump(totals.ruleHits, entry.rule);\n }\n return totals;\n }\n\n // src/dashboard/client/api.ts\n async function getJson(path) {\n const response = await fetch(API + path);\n if (!response.ok) throw new Error(await errorFrom(response));\n return await response.json();\n }\n async function postJson(path, body) {\n const response = await fetch(API + path, {\n method: "POST",\n headers: { "content-type": "application/json" },\n body: JSON.stringify(body)\n });\n const data = await response.json().catch(() => ({}));\n return response.ok ? { ok: true, data } : { ok: false, data, error: String(data.error ?? "The server refused this.") };\n }\n async function errorFrom(response) {\n const body = await response.json().catch(() => ({}));\n return body.error ?? `${response.status} ${response.statusText}`;\n }\n\n // src/dashboard/client/actions.ts\n var armed = 0;\n function isConfirming() {\n return armed > 0;\n }\n function actorActions(key, after) {\n if (!BOOT.allowActing) return [];\n const forget = el("button", null, "Forget");\n forget.title = "Discard this actor\'s history \u2014 the cure for a false positive that has stuck";\n forget.addEventListener("click", () => {\n void act({ key, action: "forget" }, `Forgot ${key}`, "Its next request is assessed as a first request.", after);\n });\n const clear2 = el("button", null, "Clear as human");\n clear2.title = "Grant this actor human clearance for an hour, as though it had solved a challenge";\n clear2.addEventListener("click", () => {\n void act({ key, action: "clear", forMs: 60 * 6e4 }, `Cleared ${key}`, "Held as human for an hour, then reassessed.", after);\n });\n return [confirmingButton("Allowlist", `Allowlist ${key} \u2014 it stops being assessed at all`, () => allowlist(key, after)), forget, clear2];\n }\n function confirmingButton(label2, confirmation, run2) {\n const button = el("button", "danger", label2);\n let pending2 = false;\n let timer2;\n const disarm = () => {\n if (!pending2) return;\n pending2 = false;\n armed--;\n button.textContent = label2;\n button.className = "danger";\n };\n button.addEventListener("click", () => {\n if (pending2) {\n if (timer2 !== void 0) clearTimeout(timer2);\n disarm();\n run2();\n return;\n }\n pending2 = true;\n armed++;\n button.textContent = confirmation;\n button.className = "danger primary";\n timer2 = setTimeout(disarm, 5e3);\n });\n return button;\n }\n async function allowlist(key, after) {\n const result = await postJson("/api/ranges", { name: "allowlist", add: [key] });\n if (!result.ok) {\n toast("bad", "Not allowlisted", result.error ?? "");\n return;\n }\n toast("warn", `Allowlisted ${key}`, "Requests from it are no longer assessed at all.");\n after();\n }\n async function act(body, title, detail, after) {\n const result = await postJson("/api/actor", body);\n if (!result.ok) {\n toast("bad", "Refused", result.error ?? "");\n return;\n }\n toast("ok", title, detail);\n after();\n }\n\n // src/dashboard/client/format.ts\n var numbers = new Intl.NumberFormat();\n function n(value) {\n return numbers.format(value ?? 0);\n }\n function pct(part, whole) {\n return whole > 0 ? `${Math.round(part / whole * 100)}%` : "\u2014";\n }\n function ms(value) {\n return value >= 10 ? `${value.toFixed(1)}ms` : `${value.toFixed(2)}ms`;\n }\n function uptime(milliseconds) {\n const seconds = Math.max(0, Math.round(milliseconds / 1e3));\n if (seconds < 90) return `${seconds}s`;\n const minutes = Math.round(seconds / 60);\n if (minutes < 90) return `${minutes}m`;\n return `${Math.round(minutes / 60)}h`;\n }\n function rangeLabel(milliseconds) {\n const seconds = Math.round(milliseconds / 1e3);\n if (seconds < 90) return `${seconds}s`;\n const minutes = Math.round(seconds / 60);\n return minutes < 90 ? `${minutes} min` : `${Math.round(minutes / 60)}h`;\n }\n function clockTime(at) {\n const when = new Date(at);\n return `${pad(when.getHours())}:${pad(when.getMinutes())}:${pad(when.getSeconds())}`;\n }\n function clockDate(at) {\n const when = new Date(at);\n return `${pad(when.getDate())}-${pad(when.getMonth() + 1)}-${when.getFullYear()}`;\n }\n function clockStamp(at) {\n return `${clockDate(at)} ${clockTime(at)}`;\n }\n function pad(value) {\n return String(value).padStart(2, "0");\n }\n function windowLabel(count, oldestAt2, now) {\n if (count === 0) return "this window \xB7 empty";\n const span = oldestAt2 === void 0 ? 0 : Math.max(0, now - oldestAt2);\n return `last ${n(count)} requests \xB7 ${rangeLabel(span)}`;\n }\n\n // src/dashboard/client/bars.ts\n function drawBars(target, rows, emptyText) {\n clear(target);\n const filtered = [...rows].filter(([, value]) => value > 0);\n if (filtered.length === 0) {\n target.appendChild(el("div", "note", emptyText));\n return;\n }\n filtered.sort((a, b) => b[1] - a[1]);\n const max = filtered[0]?.[1] ?? 1;\n for (const [label2, value] of filtered.slice(0, 14)) {\n const bar = el("div", "bar");\n const track = el("div", "track");\n const fill = el("div", "fill");\n fill.style.width = `${Math.max(2, Math.round(value / max * 100))}%`;\n track.appendChild(fill);\n track.appendChild(el("div", "lbl", label2));\n bar.appendChild(track);\n bar.appendChild(el("div", "v tnum", n(value)));\n target.appendChild(bar);\n }\n }\n function pairs(record) {\n return Object.entries(record ?? {});\n }\n\n // src/dashboard/client/actor.ts\n function initActor() {\n if (!SECTIONS.actors) return;\n $("actor-close").addEventListener("click", closeActor);\n }\n function openActor(key) {\n state.actor = key;\n app.drawNow();\n $("actor-panel").scrollIntoView({ block: "nearest" });\n }\n function closeActor() {\n state.actor = void 0;\n app.drawNow();\n }\n function drawActor() {\n if (!SECTIONS.actors) return;\n const panel = $("actor-panel");\n if (state.actor === void 0) {\n panel.hidden = true;\n return;\n }\n panel.hidden = false;\n $("actor-key").textContent = state.actor;\n const mine = state.rows.filter((row) => row.entry.actor === state.actor).map((row) => row.entry);\n const latest = mine[mine.length - 1];\n const stats = latest?.actorStats;\n const gaps = [];\n for (let i = 1; i < mine.length; i++) gaps.push(mine[i].at - mine[i - 1].at);\n const meanGap = gaps.length > 0 ? gaps.reduce((a, b) => a + b, 0) / gaps.length : 0;\n const box = $("actor-stats");\n clear(box);\n const rows = [\n ["In this window", `${n(mine.length)} requests`],\n ["Engine sees", stats !== void 0 ? `${n(stats.requests)} requests, ${n(stats.distinctPaths)} distinct paths` : "\u2014"],\n ["Prior confirmations", stats !== void 0 ? n(stats.priorConfirmations) : "\u2014"],\n ["Holds clearance", stats !== void 0 ? stats.cleared ? "yes" : "no" : "\u2014"],\n ["First seen", stats !== void 0 ? clockStamp(stats.firstSeen) : "\u2014"],\n ["Mean gap", gaps.length > 0 ? `${Math.round(meanGap)}ms over ${n(gaps.length)} gaps` : "one request only"]\n ];\n for (const [key, value] of rows) {\n box.appendChild(el("dt", null, key));\n box.appendChild(el("dd", null, value));\n }\n const verdicts = /* @__PURE__ */ new Map();\n const actions = /* @__PURE__ */ new Map();\n for (const entry of mine) {\n verdicts.set(entry.verdict, (verdicts.get(entry.verdict) ?? 0) + 1);\n if (entry.action !== void 0) actions.set(entry.action, (actions.get(entry.action) ?? 0) + 1);\n }\n drawBars($("actor-mix"), [...verdicts, ...actions], "Nothing yet.");\n const bar = $("actor-actions");\n clear(bar);\n const buttons = actorActions(state.actor, () => app.drawNow());\n bar.hidden = buttons.length === 0;\n for (const button of buttons) bar.appendChild(button);\n }\n\n // src/dashboard/client/pager.ts\n var painted = /* @__PURE__ */ new WeakMap();\n function renderPager(host, model, options) {\n const signature = JSON.stringify([model.page, model.from, model.to, model.total, model.atStart, model.atEnd, model.held ?? "", options.withSize, model.size?.current]);\n if (painted.get(host) === signature && host.childElementCount > 0) return;\n painted.set(host, signature);\n clear(host);\n const steps = [\n { to: model.page - 1, glyph: "\u2039", label: "Previous page", disabled: model.atStart },\n { to: model.page + 1, glyph: "\u203A", label: "Next page", disabled: model.atEnd }\n ];\n const [previous, next] = steps;\n host.appendChild(stepButton(previous, model));\n const range = model.total === void 0 ? `${n(model.from)}\u2013${n(model.to)}` : `${n(model.from)}\u2013${n(model.to)} of ${n(model.total)}`;\n const where = el("span", "where", range);\n where.setAttribute("aria-live", "polite");\n host.appendChild(where);\n host.appendChild(stepButton(next, model));\n if (model.held !== void 0) {\n const held = el("span", "held", model.held);\n held.title = "New requests are still arriving and are still counted. They appear when you return to the first page.";\n host.appendChild(held);\n }\n if (options.withSize && model.size !== void 0) {\n const size = model.size;\n const label2 = el("label", "size");\n label2.appendChild(document.createTextNode("Per page"));\n const select2 = document.createElement("select");\n for (const choice of size.choices) {\n const option = document.createElement("option");\n option.value = String(choice);\n option.textContent = String(choice);\n option.selected = choice === size.current;\n select2.appendChild(option);\n }\n select2.addEventListener("change", () => {\n const chosen = Number(select2.value);\n if (Number.isFinite(chosen) && chosen > 0) size.set(chosen);\n });\n label2.appendChild(select2);\n host.appendChild(label2);\n }\n }\n function stepButton(step, model) {\n const button = el("button", "step", step.glyph);\n const element = button;\n element.type = "button";\n element.disabled = step.disabled;\n button.setAttribute("aria-label", step.label);\n button.title = step.label;\n button.addEventListener("click", () => model.go(Math.max(0, step.to)));\n return button;\n }\n\n // src/dashboard/client/replay.ts\n function replayLine(entry) {\n const headers = {};\n for (const [name, value] of entry.headers ?? []) headers[name] = value;\n const query = Object.keys(entry.query).map((name) => `${encodeURIComponent(name)}=${encodeURIComponent(entry.query[name] ?? "")}`).join("&");\n return JSON.stringify({\n method: entry.method,\n url: entry.path + (query === "" ? "" : `?${query}`),\n headers,\n ip: entry.actor,\n timestamp: new Date(entry.at).toISOString(),\n protocol: entry.protocol ?? "https",\n httpVersion: entry.httpVersion ?? "1.1"\n });\n }\n function replayFile(entries) {\n return entries.map(replayLine).join("\\n");\n }\n function corpusCase(entry) {\n const headers = (entry.headers ?? []).map((pair) => ` [${JSON.stringify(pair[0])}, ${JSON.stringify(pair[1])}]`).join(",\\n");\n return [\n "bot({",\n ` id: ${JSON.stringify(`case-${entry.requestId.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`)},`,\n ` title: ${JSON.stringify(`${entry.method} ${entry.path} from ${entry.userAgent.slice(0, 60)}`)},`,\n \' audience: "unwanted-bot", // human | benign-bot | declared-bot | unwanted-bot | hostile | infrastructure\',\n \' category: "observed",\',\n ` provenance: "Captured from the live dashboard on ${new Date(entry.at).toISOString().slice(0, 10)}",`,\n " requests: [",\n " {",\n " headers: [",\n headers,\n " ],",\n ` protocol: ${JSON.stringify(entry.protocol ?? "https")},`,\n ` httpVersion: ${JSON.stringify(entry.httpVersion ?? "1.1")},`,\n ` path: ${JSON.stringify(entry.path)},`,\n " },",\n " ],",\n ` expect: { verdict: ${JSON.stringify(entry.verdict)}, certain: ${String(entry.certain)} },`,\n "}),"\n ].join("\\n");\n }\n\n // src/dashboard/client/result.ts\n function showResult(kind, build) {\n const box = $("policy-result");\n box.hidden = false;\n box.className = `result ${kind}`;\n clear(box);\n build(box);\n }\n function renderPreview(preview, notes = []) {\n showResult(preview.newDenials > 0 ? "warn" : "ok", (box) => {\n const head = el("div");\n head.appendChild(el("b", null, `${n(preview.changed)} of ${n(preview.evaluated)} requests would be treated differently.`));\n box.appendChild(head);\n for (const note of notes) box.appendChild(el("div", "ev-meta", note));\n if (preview.newDenials > 0) {\n box.appendChild(el("div", null, `${n(preview.newDenials)} request(s) that are served today would be denied. Read the samples before applying this.`));\n }\n for (const warning of preview.warnings) box.appendChild(el("div", "ev-meta", warning));\n const dead = preview.ruleHits.filter((row) => row.hits === 0 && row.rule !== "default");\n if (dead.length > 0) box.appendChild(el("div", "ev-meta", `Never matched in this window: ${dead.map((row) => row.rule).join(", ")}`));\n if (preview.samples.length > 0) {\n const table = el("table", "diff");\n const head2 = el("tr");\n for (const label2 of ["Request", "Now", "Would be"]) head2.appendChild(el("th", null, label2));\n table.appendChild(head2);\n for (const sample of preview.samples) {\n const row = el("tr");\n const what = el("td");\n what.appendChild(el("div", "mono", sample.path));\n what.appendChild(el("div", "ev-meta", `${sample.verdict} \xB7 ${sample.userAgent.slice(0, 48)}`));\n row.appendChild(what);\n const from = el("td", "from");\n from.appendChild(el("div", null, sample.from));\n from.appendChild(el("div", "ev-meta", sample.fromRule));\n row.appendChild(from);\n const kind = sample.to === "block" || sample.to === "drop" || sample.to === "redirect" ? "deny" : sample.to === "allow" ? "allow" : "";\n const to = el("td", `to ${kind}`);\n to.appendChild(el("div", null, sample.to));\n to.appendChild(el("div", "ev-meta", sample.toRule));\n row.appendChild(to);\n table.appendChild(row);\n }\n box.appendChild(table);\n } else if (preview.evaluated === 0) {\n box.appendChild(el("div", "ev-meta", "No traffic in the window to preview against \u2014 send some requests first."));\n }\n });\n }\n\n // src/dashboard/client/guard.ts\n var draft;\n var MODE_NOTES = {\n strict: "A terminal action survives only on proven evidence. Nothing is ever denied on a guess. This is the default, and it is the claim this library makes about itself.",\n balanced: "A terminal action also survives on a probabilistic verdict that clears the score threshold with at least two independent strong signals. Real people do trip two signals \u2014 a hardened browser behind a corporate proxy is the usual pair \u2014 so this setting will eventually deny somebody who should have been served.",\n aggressive: "The guard is off. Every rule does exactly what it says, on proof or on suspicion alike, and the people it turns away first are the ones with the most unusual and most legitimate setups."\n };\n function drawGuard() {\n if (!SECTIONS.guard) return;\n const document_ = state.policy;\n const snapshot = state.snapshot;\n if (snapshot === void 0) return;\n const editable = document_?.guardEditable === true;\n const panel = $("stat-policy");\n clear(panel);\n if (!editable || document_ === void 0) {\n const rows = [\n ["False-positive policy", snapshot.policy.falsePositivePolicy],\n ["Fallback when the guard stops a rule", snapshot.policy.fallbackAction],\n ["Terminal score threshold", String(snapshot.policy.terminalScoreThreshold)],\n ["Suspect threshold", String(snapshot.policy.suspectThreshold)],\n ["Action when no rule matches", snapshot.policy.defaultAction],\n ["Challenge configured", snapshot.policy.challengeEnabled ? "yes" : "no"],\n ["Range sets", snapshot.ranges.length === 0 ? "none" : snapshot.ranges.map((range) => `${range.name} (${range.size})`).join(", ")]\n ];\n for (const [key, value] of rows) {\n const line = el("div", "stat-row");\n line.appendChild(el("span", "k", key));\n line.appendChild(el("span", "v", value));\n panel.appendChild(line);\n }\n $("guard-mode").textContent = "not editable here";\n $("guard-note").textContent = "These are fixed at construction on this dashboard. It can change which rules exist; it cannot change how far a rule is allowed to go, because relaxing that is the one edit that can start denying people. Enable it deliberately with controls: { editGuard: true }.";\n return;\n }\n if (draft === void 0) draft = { ...document_.guard };\n const vocabulary = document_.vocabulary;\n $("guard-mode").textContent = state.guardDirty ? "unsaved changes" : "editable";\n $("guard-note").textContent = "Changing these changes what every rule is allowed to do, on the next request. Preview it against the traffic in the window first \u2014 that is the only view you get of who it would start turning away.";\n panel.appendChild(\n guardField(\n // "Mode" rather than "Guard": the panel is already called that, and a field\n // repeating its own panel\'s name reads as a heading rather than as a control.\n "Mode",\n segmented(\n vocabulary.falsePositivePolicies.map((mode) => [mode, mode]),\n draft.falsePositivePolicy,\n (value) => {\n setDraft({ falsePositivePolicy: value });\n drawGuard();\n }\n )\n )\n );\n panel.appendChild(el("div", "note guard-explains", MODE_NOTES[draft.falsePositivePolicy] ?? ""));\n panel.appendChild(\n guardField(\n "Fallback",\n select(vocabulary.fallbackActions, draft.fallbackAction, (value) => setDraft({ fallbackAction: value })),\n "what a stopped rule becomes \u2014 the terminal actions are absent because a terminal fallback would deny the request the guard just protected"\n )\n );\n panel.appendChild(\n guardField(\n "Default action",\n select(vocabulary.actions, draft.defaultAction, (value) => setDraft({ defaultAction: value })),\n "when no rule matches"\n )\n );\n panel.appendChild(\n guardField(\n "Terminal score",\n number(draft.terminalScoreThreshold, (value) => setDraft({ terminalScoreThreshold: value })),\n "balanced mode only: the score a probabilistic verdict must clear"\n )\n );\n panel.appendChild(\n guardField(\n "Suspect at",\n number(draft.suspectThreshold, (value) => setDraft({ suspectThreshold: value })),\n "the score at which a request becomes suspected-bot"\n )\n );\n const bar = el("div", "bar-actions");\n const preview = el("button", null, "Preview");\n preview.addEventListener("click", () => {\n void previewGuard();\n });\n const apply = el("button", "primary", "Apply");\n apply.addEventListener("click", () => {\n void applyGuard();\n });\n const revert = el("button", null, "Revert");\n revert.addEventListener("click", () => {\n draft = { ...document_.guard };\n state.guardDirty = false;\n drawGuard();\n });\n bar.appendChild(preview);\n bar.appendChild(apply);\n bar.appendChild(revert);\n panel.appendChild(bar);\n }\n function setDraft(change) {\n if (draft === void 0) return;\n draft = { ...draft, ...change };\n state.guardDirty = true;\n $("guard-mode").textContent = "unsaved changes";\n }\n function resetGuardDraft() {\n draft = void 0;\n state.guardDirty = false;\n }\n function liveRules() {\n return (state.policy?.rules ?? []).filter((row) => row.editable && row.rule !== void 0).map((row) => row.rule);\n }\n async function previewGuard() {\n if (draft === void 0) return;\n const result = await postJson("/api/policy/preview", { rules: liveRules(), guard: draft });\n if (!result.ok) {\n showResult("bad", (box) => box.appendChild(el("div", null, result.error ?? "")));\n toast("bad", "Refused", result.error ?? "");\n return;\n }\n renderPreview(result.data, state.editorDirty ? ["Previewed against the rules currently in force, not the unsaved edits in the editor."] : []);\n app.showTab("policy");\n }\n async function applyGuard() {\n if (draft === void 0) return;\n const result = await postJson("/api/guard", draft);\n if (!result.ok) {\n showResult("bad", (box) => box.appendChild(el("div", null, result.error ?? "")));\n toast("bad", "Guard unchanged", result.error ?? "");\n return;\n }\n state.guardDirty = false;\n draft = { ...result.data.guard };\n if (state.policy !== void 0) state.policy.guard = { ...result.data.guard };\n toast("ok", "Guard changed", `${result.data.guard.falsePositivePolicy}, falling back to ${result.data.guard.fallbackAction}.`);\n showResult("warn", (box) => {\n box.appendChild(el("div", null, "The guard changed. It applies from the next request, and it is in the notices panel and in your logs."));\n if (result.data.guard.falsePositivePolicy !== "strict") {\n box.appendChild(\n el(\n "div",\n "ev-meta",\n "Requests can now be denied without proof. The guard-stop count is the series to watch: every stop that no longer happens is a request that used to be recoverable and is not any more."\n )\n );\n }\n });\n app.draw();\n }\n function guardField(name, control, hint) {\n const row = el("div", "field");\n row.appendChild(label(name, control));\n const right = el("div", "field-row");\n right.appendChild(control);\n if (hint !== void 0) right.appendChild(el("span", "hint", hint));\n row.appendChild(right);\n return row;\n }\n function segmented(options, value, onChange) {\n const box = el("div", "seg");\n for (const [label2, option] of options) {\n const button = el("button", null, label2);\n button.type = "button";\n button.setAttribute("aria-pressed", String(option === value));\n button.addEventListener("click", () => onChange(option));\n box.appendChild(button);\n }\n return box;\n }\n function select(options, value, onChange) {\n const node = el("select");\n for (const option of options) {\n const item = el("option", null, option);\n item.value = option;\n if (option === value) item.selected = true;\n node.appendChild(item);\n }\n node.addEventListener("change", () => onChange(node.value));\n return node;\n }\n function number(value, onChange) {\n const input = el("input");\n input.type = "number";\n input.min = "1";\n input.max = "100";\n input.value = String(value);\n input.addEventListener("input", () => {\n if (input.value !== "") onChange(Number(input.value));\n });\n return input;\n }\n\n // src/dashboard/client/ranges.ts\n var sets = [];\n async function loadRanges() {\n if (!SECTIONS.ranges) return;\n try {\n const body = await getJson("/api/ranges");\n sets = body.ranges;\n drawRanges();\n } catch {\n }\n }\n function drawRanges() {\n if (!SECTIONS.ranges) return;\n const body = $("ranges-body");\n clear(body);\n $("ranges-mode").textContent = BOOT.allowActing ? "editable" : "read-only";\n $("ranges-note").textContent = BOOT.allowActing ? "An address on the allowlist is not judged leniently \u2014 it is not judged at all. Detection does not run on it, no evidence is produced, and no rule sees it." : "These come from the code that constructed the handler. Enable controls.editRanges to add an address from here.";\n if (sets.length === 0) {\n body.appendChild(el("div", "note", "No range sets are configured. Add an address to the allowlist and one appears."));\n }\n for (const set of sets) {\n const block = el("div", "rangeset");\n const heading = el("h3");\n heading.appendChild(document.createTextNode(set.name));\n heading.appendChild(el("span", null, `${n(set.size)} entr${set.size === 1 ? "y" : "ies"}`));\n block.appendChild(heading);\n const list = el("div", "cidrs");\n for (const entry of set.entries) {\n const chip = el("span", BOOT.allowActing ? "cidr" : "cidr readonly");\n chip.appendChild(document.createTextNode(entry));\n if (BOOT.allowActing) {\n const remove = el("button", null, "\xD7");\n remove.title = `Remove ${entry} from ${set.name}`;\n remove.setAttribute("aria-label", `Remove ${entry} from ${set.name}`);\n remove.addEventListener("click", () => void update(set.name, { remove: [entry] }));\n chip.appendChild(remove);\n }\n list.appendChild(chip);\n }\n if (set.entries.length === 0) list.appendChild(el("span", "hint", "empty"));\n block.appendChild(list);\n body.appendChild(block);\n }\n if (!BOOT.allowActing) return;\n const form = el("div", "rangeset");\n const row = el("div", "field-row");\n const name = el("input", "mono-input");\n name.type = "text";\n name.value = "allowlist";\n name.setAttribute("aria-label", "Range set");\n name.style.maxWidth = "150px";\n const value = el("input", "mono-input");\n value.type = "text";\n value.placeholder = "203.0.113.0/24";\n value.setAttribute("aria-label", "Address or CIDR to add");\n const add = el("button", null, "Add");\n const submit = () => {\n const entry = value.value.trim();\n if (entry === "") return;\n value.value = "";\n void update(name.value.trim(), { add: [entry] });\n };\n add.addEventListener("click", submit);\n value.addEventListener("keydown", (event) => {\n if (event.key === "Enter") submit();\n });\n row.appendChild(name);\n row.appendChild(value);\n row.appendChild(add);\n form.appendChild(row);\n body.appendChild(form);\n }\n async function update(name, change) {\n const result = await postJson("/api/ranges", { name, ...change });\n if (!result.ok) {\n toast("bad", "Ranges unchanged", result.error ?? "");\n return;\n }\n toast(name === "allowlist" && change.add !== void 0 ? "warn" : "ok", `\u201C${name}\u201D updated`, `${n(result.data.entries?.length ?? 0)} entr${result.data.entries?.length === 1 ? "y" : "ies"} now.`);\n await loadRanges();\n }\n\n // src/dashboard/client/draft.ts\n function draftRule(entry, existingIds = []) {\n const match = {};\n let because;\n let stem;\n const proven = entry.evidence.filter((item) => item.certainty === "certain" && item.direction !== "human");\n const detectors = [...new Set((proven.length > 0 ? proven : entry.evidence.filter((item) => item.direction !== "human")).map((item) => item.detector))];\n if (entry.identity !== void 0 && entry.identity !== "") {\n match["identity"] = [entry.identity];\n if (entry.certain) match["certain"] = true;\n stem = entry.identity;\n because = entry.certain ? `Matched on the identity \u201C${entry.identity}\u201D, and on proof \u2014 so a client merely claiming that name does not match.` : `Matched on the claimed identity \u201C${entry.identity}\u201D. Nothing has verified it, so this matches anything that says so.`;\n } else if (proven.length > 0) {\n match["detector"] = detectors;\n match["certain"] = true;\n stem = detectors[0] ?? "proven";\n because = `Matched on proof from ${detectors.join(", ")}. Only requests that carry the same proof match.`;\n } else if (detectors.length > 0) {\n match["verdict"] = [entry.verdict];\n match["detector"] = detectors;\n match["minScore"] = Math.max(0, Math.floor(entry.score / 10) * 10);\n stem = detectors[0] ?? entry.verdict;\n because = `Matched on ${entry.verdict} at score ${String(match["minScore"])} or more, from ${detectors.join(", ")}. Every one of those is probabilistic, so the guard will not let this rule deny anybody.`;\n } else {\n match["verdict"] = [entry.verdict];\n stem = entry.verdict;\n because = `Nothing fired on this request, so there is nothing sharper to match on than the verdict itself. Narrow it before you use it.`;\n }\n return {\n rule: {\n id: uniqueId(`from-${slug(stem)}`, existingIds),\n match,\n action: "tag",\n reason: "Drafted from a request on the dashboard.",\n _open: true\n },\n because\n };\n }\n function slug(value) {\n const cleaned = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");\n return cleaned === "" ? "request" : cleaned.slice(0, 40);\n }\n function uniqueId(wanted, taken) {\n if (!taken.includes(wanted)) return wanted;\n for (let suffix = 2; suffix < 1e3; suffix++) {\n const candidate = `${wanted}-${suffix}`;\n if (!taken.includes(candidate)) return candidate;\n }\n return `${wanted}-${Date.now()}`;\n }\n\n // src/dashboard/client/policy.ts\n async function loadPolicy() {\n if (!SECTIONS.policy) return;\n try {\n const document_ = await getJson("/api/policy");\n state.policy = document_;\n if (!state.editorDirty) {\n setEditorRules(document_.rules.filter((row) => row.editable).map((row) => row.rule));\n }\n if (!state.guardDirty) resetGuardDraft();\n drawPolicyTab();\n } catch {\n }\n }\n function setEditorRules(rules) {\n state.editorRules = JSON.parse(JSON.stringify(rules ?? []));\n renderEditor();\n }\n function markDirty() {\n state.editorDirty = true;\n $("policy-dirty").hidden = false;\n }\n function editorRules() {\n if (state.editorMode === "json") {\n try {\n const parsed = JSON.parse(byId("policy-json").value);\n if (!Array.isArray(parsed)) return { error: "The JSON must be an array of rules." };\n return { rules: parsed };\n } catch (error) {\n return { error: `The editor does not contain valid JSON: ${String(error)}` };\n }\n }\n return { rules: state.editorRules };\n }\n function cleanRule(rule) {\n const match = {};\n for (const [key, value] of Object.entries(rule.match ?? {})) {\n if (value === void 0 || value === null || value === "") continue;\n if (Array.isArray(value) && value.length === 0) continue;\n match[key] = value;\n }\n const params = {};\n for (const [key, value] of Object.entries(rule.params ?? {})) {\n if (value === void 0 || value === null || value === "") continue;\n if (key === "limit") {\n const limit = value;\n if (limit.max === void 0 || limit.windowMs === void 0) continue;\n }\n params[key] = value;\n }\n const out = { id: rule.id, match, action: rule.action };\n if (Object.keys(params).length > 0) out["params"] = params;\n if (rule.reason !== void 0 && rule.reason !== "") out["reason"] = rule.reason;\n return out;\n }\n function cleanRules(rules) {\n return rules.map(cleanRule);\n }\n function field(name, control, hint) {\n const row = el("div", "field");\n row.appendChild(label(name, control));\n const right = el("div", "field-row");\n for (const node of Array.isArray(control) ? control : [control]) right.appendChild(node);\n if (hint !== void 0) right.appendChild(el("span", "hint", hint));\n row.appendChild(right);\n return row;\n }\n function chipSelect(options, selected, onChange) {\n const box = el("div", "chips-select");\n const chosen = Array.isArray(selected) ? [...selected] : selected === void 0 ? [] : [String(selected)];\n for (const option of options) {\n const button = el("button", null, option);\n button.type = "button";\n button.setAttribute("aria-pressed", String(chosen.includes(option)));\n button.addEventListener("click", () => {\n const at = chosen.indexOf(option);\n if (at === -1) chosen.push(option);\n else chosen.splice(at, 1);\n button.setAttribute("aria-pressed", String(at === -1));\n onChange(chosen.length === 0 ? void 0 : [...chosen]);\n });\n box.appendChild(button);\n }\n return box;\n }\n function segmented2(options, value, onChange) {\n const box = el("div", "seg");\n for (const [label2, option] of options) {\n const button = el("button", null, label2);\n button.type = "button";\n button.setAttribute("aria-pressed", String(option === value));\n button.addEventListener("click", () => {\n for (const other of Array.from(box.children)) other.setAttribute("aria-pressed", "false");\n button.setAttribute("aria-pressed", "true");\n onChange(option);\n });\n box.appendChild(button);\n }\n return box;\n }\n function textInput(value, placeholder, onChange, mono = false) {\n const input = el("input", mono ? "mono-input" : null);\n input.type = "text";\n input.value = value === void 0 || value === null ? "" : String(value);\n input.placeholder = placeholder;\n input.addEventListener("input", () => onChange(input.value.trim() === "" ? void 0 : input.value));\n return input;\n }\n function listInput(value, placeholder, onChange) {\n const current = value === void 0 ? "" : Array.isArray(value) ? value.join(", ") : String(value);\n const input = textInput(current, placeholder, () => {\n }, true);\n input.addEventListener("input", () => {\n const parts = input.value.split(",").map((part) => part.trim()).filter(Boolean);\n onChange(parts.length === 0 ? void 0 : parts.length === 1 ? parts[0] : parts);\n });\n return input;\n }\n function numberInput(value, placeholder, onChange) {\n const input = el("input");\n input.type = "number";\n input.value = value === void 0 || value === null ? "" : String(value);\n input.placeholder = placeholder;\n input.addEventListener("input", () => onChange(input.value === "" ? void 0 : Number(input.value)));\n return input;\n }\n function matchSummary(rule) {\n const box = el("div", "rule-summary");\n const match = rule.match ?? {};\n const parts = [];\n for (const key of ["verdict", "botClass", "category", "identity", "detector", "method", "path"]) {\n const value = match[key];\n if (value === void 0) continue;\n parts.push([key, Array.isArray(value) ? value.join(", ") : String(value)]);\n }\n if (match["certain"] !== void 0) parts.push(["certain", String(match["certain"])]);\n if (match["minScore"] !== void 0 || match["maxScore"] !== void 0) {\n parts.push(["score", `${String(match["minScore"] ?? 0)}\u2013${String(match["maxScore"] ?? 99)}`]);\n }\n if (match["minPriorConfirmations"] !== void 0) parts.push(["prior", String(match["minPriorConfirmations"])]);\n if (match["minUnsolvedChallenges"] !== void 0) parts.push(["unsolved", String(match["minUnsolvedChallenges"])]);\n if (parts.length === 0) {\n box.appendChild(el("span", "none", "matches everything"));\n return box;\n }\n for (const [key, value] of parts.slice(0, 4)) box.appendChild(el("span", "t", `${key}: ${value}`));\n if (parts.length > 4) box.appendChild(el("span", "k", `+${parts.length - 4} more`));\n return box;\n }\n function ruleCard(rule, index) {\n const vocabulary = state.policy?.vocabulary;\n const open = rule._open === true;\n const card = el("div", `rule${open ? "" : " collapsed"}`);\n const head = el("div", "rule-head");\n const chevron = el("button", "chev", open ? "\u25BE" : "\u25B8");\n chevron.title = open ? "Collapse" : "Expand";\n chevron.setAttribute("aria-expanded", String(open));\n chevron.addEventListener("click", () => {\n rule._open = !open;\n renderEditor();\n });\n head.appendChild(chevron);\n head.appendChild(el("span", "ord", index + 1));\n const id = textInput(rule.id, "rule-id", (value) => {\n rule.id = value ?? "";\n markDirty();\n }, true);\n id.setAttribute("aria-label", "Rule id");\n head.appendChild(id);\n if (open) {\n const action = el("select");\n action.setAttribute("aria-label", "Action");\n for (const name of vocabulary?.actions ?? []) {\n const option = el("option", null, name);\n option.value = name;\n if (name === rule.action) option.selected = true;\n action.appendChild(option);\n }\n action.addEventListener("change", () => {\n rule.action = action.value;\n rule.params = {};\n markDirty();\n renderEditor();\n });\n head.appendChild(action);\n } else {\n head.appendChild(matchSummary(rule));\n head.appendChild(el("span", `act-pill ${actionKind(rule.action)}`, rule.action));\n }\n const up = el("button", "icon", "\u2191");\n up.title = "Move earlier \u2014 the first matching rule wins";\n up.addEventListener("click", () => moveRule(index, -1));\n const down = el("button", "icon", "\u2193");\n down.title = "Move later";\n down.addEventListener("click", () => moveRule(index, 1));\n const remove = el("button", "icon danger", "Remove");\n remove.addEventListener("click", () => {\n state.editorRules.splice(index, 1);\n markDirty();\n renderEditor();\n });\n head.appendChild(up);\n head.appendChild(down);\n head.appendChild(remove);\n card.appendChild(head);\n if (!open) return card;\n const body = el("div", "rule-body");\n rule.match = rule.match ?? {};\n const match = rule.match;\n body.appendChild(field("Verdict", chipSelect(vocabulary?.verdicts ?? [], match["verdict"], (value) => {\n match["verdict"] = value;\n markDirty();\n })));\n body.appendChild(field("Bot class", chipSelect(vocabulary?.botClasses ?? [], match["botClass"], (value) => {\n match["botClass"] = value;\n markDirty();\n })));\n body.appendChild(field("Category", chipSelect(vocabulary?.categories ?? [], match["category"], (value) => {\n match["category"] = value;\n markDirty();\n })));\n body.appendChild(field("Detector", chipSelect(vocabulary?.detectors ?? [], match["detector"], (value) => {\n match["detector"] = value;\n markDirty();\n })));\n body.appendChild(field("Method", chipSelect(vocabulary?.methods ?? [], match["method"], (value) => {\n match["method"] = value;\n markDirty();\n })));\n body.appendChild(\n field(\n "Evidence",\n segmented2(\n [\n ["any", void 0],\n ["proven", true],\n ["unproven", false]\n ],\n match["certain"],\n (value) => {\n match["certain"] = value;\n markDirty();\n }\n ),\n "proven means at least one piece of certain evidence \u2014 including a proven human"\n )\n );\n body.appendChild(\n field("Score", [\n numberInput(match["minScore"], "min", (value) => {\n match["minScore"] = value;\n markDirty();\n }),\n el("span", "hint", "to"),\n numberInput(match["maxScore"], "max", (value) => {\n match["maxScore"] = value;\n markDirty();\n })\n ])\n );\n body.appendChild(field("Identity", listInput(match["identity"], "googlebot, gptbot", (value) => {\n match["identity"] = value;\n markDirty();\n })));\n body.appendChild(field("Path", listInput(match["path"], "/api/, /search", (value) => {\n match["path"] = value;\n markDirty();\n }), "a string matches as a prefix"));\n body.appendChild(\n field(\n "Prior bots",\n numberInput(match["minPriorConfirmations"], "0", (value) => {\n match["minPriorConfirmations"] = value;\n markDirty();\n }),\n "times this actor was already proven a bot"\n )\n );\n body.appendChild(\n field(\n "Unsolved",\n numberInput(match["minUnsolvedChallenges"], "0", (value) => {\n match["minUnsolvedChallenges"] = value;\n markDirty();\n }),\n "challenges issued to this actor that were never answered \u2014 solving one clears the count"\n )\n );\n rule.params = rule.params ?? {};\n const params = rule.params;\n for (const node of paramFields(rule.action, params)) body.appendChild(node);\n body.appendChild(field("Reason", textInput(rule.reason, "shown in the decision and in your logs", (value) => {\n rule.reason = value ?? "";\n markDirty();\n })));\n card.appendChild(body);\n return card;\n }\n function paramFields(action, params) {\n switch (action) {\n case "block":\n return [\n field("Status", numberInput(params["status"], "403", (value) => {\n params["status"] = value;\n markDirty();\n })),\n field("Body", textInput(params["body"], "Automated traffic is not served here.", (value) => {\n params["body"] = value;\n markDirty();\n }))\n ];\n case "redirect":\n return [field("Location", textInput(params["location"], "/too-fast", (value) => {\n params["location"] = value;\n markDirty();\n }, true))];\n case "delay":\n return [field("Delay", numberInput(params["delayMs"], "250", (value) => {\n params["delayMs"] = value;\n markDirty();\n }), "milliseconds")];\n case "rate-limit": {\n const limit = params["limit"] ?? {};\n params["limit"] = limit;\n return [\n field("Limit", [\n numberInput(limit["max"], "60", (value) => {\n limit["max"] = value;\n markDirty();\n }),\n el("span", "hint", "requests per"),\n numberInput(limit["windowMs"], "60000", (value) => {\n limit["windowMs"] = value;\n markDirty();\n }),\n el("span", "hint", "ms")\n ])\n ];\n }\n case "custom":\n return [field("Handler", textInput(params["handler"], "handler-id", (value) => {\n params["handler"] = value;\n markDirty();\n }, true), "id of a handler you registered")];\n default:\n return [];\n }\n }\n function lockedCard(row) {\n const card = el("div", "rule locked");\n const head = el("div", "rule-head");\n head.appendChild(el("span", "ord", `#${row.index + 1}`));\n head.appendChild(el("span", "mono", row.id));\n head.appendChild(el("span", "grow"));\n head.appendChild(el("span", "pill", "predicate \u2014 locked"));\n card.appendChild(head);\n card.appendChild(\n el("div", "rule-body", "This rule matches with a function, which cannot be represented here or sent over HTTP. It stays exactly as it is, at this position, whatever else you change.")\n );\n return card;\n }\n function moveRule(index, delta) {\n const target = index + delta;\n if (target < 0 || target >= state.editorRules.length) return;\n const moved = state.editorRules.splice(index, 1)[0];\n if (moved === void 0) return;\n state.editorRules.splice(target, 0, moved);\n markDirty();\n renderEditor();\n }\n function renderEditor() {\n if (!SECTIONS.policy) return;\n const list = $("rulelist");\n clear(list);\n const locked = (state.policy?.rules ?? []).filter((row) => !row.editable);\n if (state.editorRules.length === 0 && locked.length === 0) {\n list.appendChild(el("div", "note", "No rules. Every request takes the default action \u2014 add one, or import a set."));\n }\n const rendered2 = state.editorRules.map((rule, index) => ruleCard(rule, index));\n for (const row of locked) rendered2.splice(Math.min(row.index, rendered2.length), 0, lockedCard(row));\n for (const node of rendered2) list.appendChild(node);\n if (state.editorMode === "json") byId("policy-json").value = JSON.stringify(cleanRules(state.editorRules), null, 2);\n }\n function drawPolicyTab() {\n const document_ = state.policy;\n if (document_ === void 0) return;\n const editable = document_.editable;\n $("policy-apply").hidden = !editable;\n byId("policy-json").readOnly = !editable;\n $("policy-mode").textContent = editable ? "editable" : "read-only";\n $("rule-add").hidden = !editable;\n $("policy-import").hidden = !editable;\n const preserved = document_.rules.filter((row) => !row.editable);\n let note = editable ? "First match wins \u2014 order matters." : "Read-only. Enable controls.editPolicy to change these.";\n if (preserved.length > 0) note += ` ${preserved.length} rule(s) use a predicate function and are locked.`;\n $("policy-note").textContent = note;\n renderPresetButtons();\n drawGuard();\n drawRanges();\n if (SECTIONS.robots) {\n $("robots-preview").textContent = document_.robots === "" ? "(this policy declines no crawler by name)" : document_.robots;\n const notes = $("robots-notes");\n clear(notes);\n for (const note_ of document_.robotsNotes) notes.appendChild(el("div", "ev-meta", `${note_.rule}: ${note_.reason}`));\n }\n const rules = $("stat-rules");\n clear(rules);\n const installed = state.snapshot?.rules ?? [];\n if (installed.length === 0) rules.appendChild(el("div", "note", "No rules configured \u2014 every request takes the default action."));\n else installed.forEach((rule, index) => rules.appendChild(el("span", "chip", `${index + 1}. ${rule}`)));\n }\n async function draftIntoEditor(entry) {\n if (state.policy === void 0) await loadPolicy();\n const drafted = draftRule(entry, state.editorRules.map((rule) => rule.id));\n state.editorRules.push(drafted.rule);\n markDirty();\n if (state.editorMode === "json") byId("policy-json").value = JSON.stringify(cleanRules(state.editorRules), null, 2);\n renderEditor();\n app.showTab("policy");\n const notes = [\n `Drafted \u201C${drafted.rule.id}\u201D, tagging only. Nothing is applied \u2014 read it, choose the action, then apply.`,\n drafted.because,\n "It was added last, which is the only position that cannot change what an existing rule does. Move it up with \u2191 if it needs to win."\n ];\n showResult("warn", (box) => {\n for (const note of notes) box.appendChild(el("div", note === notes[0] ? null : "ev-meta", note));\n });\n toast("ok", "Rule drafted", "In the editor, tagging only, not applied.");\n await runPreview(notes);\n }\n function switchToGui() {\n if (state.editorMode === "gui") return;\n const parsed = editorRules();\n if (parsed.error !== void 0) {\n toast("bad", "That JSON will not parse", parsed.error);\n return;\n }\n state.editorRules = parsed.rules ?? [];\n state.editorMode = "gui";\n $("mode-gui").setAttribute("aria-pressed", "true");\n $("mode-json").setAttribute("aria-pressed", "false");\n $("editor-gui").hidden = false;\n $("editor-json").hidden = true;\n renderEditor();\n }\n function switchToJson() {\n state.editorMode = "json";\n $("mode-gui").setAttribute("aria-pressed", "false");\n $("mode-json").setAttribute("aria-pressed", "true");\n $("editor-gui").hidden = true;\n $("editor-json").hidden = false;\n byId("policy-json").value = JSON.stringify(cleanRules(state.editorRules), null, 2);\n }\n async function exportSettings() {\n try {\n const response = await fetch(`${API}/api/settings`);\n const text = await response.text();\n if (!response.ok) throw new Error(text);\n download(text, `bothandler-settings-${today()}.json`, "application/json");\n toast("ok", "Settings exported", "Rules, plus a record of the configuration around them.");\n } catch (error) {\n toast("bad", "Export failed", String(error));\n }\n }\n function readSettingsFile(file) {\n const reader = new FileReader();\n reader.onload = () => {\n try {\n applyImported(JSON.parse(String(reader.result)), file.name);\n } catch (error) {\n toast("bad", "That file is not JSON", String(error));\n }\n };\n reader.onerror = () => toast("bad", "Could not read that file", "");\n reader.readAsText(file);\n }\n function applyImported(document_, name) {\n const rules = Array.isArray(document_) ? document_ : Array.isArray(document_.rules) ? document_.rules : void 0;\n if (rules === void 0) {\n toast("bad", "Nothing to import", "Expected an array of rules, or a settings file with a rules array.");\n return;\n }\n setEditorRules(rules);\n markDirty();\n switchToGui();\n const ignored = [];\n const readOnly = document_.readOnly;\n if (readOnly !== void 0) {\n ignored.push("the guard, the detectors, the ranges and the audit \u2014 those come from the code that built the handler, not from a file");\n if (Array.isArray(readOnly.lockedRules) && readOnly.lockedRules.length > 0) {\n ignored.push(`${readOnly.lockedRules.length} predicate rule(s), which stay as they are`);\n }\n }\n showResult("warn", (box) => {\n box.appendChild(el("div", null, `Loaded ${rules.length} rule(s) from ${name}. Nothing has been applied yet \u2014 preview it first.`));\n for (const line of ignored) box.appendChild(el("div", "ev-meta", `Ignored: ${line}`));\n });\n toast("ok", `Imported ${rules.length} rule(s)`, "Review, preview, then apply.");\n }\n function collectForSubmit() {\n const parsed = editorRules();\n if (parsed.error !== void 0) {\n showResult("bad", (box) => box.appendChild(el("div", null, parsed.error ?? "")));\n toast("bad", "That JSON will not parse", parsed.error);\n return void 0;\n }\n const cleaned = cleanRules(parsed.rules ?? []);\n const blank = cleaned.filter((rule) => rule["id"] === void 0 || rule["id"] === "").length;\n if (blank > 0) {\n toast("bad", "Every rule needs an id", "It is what every decision and log line names.");\n return void 0;\n }\n return cleaned;\n }\n async function runPreview(notes = []) {\n const rules = collectForSubmit();\n if (rules === void 0) return;\n const result = await postJson("/api/policy/preview", { rules });\n if (!result.ok) {\n showResult("bad", (box) => box.appendChild(el("div", null, result.error ?? "")));\n toast("bad", "Refused", result.error ?? "");\n return;\n }\n renderPreview(result.data, notes);\n }\n async function applyPolicy() {\n const rules = collectForSubmit();\n if (rules === void 0) return;\n const result = await postJson("/api/policy/apply", { rules });\n if (!result.ok) {\n showResult("bad", (box) => box.appendChild(el("div", null, result.error ?? "")));\n toast("bad", "Refused", result.error ?? "");\n return;\n }\n state.editorDirty = false;\n $("policy-dirty").hidden = true;\n await loadPolicy();\n toast("ok", "Applied", `${n(result.data.rules)} rule(s) now in force.`);\n showResult("ok", (box) => {\n box.appendChild(el("div", null, `Applied. ${n(result.data.rules)} rule(s) are now in force.`));\n for (const warning of result.data.warnings ?? []) box.appendChild(el("div", "ev-meta", warning));\n });\n }\n function renderPresetButtons() {\n const box = $("preset-buttons");\n const presets = state.policy?.vocabulary.presets ?? [];\n if (box.childElementCount === presets.length) return;\n clear(box);\n for (const preset of presets) {\n const button = el("button", null, preset);\n button.addEventListener("click", () => {\n void (async () => {\n const result = await postJson("/api/policy/preview", { preset });\n if (!result.ok) {\n toast("bad", "Refused", result.error ?? "");\n return;\n }\n renderPreview(result.data);\n })();\n });\n box.appendChild(button);\n }\n }\n function initPolicy() {\n if (!SECTIONS.policy) return;\n $("mode-gui").addEventListener("click", switchToGui);\n $("mode-json").addEventListener("click", switchToJson);\n $("rule-add").addEventListener("click", () => {\n state.editorRules.push({ id: `new-rule-${state.editorRules.length + 1}`, match: {}, action: "tag", params: {}, _open: true });\n markDirty();\n renderEditor();\n });\n $("rule-expand").addEventListener("click", () => {\n const anyClosed = state.editorRules.some((rule) => rule._open !== true);\n for (const rule of state.editorRules) rule._open = anyClosed;\n $("rule-expand").textContent = anyClosed ? "Collapse all" : "Expand all";\n renderEditor();\n });\n byId("policy-json").addEventListener("input", markDirty);\n $("policy-preview").addEventListener("click", () => void runPreview());\n $("policy-apply").addEventListener("click", () => void applyPolicy());\n $("policy-revert").addEventListener("click", () => {\n state.editorDirty = false;\n $("policy-dirty").hidden = true;\n $("policy-result").hidden = true;\n resetGuardDraft();\n void loadPolicy();\n });\n $("policy-export").addEventListener("click", () => void exportSettings());\n $("policy-import").addEventListener("click", () => byId("policy-file").click());\n byId("policy-file").addEventListener("change", () => {\n const input = byId("policy-file");\n const file = input.files?.[0];\n if (file !== void 0) readSettingsFile(file);\n input.value = "";\n });\n const panel = $("editor-panel");\n for (const name of ["dragenter", "dragover"]) {\n panel.addEventListener(name, (event) => {\n if (state.policy?.editable !== true) return;\n event.preventDefault();\n panel.classList.add("drop");\n });\n }\n for (const name of ["dragleave", "drop"]) {\n panel.addEventListener(name, (event) => {\n panel.classList.remove("drop");\n if (name !== "drop") return;\n event.preventDefault();\n const file = event.dataTransfer?.files?.[0];\n if (file !== void 0) readSettingsFile(file);\n });\n }\n }\n\n // src/dashboard/client/feed.ts\n var FILTERS = [\n ["all", "All"],\n ["proven", "Proven"],\n ["suspected", "Suspected"],\n ["human", "Human"],\n ["guard", "Guard stops"],\n ["deny", "Denied"],\n ["mitigate", "Mitigated"],\n ["allow", "Served"]\n ];\n var rendered = /* @__PURE__ */ new Map();\n function initFeed() {\n const loadThem = byId("feed-load-skipped");\n loadThem.addEventListener("click", () => {\n loadThem.disabled = true;\n void loadSkipped().finally(() => {\n loadThem.disabled = false;\n });\n });\n const filters = $("filters");\n for (const [name, label2] of FILTERS) {\n const button = el("button", null, label2);\n button.setAttribute("aria-pressed", String(name === state.filter));\n button.dataset["filter"] = name;\n button.addEventListener("click", () => {\n state.filter = name;\n resetPaging();\n for (const other of Array.from(filters.children)) other.setAttribute("aria-pressed", "false");\n button.setAttribute("aria-pressed", "true");\n app.syncUrl();\n app.drawNow();\n });\n filters.appendChild(button);\n }\n const search = byId("search");\n search.addEventListener("input", () => {\n setSearch(search.value.trim());\n app.syncUrl();\n app.drawNow();\n });\n const exportShown = byId("feed-export");\n exportShown.hidden = !SECTIONS.evidence;\n exportShown.addEventListener("click", () => {\n const rows = matchingRows();\n if (rows.length === 0) {\n toast("warn", "Nothing to export", "No request in the window matches this filter.");\n return;\n }\n const entries = rows.map((row) => row.entry).reverse();\n download(`${replayFile(entries)}\n`, `bothandler-feed-${today()}.jsonl`, "application/x-ndjson");\n toast("ok", `Exported ${n(entries.length)} request(s)`, "Replay them with `bothandlerjs replay`.");\n });\n }\n function reflectFilterButtons() {\n for (const button of Array.from($("filters").children)) {\n button.setAttribute("aria-pressed", String(button instanceof HTMLElement && button.dataset["filter"] === state.filter));\n }\n const search = byId("search");\n if (search.value !== state.search) search.value = state.search;\n }\n async function loadSkipped() {\n const before = state.rows.length;\n try {\n const body = await getJson("/api/feed");\n for (const entry of body.entries) ingest(entry);\n sortRows();\n } catch {\n toast("bad", "Could not load them", "The dashboard did not answer. The entries are still in the window; try again.");\n return;\n }\n state.caughtUp = (state.snapshot?.skipped ?? 0) + state.laggedDrops;\n const added = state.rows.length - before;\n resetPaging();\n app.drawNow();\n toast(\n added > 0 ? "ok" : "warn",\n added > 0 ? `Loaded ${n(added)}` : "Nothing left to load",\n added > 0 ? "They are in the feed now, in the order they happened." : "The window no longer holds them; the ring had already rotated past."\n );\n }\n var FEED_PAGE_SIZES = [25, 50, 100, 200];\n function drawPager(paged) {\n const size = state.feedPageSize;\n const hidden = paged.pages <= 1;\n const model = {\n page: paged.page,\n from: paged.page * size + 1,\n to: Math.min(paged.total, (paged.page + 1) * size),\n total: paged.total,\n atStart: paged.page === 0,\n atEnd: paged.page >= paged.pages - 1,\n ...paged.page > 0 ? { held: "held while you read" } : {},\n go: (page) => {\n goToFeedPage(page);\n app.drawNow();\n },\n size: {\n current: size,\n choices: FEED_PAGE_SIZES,\n set: (next) => {\n state.feedPageSize = next;\n resetPaging();\n app.drawNow();\n }\n }\n };\n for (const [id, withSize] of [\n ["feed-pager-top", true],\n ["feed-pager", false]\n ]) {\n const host = $(id);\n host.hidden = hidden;\n if (hidden) clear(host);\n else renderPager(host, model, { withSize });\n }\n }\n function drawFeed() {\n const body = byId("rows");\n const paged = feedPage(state.feedPageSize);\n const shown = paged.rows;\n let index = 0;\n const place = (node) => {\n const current = body.childNodes[index] ?? null;\n if (current !== node) body.insertBefore(node, current);\n index++;\n };\n for (const row of shown) {\n const id = row.entry.requestId;\n const open = state.open.has(id);\n let cached = rendered.get(id);\n if (cached === void 0 || cached.rev !== row.rev || cached.open !== open) {\n cached = {\n row: buildRow(row.entry, open),\n detail: open ? buildDetail(row.entry) : void 0,\n rev: row.rev,\n open\n };\n rendered.set(id, cached);\n }\n place(cached.row);\n if (cached.detail !== void 0) place(cached.detail);\n }\n while (body.childNodes.length > index) body.removeChild(body.childNodes[index]);\n if (rendered.size > shown.length * 2 + 100) {\n const live = new Set(shown.map((row) => row.entry.requestId));\n for (const id of Array.from(rendered.keys())) if (!live.has(id)) rendered.delete(id);\n }\n const total = state.rows.length;\n const matching = matchingCount();\n $("empty").hidden = total > 0;\n $("feed-count").textContent = matching === total ? `${n(total)} in this window` : `${n(matching)} of ${n(total)}`;\n drawPager(paged);\n const skipped = Math.max(0, (state.snapshot?.skipped ?? 0) + state.laggedDrops - state.caughtUp);\n const note = $("feed-skipped");\n note.hidden = skipped === 0;\n note.textContent = `${n(skipped)} not streamed`;\n byId("feed-load-skipped").hidden = skipped === 0;\n note.title = state.laggedDrops > 0 ? `${n(state.laggedDrops)} were skipped because this connection could not keep up, and the rest by the rate cap. All of them are still in the window, the preview and the export.` : "Entries the rate cap kept off this stream. They are still in the window, the preview and the export \u2014 raise maxEventsPerSecond to see them live.";\n }\n function resetFeedCache() {\n rendered.clear();\n }\n function buildRow(entry, open) {\n const out = outcome(entry);\n const tr = el("tr", `row a-${entry.downgradedFrom !== void 0 ? "guard" : out}${open ? " open" : ""}`);\n tr.appendChild(el("td", "num mono tnum when", clockTime(entry.at)));\n const request = el("td", "edge req");\n const toggle = el("button", "row-toggle", `${entry.method} ${entry.path}`);\n toggle.type = "button";\n toggle.setAttribute("aria-expanded", String(open));\n toggle.setAttribute("aria-label", `${entry.method} ${entry.path}, ${entry.verdict}. Evidence.`);\n toggle.dataset["request"] = entry.requestId;\n request.appendChild(toggle);\n const ua = el("span", "ua");\n if (SECTIONS.actors) {\n const actorLink = el("a", null, entry.actor);\n actorLink.href = "#actor";\n actorLink.title = "Show everything from this actor";\n actorLink.addEventListener("click", (event) => {\n event.preventDefault();\n event.stopPropagation();\n openActor(entry.actor);\n });\n ua.appendChild(actorLink);\n ua.appendChild(document.createTextNode(` \xB7 ${entry.userAgent}`));\n } else {\n ua.appendChild(document.createTextNode(`${entry.actor} \xB7 ${entry.userAgent}`));\n }\n ua.title = `${entry.actor} \xB7 ${entry.userAgent}`;\n request.appendChild(ua);\n tr.appendChild(request);\n const verdictCell = el("td");\n const [badgeClass, badgeLabel] = verdictBadge(entry);\n verdictCell.appendChild(el("span", `badge ${badgeClass}`, badgeLabel));\n if (entry.identity !== void 0) verdictCell.appendChild(el("span", "sub", entry.identity));\n tr.appendChild(verdictCell);\n tr.appendChild(el("td", "num mono tnum", entry.certain ? "proven" : String(entry.score)));\n const actionCell = el("td");\n if (entry.action !== void 0) {\n const kind = out === "deny" ? "act-deny" : out === "mitigate" ? "act-mitigate" : out === "allow" ? "act-allow" : "act-tag";\n actionCell.appendChild(el("span", `act ${kind}`, entry.action));\n if (entry.rule !== void 0) {\n const ruleLabel = el("span", "sub", entry.rule);\n ruleLabel.title = entry.rule;\n actionCell.appendChild(ruleLabel);\n }\n if (entry.downgradedFrom !== void 0) actionCell.appendChild(el("span", "guard", `guard stopped ${entry.downgradedFrom}`));\n } else {\n actionCell.appendChild(el("span", "sub", "assessed only"));\n }\n tr.appendChild(actionCell);\n tr.appendChild(el("td", "num mono tnum", entry.durationMs.toFixed(2)));\n tr.dataset["request"] = entry.requestId;\n const flip = () => {\n if (state.open.has(entry.requestId)) state.open.delete(entry.requestId);\n else state.open.add(entry.requestId);\n app.drawNow();\n rootNode().querySelector(`button.row-toggle[data-request="${cssEscape(entry.requestId)}"]`)?.focus();\n };\n toggle.addEventListener("click", (event) => {\n event.stopPropagation();\n flip();\n });\n tr.addEventListener("click", flip);\n return tr;\n }\n function buildDetail(entry) {\n const tr = el("tr", "detail");\n const cell = el("td");\n cell.colSpan = 6;\n if (!SECTIONS.evidence) {\n cell.appendChild(\n el(\n "div",\n "ev-meta",\n "The evidence section is switched off on this dashboard, so the reasons behind this verdict are not sent to it. What fired, and why, is on a dashboard that has `sections: { evidence: true }`."\n )\n );\n } else if (entry.evidence.length === 0) {\n cell.appendChild(\n el(\n "div",\n "ev-meta",\n entry.bypass !== void 0 ? `Detection was skipped for this request: ${entry.bypass}.` : "No detector produced any evidence. This is what ordinary traffic looks like."\n )\n );\n } else {\n const list = el("div", "ev");\n for (const item of entry.evidence) {\n const row = el("div", `ev-item${item.direction === "human" ? " human" : ""}`);\n row.appendChild(el("div", `tier t-${item.certainty}`, item.certainty));\n const body = el("div");\n body.appendChild(el("div", null, item.summary));\n let meta = `${item.detector} \xB7 points to ${item.direction}`;\n if (item.family !== void 0) meta += ` \xB7 family \u201C${item.family}\u201D, counted once with its siblings`;\n body.appendChild(el("div", "ev-meta", meta));\n if (item.deterministicBasis !== void 0) body.appendChild(el("div", "basis", item.deterministicBasis));\n row.appendChild(body);\n list.appendChild(row);\n }\n cell.appendChild(list);\n }\n for (const failure of entry.failures) {\n cell.appendChild(el("div", "ev-meta", `Detector ${failure.detector} ${failure.reason}: ${failure.message}`));\n }\n if (entry.downgradeReason !== void 0) cell.appendChild(el("div", "basis", `Guard: ${entry.downgradeReason}`));\n const queryNames = Object.keys(entry.query);\n if (queryNames.length > 0) {\n const queryTable = el("table", "hdr");\n for (const name of queryNames) {\n const value = entry.query[name] ?? "";\n const row = el("tr");\n row.appendChild(el("td", "n", `?${name}`));\n row.appendChild(el("td", `v${value === "[redacted]" ? " red" : ""}`, value));\n queryTable.appendChild(row);\n }\n cell.appendChild(queryTable);\n }\n if (entry.headers !== void 0 && entry.headers.length > 0) {\n const table = el("table", "hdr");\n for (const [name, value] of entry.headers) {\n const row = el("tr");\n row.appendChild(el("td", "n", `${name}:`));\n row.appendChild(el("td", `v${value === "[redacted]" ? " red" : ""}`, value));\n table.appendChild(row);\n }\n cell.appendChild(table);\n }\n const tools = el("div", "tools");\n if (SECTIONS.evidence) {\n tools.appendChild(copyButton("Copy replay line", () => replayLine(entry)));\n tools.appendChild(downloadButton("Download replay line", () => replayLine(entry), `request-${entry.requestId}.jsonl`));\n tools.appendChild(copyButton("Copy corpus case", () => corpusCase(entry)));\n }\n if (SECTIONS.policy) {\n const draft2 = el("button", null, "Draft a rule");\n draft2.title = "Start a rule from this request, in the policy editor";\n draft2.addEventListener("click", (event) => {\n event.stopPropagation();\n void draftIntoEditor(entry);\n });\n tools.appendChild(draft2);\n }\n if (SECTIONS.actors) {\n const actorButton = el("button", null, "Show this actor");\n actorButton.addEventListener("click", (event) => {\n event.stopPropagation();\n openActor(entry.actor);\n });\n tools.appendChild(actorButton);\n }\n cell.appendChild(tools);\n const foot = el("div", "detail-foot");\n foot.appendChild(el("span", null, clockTime(entry.at)));\n foot.appendChild(el("span", null, `actor ${entry.actor}`));\n if (entry.rule !== void 0) foot.appendChild(el("span", null, `rule \u201C${entry.rule}\u201D`));\n foot.appendChild(el("span", null, `assessed in ${entry.durationMs.toFixed(3)}ms`));\n foot.appendChild(el("span", "mono", entry.requestId));\n cell.appendChild(foot);\n tr.appendChild(cell);\n return tr;\n }\n function copyButton(label2, produce) {\n const button = el("button", null, label2);\n button.addEventListener("click", (event) => {\n event.stopPropagation();\n const text = produce();\n const done = () => {\n button.textContent = "Copied";\n setTimeout(() => {\n button.textContent = label2;\n }, 1200);\n };\n if (navigator.clipboard?.writeText !== void 0) navigator.clipboard.writeText(text).then(done, () => showText(text));\n else showText(text);\n });\n return button;\n }\n function downloadButton(label2, produce, filename) {\n const button = el("button", null, label2);\n button.addEventListener("click", (event) => {\n event.stopPropagation();\n download(`${produce()}\n`, filename, "application/x-ndjson");\n });\n return button;\n }\n function showText(text) {\n const box = el("pre", "code", text);\n const host = $("policy-result");\n host.hidden = false;\n host.className = "result";\n clear(host);\n host.appendChild(el("div", "ev-meta", "Copying needs a secure context; here is the text."));\n host.appendChild(box);\n const selection = getSelection();\n if (selection !== null) {\n const range = document.createRange();\n range.selectNodeContents(box);\n selection.removeAllRanges();\n selection.addRange(range);\n }\n }\n\n // src/dashboard/client/stream.ts\n var source;\n function connectStream() {\n if (!SECTIONS.feed) {\n $("dot").className = "dot";\n $("conn").textContent = "feed off";\n return;\n }\n if (source !== void 0) return;\n source = new EventSource(`${API}/api/stream`);\n source.addEventListener("open", () => {\n $("dot").className = "dot on";\n $("conn").textContent = "live";\n });\n source.addEventListener("sync", (event) => {\n const detail = JSON.parse(event.data);\n if (!detail.replace) return;\n clearFeed();\n resetFeedCache();\n });\n source.addEventListener("entry", (event) => {\n ingest(JSON.parse(event.data));\n if (state.paused) {\n state.bufferedWhilePaused++;\n $("pause").textContent = `Resume (${state.bufferedWhilePaused})`;\n }\n app.draw();\n });\n source.addEventListener("update", (event) => {\n ingest(JSON.parse(event.data));\n app.draw();\n });\n source.addEventListener("reset", () => {\n clearFeed();\n resetFeedCache();\n app.draw();\n });\n source.addEventListener("lagged", (event) => {\n const detail = JSON.parse(event.data);\n state.laggedDrops += detail.dropped;\n app.draw();\n });\n source.addEventListener("stats", (event) => {\n state.snapshot = JSON.parse(event.data);\n app.draw();\n });\n source.addEventListener("error", () => {\n $("dot").className = "dot off";\n $("conn").textContent = "reconnecting\u2026";\n });\n }\n async function loadInitialSnapshot() {\n try {\n state.snapshot = await getJson("/api/stats");\n app.drawNow();\n } catch {\n }\n }\n\n // src/dashboard/client/panels.ts\n var paintedSnapshot;\n function drawTiles(force = false) {\n if (!SECTIONS.statistics) return;\n const box = $("tiles");\n const snapshot = state.snapshot;\n if (snapshot === void 0) return;\n if (!force && paintedSnapshot === snapshot) return;\n paintedSnapshot = snapshot;\n const metrics = snapshot.metrics;\n clear(box);\n if (metrics === void 0) {\n box.appendChild(el("div", "note", "Counters are switched off on this handler (metrics: false). The live feed still works."));\n return;\n }\n const actions = metrics.actions;\n const denied = actions.block + actions.drop + actions.redirect;\n const mitigated = actions.challenge + actions["rate-limit"] + actions.delay;\n const served = actions.allow + actions.tag + actions.log;\n const total = metrics.requests;\n const unremarkable = metrics.verdicts.unknown + metrics.verdicts.human;\n const provenBotCount = provenBots(metrics.verdicts);\n const actors = SECTIONS.registry ? { tab: "actors", label: "Actors" } : void 0;\n const tiles = [\n ["", n(total), "Requests", "since start", void 0],\n ["proven", n(provenBotCount), "Proven bots", `${pct(provenBotCount, total)} of traffic`, void 0],\n ["warn", n(metrics.verdicts["suspected-bot"]), "Suspected", "never denied alone", void 0],\n ["", n(unremarkable), "Unremarkable", `${pct(unremarkable, total)} of traffic`, void 0],\n ["warn", n(metrics.downgrades), "Guard stops", metrics.downgrades > 0 ? "a rule over-reached" : "no rule overreached", void 0],\n ["crit", n(denied), "Denied", `${pct(denied, total)} of traffic`, void 0],\n [\n "",\n n(mitigated),\n "Mitigated",\n metrics.challenges.issued > 0 ? `${n(metrics.challenges.solved)} of ${n(metrics.challenges.issued)} challenges solved` : "challenged or limited",\n void 0\n ],\n ["good", n(served), "Served", `${pct(served, total)} of traffic`, void 0],\n ["", n(metrics.actorsTracked), "Actors tracked", "in the registry now", actors]\n ];\n for (const [kind, value, key, sub, goes] of tiles) {\n const tile = goes === void 0 ? el("div", `tile ${kind}`) : el("button", `tile ${kind} go`);\n tile.appendChild(el("div", "v tnum", value));\n tile.appendChild(el("div", "k", key));\n tile.appendChild(el("div", "s", sub));\n if (goes !== void 0) {\n tile.type = "button";\n tile.appendChild(el("span", "sr-only", `. Show the ${goes.label} screen`));\n tile.addEventListener("click", () => app.showTab(goes.tab, { replace: false }));\n }\n box.appendChild(tile);\n }\n }\n function drawChips() {\n const snapshot = state.snapshot;\n if (snapshot === void 0) return;\n const box = $("chips");\n clear(box);\n const facts = [\n // Which process this is, first, because everything after it is a fact about this\n // process and nothing else. Behind a load balancer there are as many of these\n // dashboards as there are pods, each showing its own share of the traffic.\n ["instance", snapshot.instance],\n ["guard", snapshot.policy.falsePositivePolicy],\n ["suspect at", String(snapshot.policy.suspectThreshold)]\n ];\n if (SECTIONS.statistics) facts.push(["detectors", String(snapshot.detectors.length)]);\n if (SECTIONS.policy) facts.push(["rules", String(snapshot.rules.length)]);\n facts.push(["uptime", uptime(snapshot.now - snapshot.startedAt)]);\n for (const [label2, value] of facts) {\n const fact = el("span");\n fact.appendChild(document.createTextNode(`${label2} `));\n fact.appendChild(el("b", null, value));\n box.appendChild(fact);\n }\n }\n function updateWindowLabels() {\n const label2 = windowLabel(state.rows.length, oldestAt(), Date.now());\n for (const node of Array.from(rootNode().querySelectorAll(".win"))) node.textContent = label2;\n }\n function drawLivePanels() {\n if (!SECTIONS.statistics) return;\n const totals = aggregate(state.rows);\n drawBars($("live-detectors"), totals.detectors, "Nothing has fired in this window.");\n if (SECTIONS.actors) drawBars($("live-actors"), totals.actors, "No traffic in this window.");\n }\n function drawStatsPanels() {\n const snapshot = state.snapshot;\n if (snapshot === void 0) return;\n const metrics = snapshot.metrics;\n if (metrics !== void 0) {\n drawBars($("stat-verdicts"), pairs(metrics.verdicts), "Nothing assessed yet.");\n drawBars($("stat-actions"), pairs(metrics.actions), "No decisions yet.");\n drawBars($("stat-classes"), pairs(metrics.botClasses), "Nothing classified yet.");\n drawBars($("stat-detectors"), pairs(metrics.detectorFirings), "No detector has produced evidence yet.");\n const challenges = $("stat-challenges");\n clear(challenges);\n if (!snapshot.policy.challengeEnabled) {\n challenges.appendChild(el("div", "note", "No challenge is configured, so a rule asking for one degrades to a tag. Set challenge.secrets to enable it."));\n } else {\n const funnel = [\n ["Issued", n(metrics.challenges.issued)],\n ["Solved", n(metrics.challenges.solved)],\n ["Rejected", n(metrics.challenges.rejected)],\n ["Solve rate", metrics.challenges.issued > 0 ? pct(metrics.challenges.solved, metrics.challenges.issued) : "\u2014"]\n ];\n for (const [key, value] of funnel) challenges.appendChild(statRow(key, value));\n }\n const health = $("stat-health");\n clear(health);\n const bypassed = metrics.bypassed.allowlist + metrics.bypassed["ignored-path"];\n const rows = [\n ["Requests assessed", n(metrics.requests)],\n ["Bypassed \u2014 allowlist", n(metrics.bypassed.allowlist)],\n ["Bypassed \u2014 ignored path", n(metrics.bypassed["ignored-path"])],\n ["Detection ran on", pct(metrics.requests - bypassed, metrics.requests)],\n ["Actors tracked", n(metrics.actorsTracked)],\n ["Guard stops", n(metrics.downgrades)]\n ];\n const failures = pairs(metrics.detectorFailures);\n for (const [detector, count] of failures) rows.push([`Detector failures \u2014 ${detector}`, n(count)]);\n for (const [key, value] of rows) health.appendChild(statRow(key, value));\n if (failures.length === 0) health.appendChild(el("div", "note", "No detector has thrown or timed out."));\n }\n const totals = aggregate(state.rows);\n if (SECTIONS.actors) drawBars($("stat-identities"), totals.identities, "No client has named itself in this window.");\n drawBars($("stat-paths"), totals.paths, "No traffic in this window.");\n drawBars($("stat-denied-paths"), totals.deniedPaths, "Nothing has been denied in this window.");\n drawBars($("stat-guard"), totals.guardStops, "No rule has asked for more than its evidence supports.");\n drawBars($("stat-bypassed"), totals.bypassed, "Nothing bypassed detection.");\n drawRuleHits(totals);\n const list = $("stat-detector-list");\n clear(list);\n $("detector-count").textContent = `${snapshot.detectors.length} installed`;\n for (const detector of snapshot.detectors) {\n const row = el("div", "det");\n const left = el("div");\n left.appendChild(el("div", "mono", detector.id));\n left.appendChild(el("div", "d", detector.description));\n row.appendChild(left);\n const fires = metrics?.detectorFirings[detector.id] ?? 0;\n const timing = metrics?.detectorTimings[detector.id];\n let right = `${n(fires)} \xB7 ${detector.cost} \xB7 ${detector.stage}`;\n if (timing !== void 0 && timing.count > 0) right += ` \xB7 ${ms(timing.totalMs / timing.count)} avg`;\n row.appendChild(el("div", "n", right));\n list.appendChild(row);\n }\n }\n function statRow(key, value) {\n const line = el("div", "stat-row");\n line.appendChild(el("span", "k", key));\n line.appendChild(el("span", "v", value));\n return line;\n }\n function drawRuleHits(totals) {\n const target = $("stat-rule-hits");\n clear(target);\n const rules = state.snapshot?.rules ?? [];\n if (rules.length === 0) {\n target.appendChild(el("div", "note", "No rules configured."));\n return;\n }\n let max = 1;\n for (const rule of rules) max = Math.max(max, totals.ruleHits.get(rule) ?? 0);\n for (const rule of rules) {\n const value = totals.ruleHits.get(rule) ?? 0;\n const bar = el("div", `bar${value === 0 ? " dead" : ""}`);\n const track = el("div", "track");\n if (value > 0) {\n const fill = el("div", "fill");\n fill.style.width = `${Math.max(2, Math.round(value / max * 100))}%`;\n track.appendChild(fill);\n }\n track.appendChild(el("div", "lbl", rule));\n bar.appendChild(track);\n bar.appendChild(el("div", "v tnum", value === 0 ? "never" : n(value)));\n target.appendChild(bar);\n }\n }\n function drawAudit() {\n const snapshot = state.snapshot;\n if (snapshot === void 0) return;\n const body = $("audit-body");\n const checks = $("audit-checks");\n clear(body);\n clear(checks);\n const audit = snapshot.audit;\n if (audit === void 0) {\n $("audit-spans").textContent = "";\n body.appendChild(\n el(\n "div",\n "note",\n "The traffic audit is switched off on this handler (audit: false). It watches the shape of your traffic rather than any one request \u2014 a spike in automation, a collapse in human traffic, a policy suddenly denying far more than usual."\n )\n );\n return;\n }\n const current = audit.window;\n const baseline = audit.baseline;\n $("audit-spans").textContent = `last ${rangeLabel(current.spanMs)} against the ${rangeLabel(baseline.spanMs)} before`;\n const rows = [\n ["Requests", n(current.requests), n(baseline.requests)],\n ["Rate", `${current.rate.toFixed(1)}/min`, `${baseline.rate.toFixed(1)}/min`],\n ["Bot share", `${Math.round(current.botShare * 100)}%`, `${Math.round(baseline.botShare * 100)}%`],\n ["Bots", n(current.bots), n(baseline.bots)],\n ["Humans", n(current.humans), n(baseline.humans)],\n ["Denials", n(current.denials), n(baseline.denials)],\n ["Challenges", n(current.challenges), n(baseline.challenges)],\n ["Guard stops", n(current.downgrades), n(baseline.downgrades)],\n ["Detector failures", n(current.failures), n(baseline.failures)],\n ["Bypassed", n(current.bypassed), n(baseline.bypassed)]\n ];\n for (const [key, now, was] of rows) {\n const line = el("div", "stat-row");\n line.appendChild(el("span", "k", key));\n const values = el("span", "v");\n values.appendChild(el("b", null, now));\n values.appendChild(el("span", "was", ` was ${was}`));\n line.appendChild(values);\n body.appendChild(line);\n }\n if (audit.checks.length === 0) {\n checks.appendChild(el("div", "note", "No checks are installed, so nothing here will ever raise an anomaly."));\n return;\n }\n for (const check of audit.checks) {\n const row = el("div", "det");\n const left = el("div");\n left.appendChild(el("div", "mono", check.id));\n left.appendChild(el("div", "d", check.description));\n row.appendChild(left);\n checks.appendChild(row);\n }\n }\n function drawNoticeBadge() {\n const notices = state.snapshot?.notices ?? [];\n const badge = $("notice-badge");\n badge.hidden = notices.length === 0 || !SECTIONS.notices;\n badge.textContent = String(notices.length);\n }\n function drawNotices() {\n const box = $("stat-notices");\n const notices = state.snapshot?.notices ?? [];\n clear(box);\n $("notice-count").textContent = notices.length > 0 ? `${notices.length} total` : "";\n if (notices.length === 0) {\n box.appendChild(el("div", "note", "Nothing to report: no startup warnings, no detector errors."));\n return;\n }\n for (const notice of notices.slice().reverse().slice(0, 40)) {\n const row = el("div", `notice ${notice.kind}`);\n const when = el("div", "when");\n when.appendChild(el("div", "tag", notice.kind));\n when.appendChild(el("div", null, clockTime(notice.at)));\n row.appendChild(when);\n const body = el("div");\n body.appendChild(el("div", null, notice.message));\n if (notice.source !== void 0) body.appendChild(el("div", "ev-meta", notice.source));\n row.appendChild(body);\n box.appendChild(row);\n }\n }\n function drawChanges() {\n if (!SECTIONS.changes) return;\n const box = $("stat-changes");\n const changes = state.snapshot?.changes ?? [];\n clear(box);\n $("change-count").textContent = changes.length > 0 ? `${changes.length} this run` : "";\n if (changes.length === 0) {\n box.appendChild(el("div", "note", "Nothing has been changed at runtime. Rules, guard and ranges are as the code that built this handler left them."));\n return;\n }\n for (const change of changes.slice().reverse()) {\n const row = el("div", "notice");\n const when = el("div", "when");\n when.appendChild(el("div", "tag", change.kind));\n when.appendChild(el("div", null, clockTime(change.at)));\n row.appendChild(when);\n const body = el("div");\n body.appendChild(el("div", null, change.summary));\n body.appendChild(el("div", "ev-meta", change.by === void 0 || change.by === "" ? "by an unnamed viewer \u2014 this listener\'s auth carries no identity" : `by ${change.by}`));\n row.appendChild(body);\n box.appendChild(row);\n }\n }\n function drawPeers() {\n const box = $("peers");\n if (BOOT.peers.length === 0) {\n box.hidden = true;\n return;\n }\n if (box.childElementCount > 0) return;\n box.appendChild(el("span", "hint", "also:"));\n for (const peer of BOOT.peers) {\n const link = el("a", "linkbtn", peer.label);\n link.href = peer.href;\n link.rel = "noreferrer noopener";\n box.appendChild(link);\n }\n }\n\n // src/dashboard/client/charts.ts\n var BUCKETS = 60;\n var LATENCY_BOUNDS = [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 25, 50, 100];\n var SCORE_BOUNDS = 10;\n function positionTip(tip, host, clientX, boxLeft) {\n tip.style.opacity = "1";\n tip.style.left = `${Math.min(host.clientWidth - 150, Math.max(4, clientX - boxLeft - 60))}px`;\n tip.style.top = "14px";\n }\n function tipRow(label2, value, colour) {\n const line = el("div", "r");\n const left = el("em");\n if (colour !== void 0) {\n const swatch = el("span", "swatch");\n swatch.style.background = colour;\n left.appendChild(swatch);\n }\n left.appendChild(document.createTextNode(label2));\n line.appendChild(left);\n line.appendChild(el("b", null, value));\n return line;\n }\n function timeline() {\n const bucketMs = state.rangeMs / BUCKETS;\n const now = Date.now();\n const start2 = now - state.rangeMs;\n const buckets = [];\n for (let i = 0; i < BUCKETS; i++) buckets.push({ at: start2 + i * bucketMs, served: 0, mitigated: 0, denied: 0, total: 0 });\n for (const { entry } of state.rows) {\n const index = Math.floor((entry.at - start2) / bucketMs);\n if (index < 0 || index >= BUCKETS) continue;\n const bucket = buckets[index];\n if (bucket === void 0) continue;\n bucket.total++;\n const out = outcome(entry);\n if (out === "deny") bucket.denied++;\n else if (out === "mitigate") bucket.mitigated++;\n else bucket.served++;\n }\n return buckets;\n }\n function drawTraffic() {\n const host = $("traffic-chart");\n const svg = $("traffic");\n const width = Math.max(320, host.clientWidth - 30);\n const height = 190;\n const padBottom = 26;\n const markerRow = 8;\n const plot = height - padBottom - markerRow;\n const buckets = timeline();\n const now = Date.now();\n const start2 = now - state.rangeMs;\n let peak = 1;\n for (const bucket of buckets) if (bucket.total > peak) peak = bucket.total;\n const max = Math.max(2, Math.ceil(peak / 2) * 2);\n clear(svg);\n svg.setAttribute("viewBox", `0 0 ${width} ${height}`);\n svg.setAttribute("height", String(height));\n const s1 = css("--s1");\n const s2 = css("--s2");\n const crit = css("--crit");\n const grid = css("--grid");\n const muted = css("--muted");\n const step = width / BUCKETS;\n const barWidth = Math.max(2, step - 2);\n for (const fraction of [0, 0.5, 1]) {\n const y = markerRow + plot - fraction * plot;\n svg.appendChild(svgEl("line", { class: "gridline", x1: 0, x2: width, y1: y, y2: y, stroke: grid, "stroke-width": 1 }));\n if (fraction > 0) svg.appendChild(svgText({ x: 2, y: y - 3, fill: muted, "font-size": 10 }, Math.round(max * fraction)));\n }\n buckets.forEach((bucket, index) => {\n const x = index * step + 1;\n const servedHeight = bucket.served / max * plot;\n const mitigatedHeight = bucket.mitigated / max * plot;\n let y = markerRow + plot;\n if (servedHeight > 0) {\n y -= servedHeight;\n svg.appendChild(svgEl("rect", { x, y, width: barWidth, height: servedHeight, fill: s1, rx: 2 }));\n }\n if (mitigatedHeight > 0) {\n y -= mitigatedHeight + (servedHeight > 0 ? 2 : 0);\n svg.appendChild(svgEl("rect", { x, y: Math.max(markerRow, y), width: barWidth, height: mitigatedHeight, fill: s2, rx: 2 }));\n }\n if (bucket.denied > 0) svg.appendChild(svgEl("rect", { x, y: 0, width: barWidth, height: 5, fill: crit, rx: 2 }));\n });\n svg.appendChild(svgEl("line", { x1: 0, x2: width, y1: markerRow + plot, y2: markerRow + plot, stroke: css("--line"), "stroke-width": 1 }));\n const span = rangeLabel(state.rangeMs);\n const labels = [\n [0, `${span} ago`],\n [BUCKETS / 2, rangeLabel(state.rangeMs / 2)],\n [BUCKETS - 1, "now"]\n ];\n for (const [position, text] of labels) {\n svg.appendChild(svgText({ x: Math.min(width - 26, Math.max(0, position * step)), y: height - 8, fill: muted, "font-size": 10 }, text));\n }\n const changes = (state.snapshot?.changes ?? []).filter((change) => change.at >= start2 && change.at <= now);\n const markColour = css("--proven-text");\n for (const change of changes) {\n const x = (change.at - start2) / state.rangeMs * width;\n svg.appendChild(svgEl("line", { x1: x, x2: x, y1: 0, y2: markerRow + plot, stroke: markColour, "stroke-width": 1.5, "stroke-dasharray": "3 2", opacity: 0.85 }));\n const dot = svgEl("circle", { cx: x, cy: 3, r: 3, fill: markColour });\n const title = svgEl("title");\n title.textContent = `${clockTime(change.at)} \xB7 ${change.kind}: ${change.summary}${change.by === void 0 ? "" : ` (by ${change.by})`}`;\n dot.appendChild(title);\n svg.appendChild(dot);\n }\n const hover = svgEl("rect", { x: 0, y: 0, width: 0, height: markerRow + plot, fill: css("--ink"), opacity: 0.06 });\n svg.appendChild(hover);\n const tip = $("traffic-tip");\n host.onmousemove = (event) => {\n const box = svg.getBoundingClientRect();\n const index = Math.floor((event.clientX - box.left) / box.width * BUCKETS);\n const bucket = buckets[index];\n if (bucket === void 0) {\n tip.style.opacity = "0";\n hover.setAttribute("width", "0");\n return;\n }\n hover.setAttribute("x", String(index * step));\n hover.setAttribute("width", String(step));\n clear(tip);\n tip.appendChild(el("div", "t", `${clockTime(bucket.at)} \xB7 ${Math.round(state.rangeMs / BUCKETS / 1e3)}s`));\n tip.appendChild(tipRow("Served", n(bucket.served), s1));\n tip.appendChild(tipRow("Mitigated", n(bucket.mitigated), s2));\n tip.appendChild(tipRow("Denied", n(bucket.denied), crit));\n positionTip(tip, host, event.clientX, box.left);\n };\n host.onmouseleave = () => {\n tip.style.opacity = "0";\n hover.setAttribute("width", "0");\n };\n const total = buckets.reduce((sum, bucket) => sum + bucket.total, 0);\n $("traffic-window").textContent = `last ${rangeLabel(state.rangeMs)}`;\n const served = buckets.reduce((sum, bucket) => sum + bucket.served, 0);\n const mitigated = buckets.reduce((sum, bucket) => sum + bucket.mitigated, 0);\n const denied = buckets.reduce((sum, bucket) => sum + bucket.denied, 0);\n const busiest = buckets.reduce((best, bucket) => bucket.total > best.total ? bucket : best, buckets[0] ?? { at: now, total: 0, served: 0, mitigated: 0, denied: 0 });\n $("traffic-alt").textContent = `Traffic over the last ${rangeLabel(state.rangeMs)}: ${n(total)} requests \u2014 ${n(served)} served, ${n(mitigated)} mitigated, ${n(denied)} denied. ` + (total === 0 ? "No traffic in this range." : `Busiest ${Math.round(state.rangeMs / BUCKETS / 1e3)}-second interval: ${n(busiest.total)} requests at ${clockTime(busiest.at)}.`) + (changes.length === 0 ? "" : ` ${n(changes.length)} runtime change${changes.length === 1 ? "" : "s"} in this range: ${changes.map((change) => `${change.kind}, ${change.summary}`).join("; ")}.`);\n const legend = $("traffic-legend");\n clear(legend);\n legend.appendChild(el("span", null, `${n(total)} requests in the last ${rangeLabel(state.rangeMs)} \xB7`));\n const oldest = oldestAt();\n if (oldest !== void 0 && Date.now() - oldest < state.rangeMs * 0.9) {\n legend.appendChild(el("span", null, `window holds ${rangeLabel(Date.now() - oldest)} \xB7`));\n }\n for (const [label2, colour] of [\n ["Served", s1],\n ["Mitigated \u2014 challenged, limited or delayed", s2],\n ["Denied", crit]\n ]) {\n const item = el("span");\n const swatch = el("span", "swatch");\n swatch.style.background = colour;\n item.appendChild(swatch);\n item.appendChild(document.createTextNode(label2));\n legend.appendChild(item);\n }\n if (changes.length > 0) {\n const item = el("span");\n item.appendChild(el("span", "mark"));\n item.appendChild(document.createTextNode(`${n(changes.length)} runtime change${changes.length === 1 ? "" : "s"} \u2014 hover a marker`));\n legend.appendChild(item);\n }\n }\n function differences(cumulative) {\n const counts = [];\n for (let i = 0; i < cumulative.length; i++) counts.push((cumulative[i] ?? 0) - (i > 0 ? cumulative[i - 1] ?? 0 : 0));\n return counts;\n }\n function drawScores() {\n const host = $("score-chart");\n const svg = $("scores");\n clear(svg);\n const metrics = state.snapshot?.metrics;\n const fromRun = state.scoreScope === "run" && metrics !== void 0;\n let buckets;\n let scored;\n let proven;\n if (fromRun && metrics !== void 0) {\n buckets = differences(metrics.scores.buckets);\n scored = metrics.scores.count;\n proven = metrics.proven;\n } else {\n buckets = new Array(SCORE_BOUNDS).fill(0);\n scored = 0;\n proven = 0;\n for (const { entry } of state.rows) {\n if (entry.bypass !== void 0) continue;\n if (entry.certain) {\n proven++;\n continue;\n }\n const index = Math.min(SCORE_BOUNDS - 1, Math.floor(entry.score / 10));\n buckets[index] = (buckets[index] ?? 0) + 1;\n scored++;\n }\n }\n const width = Math.max(280, host.clientWidth - 30);\n const height = 190;\n const padBottom = 30;\n const plot = height - padBottom;\n let max = 1;\n for (const value of buckets) if (value > max) max = value;\n svg.setAttribute("viewBox", `0 0 ${width} ${height}`);\n svg.setAttribute("height", String(height));\n const fill = css("--s1");\n const muted = css("--muted");\n const grid = css("--grid");\n const crit = css("--crit");\n svg.appendChild(svgEl("line", { class: "gridline", x1: 0, x2: width, y1: plot, y2: plot, stroke: grid, "stroke-width": 1 }));\n const step = width / SCORE_BOUNDS;\n buckets.forEach((value, index) => {\n const barHeight = value / max * (plot - 8);\n const barWidth = Math.max(2, Math.min(step - 6, 56));\n if (barHeight > 0) {\n svg.appendChild(svgEl("rect", { x: index * step + (step - barWidth) / 2, y: plot - barHeight, width: barWidth, height: barHeight, fill, rx: 3 }));\n }\n svg.appendChild(svgText({ x: index * step + 1, y: height - 14, fill: muted, "font-size": 9.5 }, index * 10));\n });\n const threshold = state.snapshot?.policy.suspectThreshold ?? 60;\n const x = threshold / 100 * width;\n svg.appendChild(svgEl("line", { x1: x, x2: x, y1: 0, y2: plot, stroke: crit, "stroke-width": 2, "stroke-dasharray": "4 3" }));\n svg.appendChild(svgText({ x: Math.min(width - 92, x + 5), y: 11, fill: crit, "font-size": 10 }, `suspect at ${threshold}`));\n svg.appendChild(svgText({ x: 0, y: height - 2, fill: muted, "font-size": 10 }, "score (probabilistic requests only)"));\n const tip = $("score-tip");\n host.onmousemove = (event) => {\n const box = svg.getBoundingClientRect();\n const index = Math.floor((event.clientX - box.left) / box.width * SCORE_BOUNDS);\n const value = buckets[index];\n if (value === void 0) {\n tip.style.opacity = "0";\n return;\n }\n clear(tip);\n tip.appendChild(el("div", "t", `score ${index * 10}\u2013${index * 10 + 9}`));\n tip.appendChild(tipRow("requests", n(value)));\n tip.appendChild(tipRow("share", pct(value, scored)));\n positionTip(tip, host, event.clientX, box.left);\n };\n host.onmouseleave = () => {\n tip.style.opacity = "0";\n };\n let over = 0;\n for (let bucket = 0; bucket < SCORE_BOUNDS; bucket++) if (bucket * 10 >= threshold) over += buckets[bucket] ?? 0;\n const legend = $("score-legend");\n clear(legend);\n legend.appendChild(el("span", null, `${n(scored)} scored \xB7 ${n(over)} at or over the threshold \xB7 ${n(proven)} proven, which carry no score`));\n $("score-alt").textContent = `Distribution of probabilistic scores ${fromRun ? "since start" : "in the retained window"}, with the suspect threshold at ${threshold}. ${n(scored)} scored requests, ${n(over)} at or over the threshold, ${n(proven)} proven and therefore unscored. ` + (scored === 0 ? "Nothing scored yet." : `By ten-point band: ${buckets.map((value, index) => `${index * 10}\u2013${index * 10 + 9}: ${n(value)}`).join(", ")}.`);\n $("score-window").textContent = fromRun ? "since start" : windowLabel(state.rows.length, oldestAt(), Date.now());\n if (state.scoreScope === "run" && metrics === void 0) {\n legend.appendChild(el("span", null, "\xB7 counters are off on this handler, so this is the retained window"));\n }\n }\n function drawLatency() {\n const host = $("latency-chart");\n const svg = $("latency");\n clear(svg);\n const metrics = state.snapshot?.metrics;\n if (metrics === void 0 || metrics.duration.count === 0) {\n $("latency-summary").textContent = "No assessments yet.";\n $("latency-alt").textContent = "Assessment latency: no assessments yet.";\n svg.setAttribute("viewBox", "0 0 100 40");\n svg.setAttribute("height", "40");\n svg.appendChild(svgText({ x: 0, y: 20, fill: css("--muted"), "font-size": 11 }, "No assessments yet."));\n return;\n }\n const cumulative = metrics.duration.buckets;\n const counts = differences(cumulative);\n const width = Math.max(280, host.clientWidth - 30);\n const height = 190;\n const padBottom = 30;\n const plot = height - padBottom;\n let max = 1;\n for (const value of counts) if (value > max) max = value;\n svg.setAttribute("viewBox", `0 0 ${width} ${height}`);\n svg.setAttribute("height", String(height));\n const fill = css("--s1");\n const muted = css("--muted");\n const grid = css("--grid");\n svg.appendChild(svgEl("line", { class: "gridline", x1: 0, x2: width, y1: plot, y2: plot, stroke: grid, "stroke-width": 1 }));\n const step = width / counts.length;\n counts.forEach((value, index) => {\n const barHeight = value / max * (plot - 6);\n if (barHeight > 0) {\n svg.appendChild(svgEl("rect", { x: index * step + 1, y: plot - barHeight, width: Math.max(2, step - 3), height: barHeight, fill, rx: 3 }));\n }\n if (index % 2 === 0) {\n svg.appendChild(svgText({ x: index * step + 1, y: height - 14, fill: muted, "font-size": 9.5 }, index < LATENCY_BOUNDS.length ? String(LATENCY_BOUNDS[index]) : "more"));\n }\n });\n svg.appendChild(svgText({ x: 0, y: height - 2, fill: muted, "font-size": 10 }, "milliseconds (upper bound of each bucket)"));\n const tip = $("latency-tip");\n host.onmousemove = (event) => {\n const box = svg.getBoundingClientRect();\n const index = Math.floor((event.clientX - box.left) / box.width * counts.length);\n const value = counts[index];\n if (value === void 0) {\n tip.style.opacity = "0";\n return;\n }\n clear(tip);\n tip.appendChild(el("div", "t", index < LATENCY_BOUNDS.length ? `\u2264 ${LATENCY_BOUNDS[index]}ms` : "over 100ms"));\n tip.appendChild(tipRow("requests", n(value)));\n tip.appendChild(tipRow("share", pct(value, metrics.duration.count)));\n positionTip(tip, host, event.clientX, box.left);\n };\n host.onmouseleave = () => {\n tip.style.opacity = "0";\n };\n const mean = metrics.duration.totalMs / metrics.duration.count;\n const p95 = percentile(cumulative, metrics.duration.count, 0.95);\n $("latency-summary").textContent = `Time spent in detection, per request \xB7 mean ${ms(mean)} \xB7 p95 ${ms(p95)} \xB7 max ${ms(metrics.duration.maxMs)}`;\n $("latency-alt").textContent = `Assessment latency since start over ${n(metrics.duration.count)} requests: mean ${ms(mean)}, 95th percentile ${ms(p95)}, maximum ${ms(metrics.duration.maxMs)}. By bucket: ${counts.map((value, index) => `${index < LATENCY_BOUNDS.length ? `up to ${LATENCY_BOUNDS[index]}ms` : "over 100ms"}: ${n(value)}`).join(", ")}.`;\n }\n function percentile(cumulative, count, fraction) {\n const target = count * fraction;\n const last = LATENCY_BOUNDS[LATENCY_BOUNDS.length - 1] ?? 100;\n for (let i = 0; i < cumulative.length; i++) {\n if ((cumulative[i] ?? 0) >= target) return i < LATENCY_BOUNDS.length ? LATENCY_BOUNDS[i] ?? last : last;\n }\n return last;\n }\n\n // src/dashboard/client/registry.ts\n var timer;\n async function loadActors() {\n if (!SECTIONS.registry) return;\n try {\n const body = await getJson(`/api/actors?limit=${state.actorsPageSize}&offset=${state.actorsPage * state.actorsPageSize}`);\n state.actors = body.actors;\n state.actorsTracked = body.tracked;\n drawActors();\n } catch {\n }\n }\n function trackActors() {\n if (timer !== void 0) clearInterval(timer);\n timer = void 0;\n if (state.tab !== "actors") return;\n void loadActors();\n timer = setInterval(() => {\n if (state.tab !== "actors" || state.paused || isConfirming()) return;\n void loadActors();\n }, 4e3);\n }\n var ACTORS_PAGE_SIZES = [25, 50, 100, 200];\n function drawActorsPager(full) {\n const page = state.actorsPage;\n const hidden = page === 0 && !full;\n const from = page * state.actorsPageSize + 1;\n const model = {\n page,\n from,\n to: from + state.actors.length - 1,\n total: state.actorsTracked,\n atStart: page === 0,\n atEnd: !full,\n go: (next) => {\n state.actorsPage = Math.max(0, next);\n void loadActors();\n },\n size: {\n current: state.actorsPageSize,\n choices: ACTORS_PAGE_SIZES,\n set: (next) => {\n state.actorsPageSize = next;\n state.actorsPage = 0;\n void loadActors();\n }\n }\n };\n for (const [id, withSize] of [\n ["actors-pager-top", true],\n ["actors-pager", false]\n ]) {\n const host = $(id);\n host.hidden = hidden;\n if (hidden) clear(host);\n else renderPager(host, model, { withSize });\n }\n }\n function drawActors() {\n if (!SECTIONS.registry) return;\n if (isConfirming()) return;\n const body = byId("actor-rows");\n clear(body);\n const actors = state.actors;\n $("actors-count").textContent = `${n(actors.length)} shown \xB7 ${n(state.actorsTracked)} tracked`;\n drawActorsPager(actors.length === state.actorsPageSize);\n byId("actors-empty").hidden = actors.length > 0;\n for (const actor of actors) {\n const row = el("tr");\n row.appendChild(el("td", "who", actor.key));\n row.appendChild(el("td", "num tnum", n(actor.requests)));\n row.appendChild(el("td", "num tnum", n(actor.recentRate)));\n row.appendChild(el("td", "num tnum", n(actor.distinctPaths)));\n const cadence = el("td", "num tnum", actor.cadenceCv === void 0 ? "\u2014" : actor.cadenceCv.toFixed(2));\n if (actor.cadenceCv !== void 0 && actor.cadenceCv < 0.15) cadence.className += " warn-text";\n row.appendChild(cadence);\n row.appendChild(el("td", "num tnum", n(actor.priorConfirmations)));\n const unsolved = el("td", "num tnum", n(actor.unsolvedChallenges));\n if (actor.unsolvedChallenges >= 3) unsolved.className += " warn-text";\n row.appendChild(unsolved);\n const stateCell = el("td");\n const tags = [];\n if (actor.cleared) tags.push("cleared as human");\n if (actor.distinctUserAgents > 1) tags.push(`${actor.distinctUserAgents} User-Agents`);\n tags.push(`first seen ${clockStamp(actor.firstSeen)}`);\n stateCell.appendChild(el("div", "tagline", tags.join(" \xB7 ")));\n row.appendChild(stateCell);\n const actions = el("td", "acts");\n const inFeed = el("button", null, "In feed");\n inFeed.title = "Show this actor\'s requests in the live feed";\n inFeed.addEventListener("click", () => {\n setSearch(`actor:${actor.key}`);\n const search = byId("search");\n search.value = state.search;\n app.showTab("live");\n app.syncUrl();\n });\n actions.appendChild(inFeed);\n for (const button of actorActions(actor.key, () => void loadActors())) actions.appendChild(button);\n row.appendChild(actions);\n body.appendChild(row);\n }\n }\n\n // src/dashboard/client/tester.ts\n function initTester() {\n if (!SECTIONS.tester) return;\n $("test-run").addEventListener("click", () => void run());\n byId("test-input").addEventListener("keydown", (event) => {\n if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {\n event.preventDefault();\n void run();\n }\n });\n }\n async function run() {\n const raw2 = byId("test-input").value;\n if (raw2.trim() === "") {\n toast("warn", "Nothing to assess", "Paste a User-Agent, a curl command, or a header block.");\n return;\n }\n const result = await postJson("/api/test", {\n raw: raw2,\n ip: byId("test-ip").value.trim(),\n url: byId("test-url").value.trim()\n });\n const box = $("test-result");\n box.hidden = false;\n clear(box);\n if (!result.ok) {\n box.className = "result bad";\n box.appendChild(el("div", null, result.error ?? "The server could not read that."));\n return;\n }\n const { entry, reason, assumed } = result.data;\n const out = outcome(entry);\n box.className = `result ${out === "deny" ? "bad" : out === "mitigate" ? "warn" : "ok"}`;\n const head = el("div");\n const [badgeClass, badgeLabel] = verdictBadge(entry);\n head.appendChild(el("span", `badge ${badgeClass}`, badgeLabel));\n head.appendChild(document.createTextNode(" "));\n head.appendChild(el("b", null, entry.action ?? "no decision"));\n if (entry.rule !== void 0) head.appendChild(el("span", "ev-meta", ` via ${entry.rule}`));\n box.appendChild(head);\n box.appendChild(el("div", "ev-meta", `${entry.certain ? "proven" : `score ${entry.score}`} \xB7 assessed in ${ms(entry.durationMs)}`));\n if (entry.downgradedFrom !== void 0) {\n box.appendChild(el("div", "guard", `The guard stopped ${entry.downgradedFrom} here.`));\n if (entry.downgradeReason !== void 0) box.appendChild(el("div", "basis", entry.downgradeReason));\n }\n if (entry.evidence.length === 0) {\n box.appendChild(el("div", "ev-meta", entry.bypass !== void 0 ? `Detection was skipped: ${entry.bypass}.` : "No detector produced any evidence."));\n } else {\n const list = el("div", "ev");\n for (const item of entry.evidence) {\n const row = el("div", `ev-item${item.direction === "human" ? " human" : ""}`);\n row.appendChild(el("div", `tier t-${item.certainty}`, item.certainty));\n const detail = el("div");\n detail.appendChild(el("div", null, item.summary));\n detail.appendChild(el("div", "ev-meta", `${item.detector} \xB7 points to ${item.direction}`));\n if (item.deterministicBasis !== void 0) detail.appendChild(el("div", "basis", item.deterministicBasis));\n row.appendChild(detail);\n list.appendChild(row);\n }\n box.appendChild(list);\n }\n box.appendChild(el("div", "ev-meta", reason));\n const notes = [...assumed, "no history: this is assessed as a first request, so cadence and crawl breadth have nothing to read"];\n box.appendChild(el("div", "assumed", `Assumed \u2014 ${notes.join("; ")}.`));\n }\n\n // src/dashboard/client/index.ts\n var TABS = [\n ["tab-live", "live", SECTIONS.feed],\n ["tab-actors", "actors", SECTIONS.registry],\n ["tab-stats", "stats", SECTIONS.statistics],\n ["tab-policy", "policy", SECTIONS.policy]\n ];\n var available = TABS.filter(([, , enabled]) => enabled);\n var FIRST = available[0]?.[1] ?? "live";\n function tabIndexOf(name) {\n const at = available.findIndex(([, tab]) => tab === name);\n return at === -1 ? 0 : at;\n }\n function isTab(value) {\n return available.some(([, name]) => name === value);\n }\n function initSkipLink() {\n if (!isEmbedded()) return;\n const skip = rootNode().querySelector("a.skip");\n if (skip === null) return;\n skip.addEventListener("click", (event) => {\n event.preventDefault();\n const target = rootNode().querySelector(`#view-${state.tab}`) ?? rootNode().querySelector("#view-live");\n if (target === null) return;\n target.tabIndex = -1;\n target.focus();\n target.scrollIntoView({ block: "start" });\n });\n }\n function initHeader() {\n const box = $("links");\n for (const link of BOOT.links) {\n const anchor = el("a", "linkbtn", link.label);\n anchor.href = link.href;\n anchor.rel = "noreferrer noopener";\n box.appendChild(anchor);\n }\n let stored = null;\n try {\n stored = localStorage.getItem("bothandler-dashboard-theme");\n } catch {\n stored = null;\n }\n if (stored === "dark" || stored === "light") themeElement().setAttribute("data-theme", stored);\n $("theme").addEventListener("click", () => {\n let current = themeElement().getAttribute("data-theme");\n if (current === null) current = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";\n const next = current === "dark" ? "light" : "dark";\n themeElement().setAttribute("data-theme", next);\n try {\n localStorage.setItem("bothandler-dashboard-theme", next);\n } catch {\n }\n drawNow();\n });\n const pause = byId("pause");\n pause.hidden = !SECTIONS.feed;\n pause.addEventListener("click", () => {\n state.paused = !state.paused;\n pause.setAttribute("aria-pressed", String(state.paused));\n pause.textContent = state.paused ? `Resume${state.bufferedWhilePaused > 0 ? ` (${state.bufferedWhilePaused})` : ""}` : "Pause";\n if (!state.paused) {\n state.bufferedWhilePaused = 0;\n drawNow();\n }\n });\n if (BOOT.allowReset) {\n const reset = byId("reset");\n reset.hidden = false;\n reset.addEventListener("click", () => {\n reset.disabled = true;\n void fetch(`${API}/api/reset`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }).then(async (response) => {\n if (!response.ok) {\n const body = await response.json().catch(() => ({}));\n toast("bad", "Reset refused", body.error ?? String(response.status));\n return;\n }\n clearFeed();\n resetFeedCache();\n drawNow();\n }).catch(() => {\n }).finally(() => {\n reset.disabled = false;\n });\n });\n }\n const header = rootNode().querySelector("header");\n if (header !== null) {\n const apply = () => {\n themeElement().style.setProperty("--header-h", `${header.getBoundingClientRect().height}px`);\n };\n apply();\n if (typeof ResizeObserver === "function") new ResizeObserver(apply).observe(header);\n else addEventListener("resize", apply);\n }\n }\n function syncUrl(replace = true) {\n if (isEmbedded()) return;\n const params = new URLSearchParams();\n if (state.filter !== "all") params.set("f", state.filter);\n if (state.search !== "") params.set("q", state.search);\n const query = params.toString();\n const hash = `#${state.tab}${query === "" ? "" : `?${query}`}`;\n if (location.hash === hash) return;\n history[replace ? "replaceState" : "pushState"]({ tab: state.tab }, "", hash);\n }\n function readUrl() {\n const raw2 = isEmbedded() ? "" : location.hash.slice(1);\n const split = raw2.indexOf("?");\n const name = split === -1 ? raw2 : raw2.slice(0, split);\n const params = new URLSearchParams(split === -1 ? "" : raw2.slice(split + 1));\n const filter = params.get("f") ?? "all";\n return {\n tab: isTab(name) ? name : FIRST,\n filter,\n search: params.get("q") ?? ""\n };\n }\n function showTab(name, options = {}) {\n const target = isTab(name) ? name : FIRST;\n state.tab = target;\n for (const [id, tab, enabled] of TABS) {\n const selected = tab === target;\n const node = $(id);\n node.hidden = !enabled;\n node.setAttribute("aria-selected", String(selected));\n node.tabIndex = selected ? 0 : -1;\n $(`view-${tab}`).hidden = !selected || !enabled;\n }\n if (options.focus === true) $(available[tabIndexOf(target)]?.[0] ?? "tab-live").focus();\n if (target === "policy" && state.policy === void 0) {\n void loadPolicy();\n void loadRanges();\n }\n trackActors();\n if (options.push !== false) syncUrl(options.replace !== false);\n drawNow();\n }\n function initTabs() {\n available.forEach(([id, name], index) => {\n const tab = $(id);\n tab.addEventListener("click", () => showTab(name, { replace: false }));\n tab.addEventListener("keydown", (event) => {\n let next = -1;\n if (event.key === "ArrowRight") next = (index + 1) % available.length;\n else if (event.key === "ArrowLeft") next = (index - 1 + available.length) % available.length;\n else if (event.key === "Home") next = 0;\n else if (event.key === "End") next = available.length - 1;\n if (next === -1) return;\n event.preventDefault();\n showTab(available[next]?.[1] ?? FIRST, { focus: true, replace: false });\n });\n });\n if (isEmbedded()) return;\n addEventListener("popstate", () => {\n const url = readUrl();\n state.filter = url.filter;\n setSearch(url.search);\n reflectFilterButtons();\n showTab(url.tab, { push: false });\n });\n }\n function initKeyboard() {\n eventTarget().addEventListener("keydown", ((event) => {\n const target = event.target;\n const typing = target !== null && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT");\n if (event.key === "Escape") {\n if (typing && target?.id === "search" && target.value !== "") {\n target.value = "";\n setSearch("");\n syncUrl();\n drawNow();\n return;\n }\n if (typing) {\n target?.blur();\n return;\n }\n if (state.actor !== void 0) {\n state.actor = void 0;\n drawNow();\n return;\n }\n if (state.open.size > 0) {\n state.open.clear();\n drawNow();\n }\n return;\n }\n if (typing || event.metaKey || event.ctrlKey || event.altKey) return;\n if (event.key === "/" && SECTIONS.feed) {\n event.preventDefault();\n showTab("live");\n const search = byId("search");\n search.focus();\n search.select();\n return;\n }\n const digit = ["1", "2", "3", "4"].indexOf(event.key);\n if (digit !== -1 && digit < available.length) {\n event.preventDefault();\n showTab(available[digit]?.[1] ?? FIRST, { focus: true, replace: false });\n }\n }));\n }\n function initRanges() {\n const ranges = [\n ["1m", 6e4],\n ["5m", 3e5],\n ["15m", 9e5],\n ["1h", 36e5]\n ];\n const host = $("traffic-range");\n for (const [label2, value] of ranges) {\n const button = el("button", null, label2);\n button.setAttribute("aria-pressed", String(value === state.rangeMs));\n button.addEventListener("click", () => {\n state.rangeMs = value;\n for (const other of Array.from(host.children)) other.setAttribute("aria-pressed", "false");\n button.setAttribute("aria-pressed", "true");\n drawTraffic();\n });\n host.appendChild(button);\n }\n const scopes = [\n ["since start", "run"],\n ["this window", "window"]\n ];\n const scopeHost = $("score-scope");\n for (const [label2, value] of scopes) {\n const button = el("button", null, label2);\n button.setAttribute("aria-pressed", String(value === state.scoreScope));\n button.addEventListener("click", () => {\n state.scoreScope = value;\n for (const other of Array.from(scopeHost.children)) other.setAttribute("aria-pressed", "false");\n button.setAttribute("aria-pressed", "true");\n drawScores();\n });\n scopeHost.appendChild(button);\n }\n }\n var pending = false;\n function schedule() {\n if (state.paused || pending) return;\n pending = true;\n requestAnimationFrame(() => {\n pending = false;\n draw();\n });\n }\n function draw() {\n drawChips();\n drawTiles();\n drawNoticeBadge();\n updateWindowLabels();\n if (state.tab === "live") {\n drawActor();\n drawFeed();\n drawLivePanels();\n } else if (state.tab === "actors") {\n drawActors();\n } else if (state.tab === "stats") {\n drawTraffic();\n drawLatency();\n drawScores();\n drawStatsPanels();\n if (SECTIONS.audit) drawAudit();\n } else {\n drawPolicyTab();\n if (SECTIONS.notices) drawNotices();\n if (SECTIONS.changes) drawChanges();\n }\n }\n function drawNow() {\n draw();\n }\n function applySections() {\n const gated = [\n ["tiles", SECTIONS.statistics],\n ["tester-panel", SECTIONS.tester],\n ["ranges-panel", SECTIONS.ranges],\n ["changes-panel", SECTIONS.changes],\n ["actor-panel", SECTIONS.actors],\n ["live-actors-panel", SECTIONS.actors],\n ["audit-panel", SECTIONS.audit],\n ["audit-checks-panel", SECTIONS.audit],\n ["notices-panel", SECTIONS.notices],\n ["guard-panel", SECTIONS.guard],\n ["robots-panel", SECTIONS.robots],\n ["identities-panel", SECTIONS.actors],\n ["evidence-legend", SECTIONS.evidence]\n ];\n for (const [id, enabled] of gated) {\n const node = rootNode().querySelector(`#${id}`);\n if (node !== null && !enabled) node.remove();\n }\n }\n function start() {\n app.draw = schedule;\n app.drawNow = drawNow;\n app.showTab = showTab;\n app.syncUrl = () => syncUrl();\n applySections();\n initHeader();\n drawPeers();\n initTabs();\n initSkipLink();\n initKeyboard();\n initRanges();\n if (SECTIONS.feed) initFeed();\n initActor();\n initTester();\n initPolicy();\n let resizeTimer;\n addEventListener("resize", () => {\n if (resizeTimer !== void 0) clearTimeout(resizeTimer);\n resizeTimer = setTimeout(() => {\n if (state.tab === "stats") {\n drawTraffic();\n drawLatency();\n drawScores();\n }\n }, 120);\n });\n const url = readUrl();\n state.filter = url.filter;\n setSearch(url.search);\n if (SECTIONS.feed) reflectFilterButtons();\n showTab(url.tab, { replace: true });\n suspendScrollAnchoring();\n void loadInitialSnapshot().finally(settleScrollAnchoring);\n connectStream();\n }\n function htmlElement() {\n const root2 = rootNode();\n return root2 instanceof Document ? root2.documentElement : void 0;\n }\n function suspendScrollAnchoring() {\n htmlElement()?.classList.add("settling");\n }\n function settleScrollAnchoring() {\n const html = htmlElement();\n if (html === void 0) return;\n requestAnimationFrame(() => {\n requestAnimationFrame(() => {\n html.classList.remove("settling");\n });\n });\n }\n start();\n})();\n';
|
|
6311
|
+
CLIENT_SCRIPT = '"use strict";\n(() => {\n // src/dashboard/client/css.ts\n function cssEscape(value) {\n return typeof CSS !== "undefined" && typeof CSS.escape === "function" ? CSS.escape(value) : String(value).replace(/[^\\w-]/g, "\\\\$&");\n }\n\n // src/dashboard/client/dom.ts\n function el(tag, className, value) {\n const node = document.createElement(tag);\n if (className !== void 0 && className !== null && className !== "") node.className = className;\n if (value !== void 0 && value !== null) node.textContent = String(value);\n return node;\n }\n function svgEl(name, attributes = {}) {\n const node = document.createElementNS("http://www.w3.org/2000/svg", name);\n for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, String(value));\n return node;\n }\n function svgText(attributes, text) {\n const node = svgEl("text", attributes);\n node.textContent = String(text);\n return node;\n }\n function clear(node) {\n while (node.firstChild) node.removeChild(node.firstChild);\n }\n var root = typeof document === "undefined" ? void 0 : document;\n var themeHost = typeof document === "undefined" ? void 0 : document.documentElement;\n var embedded = false;\n function isEmbedded() {\n return embedded;\n }\n function eventTarget() {\n return embedded ? root : globalThis;\n }\n function rootNode() {\n return root;\n }\n function themeElement() {\n return themeHost;\n }\n function $(id) {\n const node = root.querySelector(`#${cssEscape(id)}`);\n if (node === null || node === void 0) throw new Error(`dashboard: no element #${id}`);\n return node;\n }\n function byId(id) {\n return $(id);\n }\n function css(name) {\n return getComputedStyle(themeHost).getPropertyValue(name).trim();\n }\n var sequence = 0;\n function label(text, control) {\n const node = document.createElement("label");\n node.textContent = text;\n const single = Array.isArray(control) ? control.length === 1 ? control[0] : void 0 : control;\n if (single !== void 0 && /^(input|select|textarea)$/i.test(single.tagName)) {\n if (single.id === "") single.id = `field-${++sequence}`;\n node.htmlFor = single.id;\n } else {\n const group = Array.isArray(control) ? control : [control];\n for (const node_ of group) if (!node_.hasAttribute("aria-label")) node_.setAttribute("aria-label", text);\n }\n return node;\n }\n\n // src/dashboard/client/boot.ts\n var FALLBACK = {\n base: "",\n title: "bothandlerjs",\n allowReset: false,\n allowEdit: false,\n allowGuardEdit: false,\n allowActing: false,\n peers: [],\n sections: { feed: true, evidence: true, actors: true, registry: true, tester: true, statistics: true, audit: true, notices: true, changes: true, policy: true, guard: true, robots: true, ranges: true },\n links: []\n };\n var BOOT = globalThis.__BOOTSTRAP__ ?? FALLBACK;\n var API = BOOT.base;\n var SECTIONS = BOOT.sections;\n\n // src/dashboard/client/app.ts\n var app = {\n draw: () => {\n },\n drawNow: () => {\n },\n showTab: () => {\n },\n syncUrl: () => {\n }\n };\n function toast(kind, title, detail = "") {\n const node = el("div", `toast ${kind}`);\n node.appendChild(el("b", null, title));\n if (detail !== "") node.appendChild(el("span", null, detail));\n const host = rootNode().querySelector("#toasts");\n if (host === null) return;\n host.appendChild(node);\n setTimeout(() => node.remove(), 6e3);\n }\n function download(text, filename, type) {\n const blob = new Blob([text], { type });\n const url = URL.createObjectURL(blob);\n const anchor = el("a");\n anchor.href = url;\n anchor.download = filename;\n anchor.click();\n setTimeout(() => URL.revokeObjectURL(url), 1e3);\n }\n function today() {\n return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);\n }\n\n // src/dashboard/client/outcome.ts\n var DENY = /* @__PURE__ */ new Set(["block", "drop", "redirect"]);\n var MITIGATE = /* @__PURE__ */ new Set(["challenge", "rate-limit", "delay"]);\n function outcome(entry) {\n return actionKind(entry.action);\n }\n function actionKind(action) {\n if (action === void 0) return "pending";\n if (DENY.has(action)) return "deny";\n if (MITIGATE.has(action)) return "mitigate";\n return "allow";\n }\n function verdictBadge(entry) {\n if (entry.verdict === "verified-bot" || entry.verdict === "confirmed-bot") return ["b-proven", entry.verdict];\n if (entry.verdict === "suspected-bot") return ["b-suspected", "suspected-bot"];\n if (entry.verdict === "human") return ["b-human", "human"];\n return ["b-unknown", entry.bypass !== void 0 ? `skipped \xB7 ${entry.bypass}` : "unknown"];\n }\n function provenBots(verdicts) {\n return (verdicts["confirmed-bot"] ?? 0) + (verdicts["verified-bot"] ?? 0);\n }\n\n // src/dashboard/client/query.ts\n var FIELDS = {\n path: "path",\n url: "path",\n actor: "actor",\n ip: "actor",\n ua: "userAgent",\n useragent: "userAgent",\n agent: "userAgent",\n verdict: "verdict",\n action: "action",\n rule: "rule",\n detector: "detector",\n identity: "identity",\n method: "method",\n class: "botClass",\n botclass: "botClass",\n id: "requestId",\n request: "requestId",\n bypass: "bypass",\n score: "score",\n outcome: "outcome",\n certain: "certain"\n };\n var NUMERIC = /* @__PURE__ */ new Set(["score"]);\n var FIELD_NAMES = Object.keys(FIELDS).sort();\n var FIELD_VALUES = {\n verdict: ["confirmed-bot", "verified-bot", "suspected-bot", "human", "unknown"],\n class: ["scanner", "scraper", "impersonator", "automation", "http-client", "declared-bot", "verified-bot", "human", "unknown"],\n botclass: ["scanner", "scraper", "impersonator", "automation", "http-client", "declared-bot", "verified-bot", "human", "unknown"],\n action: ["allow", "tag", "log", "challenge", "rate-limit", "delay", "block", "drop", "redirect"],\n outcome: ["allow", "mitigate", "deny", "pending"],\n certain: ["true", "false"],\n method: ["GET", "POST", "HEAD", "PUT", "PATCH", "DELETE", "OPTIONS"]\n };\n function suggestFor(input, caret) {\n const before = input.slice(0, caret);\n const start2 = Math.max(before.lastIndexOf(" ") + 1, 0);\n const token = before.slice(start2);\n const to = caret;\n const negated = token.startsWith("-") || token.startsWith("!");\n const body = negated ? token.slice(1) : token;\n const colon = body.indexOf(":");\n if (colon === -1) {\n const partial2 = body.toLowerCase();\n if (partial2.startsWith("$")) {\n return { options: OPERATORS.filter((name) => name.startsWith(partial2)), from: start2, to };\n }\n const options2 = FIELD_NAMES.filter((name) => name.startsWith(partial2)).map((name) => `${negated ? token[0] : ""}${name}:`);\n return { options: options2, from: start2, to };\n }\n const field2 = body.slice(0, colon).toLowerCase();\n const values = FIELD_VALUES[field2];\n if (values === void 0) return { options: [], from: start2, to };\n const partial = body.slice(colon + 1).toLowerCase();\n const prefix = `${negated ? token[0] : ""}${field2}:`;\n const forms = partial.startsWith("$") ? ["$in(", "$notin("].filter((form) => form.startsWith(partial)).map((form) => `${prefix}${form}`) : [];\n const options = [...forms, ...values.filter((value) => value.toLowerCase().startsWith(partial)).map((value) => `${prefix}${value}`)];\n return { options, from: start2, to };\n }\n var OPERATORS = ["$and", "$or", "$not", "$in", "$notin"];\n var MAX_GROUP_DEPTH = 32;\n function tokenize(input) {\n const tokens = [];\n let current = "";\n let quoted = false;\n let depth = 0;\n const flush = () => {\n if (current === "") return;\n const lower = current.toLowerCase();\n if (lower === "$and" || lower === "$or" || lower === "$not") tokens.push({ kind: "op", op: lower.slice(1) });\n else tokens.push({ kind: "word", text: current });\n current = "";\n };\n for (let i = 0; i < input.length; i++) {\n const character = input[i];\n if (character === \'"\') {\n quoted = !quoted;\n continue;\n }\n if (quoted) {\n current += character;\n continue;\n }\n if (depth > 0) {\n current += character;\n if (character === "(") depth++;\n else if (character === ")") {\n depth--;\n if (depth === 0) flush();\n }\n continue;\n }\n if (character === "(") {\n if (/\\$(in|notin)$/i.test(current)) {\n current += character;\n depth = 1;\n continue;\n }\n flush();\n tokens.push({ kind: "open" });\n continue;\n }\n if (character === ")") {\n flush();\n tokens.push({ kind: "close" });\n continue;\n }\n if (/\\s/.test(character)) {\n flush();\n continue;\n }\n current += character;\n }\n flush();\n return tokens;\n }\n function toTerm(word) {\n const negated = word.startsWith("-") || word.startsWith("!");\n const body = negated ? word.slice(1) : word;\n if (body === "") return void 0;\n const colon = body.indexOf(":");\n const name = colon === -1 ? "" : body.slice(0, colon).toLowerCase();\n const field2 = FIELDS[name];\n if (colon === -1 || field2 === void 0) return { field: void 0, value: body.toLowerCase(), negated };\n const rest = body.slice(colon + 1);\n const set = /^\\$(in|notin)\\(([\\s\\S]*)$/i.exec(rest);\n if (set !== null) {\n const inside = set[2].endsWith(")") ? set[2].slice(0, -1) : set[2];\n const values = inside.split(",").map((entry) => entry.trim().toLowerCase()).filter((entry) => entry !== "");\n const inverted = set[1].toLowerCase() === "notin";\n return { field: field2, value: values[0] ?? "", negated: negated !== inverted, values };\n }\n let value = rest.toLowerCase();\n let compare;\n if (NUMERIC.has(field2)) {\n compare = value.startsWith(">") ? ">" : value.startsWith("<") ? "<" : "=";\n if (compare !== "=") value = value.slice(1);\n }\n if (value === "") return void 0;\n return { field: field2, value, negated, compare };\n }\n function parseTokens(tokens) {\n let at = 0;\n let depth = 0;\n const parseUnary = () => {\n const token = tokens[at];\n if (token === void 0) return void 0;\n if (token.kind === "op" && token.op === "not") {\n at++;\n const of = parseUnary();\n return of === void 0 ? void 0 : { kind: "not", of };\n }\n if (token.kind === "open") {\n at++;\n if (depth >= MAX_GROUP_DEPTH) return void 0;\n depth++;\n const inner = parseOr();\n depth--;\n if (tokens[at]?.kind === "close") at++;\n return inner.kind === "all" ? void 0 : inner;\n }\n if (token.kind === "close") return void 0;\n if (token.kind === "op") {\n at++;\n return parseUnary();\n }\n at++;\n const term = toTerm(token.text);\n return term === void 0 ? void 0 : { kind: "term", term };\n };\n const parseAnd = () => {\n const parts = [];\n while (at < tokens.length) {\n const token = tokens[at];\n if (token === void 0 || token.kind === "close") break;\n if (token.kind === "op" && token.op === "or") break;\n if (token.kind === "op" && token.op === "and") {\n at++;\n continue;\n }\n const before = at;\n const part = parseUnary();\n if (part !== void 0) parts.push(part);\n if (at === before) at++;\n }\n return parts.length === 0 ? { kind: "all" } : parts.length === 1 ? parts[0] : { kind: "and", parts };\n };\n const parseOr = () => {\n const parts = [parseAnd()];\n while (tokens[at]?.kind === "op" && tokens[at].op === "or") {\n at++;\n parts.push(parseAnd());\n }\n const real = parts.filter((part) => part.kind !== "all");\n if (real.length === 0) return { kind: "all" };\n return real.length === 1 ? real[0] : { kind: "or", parts: real };\n };\n return parseOr();\n }\n var MAX_QUERY_CHARS = 8192;\n function parseFilter(input) {\n const trimmed = input.trim();\n return parseTokens(tokenize(trimmed.length > MAX_QUERY_CHARS ? trimmed.slice(0, MAX_QUERY_CHARS) : trimmed));\n }\n function searchableText(entry) {\n const parts = [\n entry.method,\n entry.path,\n entry.actor,\n entry.userAgent,\n entry.verdict,\n entry.botClass,\n entry.identity ?? "",\n entry.action ?? "",\n entry.rule ?? "",\n entry.requestId\n ];\n for (const item of entry.evidence) parts.push(item.detector, item.summary);\n return parts.join(" ").toLowerCase();\n }\n function fieldValue(entry, field2) {\n switch (field2) {\n case "path":\n return entry.path;\n case "actor":\n return entry.actor;\n case "userAgent":\n return entry.userAgent;\n case "verdict":\n return entry.verdict;\n case "action":\n return entry.action ?? "";\n case "rule":\n return entry.rule ?? "";\n case "identity":\n return entry.identity ?? "";\n case "method":\n return entry.method;\n case "botClass":\n return entry.botClass;\n case "requestId":\n return entry.requestId;\n case "bypass":\n return entry.bypass ?? "";\n case "outcome":\n return outcome(entry);\n case "certain":\n return String(entry.certain);\n case "detector":\n return entry.evidence.map((item) => item.detector).join(" ");\n default:\n return "";\n }\n }\n function matchesTerm(term, entry, haystack) {\n if (term.field === void 0) return haystack.includes(term.value);\n if (term.values !== void 0) {\n if (term.values.length === 0) return false;\n const actual = fieldValue(entry, term.field).toLowerCase();\n return term.values.some((value) => actual.includes(value));\n }\n if (term.field === "score") {\n const wanted = Number(term.value);\n if (Number.isNaN(wanted)) return false;\n if (term.compare === ">") return entry.score > wanted;\n if (term.compare === "<") return entry.score < wanted;\n return entry.score === wanted;\n }\n return fieldValue(entry, term.field).toLowerCase().includes(term.value);\n }\n function matches(filter, entry, haystack) {\n switch (filter.kind) {\n case "all":\n return true;\n case "term":\n return matchesTerm(filter.term, entry, haystack) !== filter.term.negated;\n case "not":\n return !matches(filter.of, entry, haystack);\n case "and":\n return filter.parts.every((part) => matches(part, entry, haystack));\n case "or":\n return filter.parts.some((part) => matches(part, entry, haystack));\n }\n }\n function matchesFilter(filter, entry) {\n switch (filter) {\n case "proven":\n return entry.certain;\n case "suspected":\n return entry.verdict === "suspected-bot";\n case "human":\n return entry.verdict === "human";\n case "guard":\n return entry.downgradedFrom !== void 0;\n case "deny":\n return outcome(entry) === "deny";\n case "mitigate":\n return outcome(entry) === "mitigate";\n case "allow":\n return outcome(entry) === "allow";\n default:\n return true;\n }\n }\n\n // src/dashboard/client/store.ts\n var MAX_ROWS = 1e3;\n var state = {\n rows: [],\n byId: /* @__PURE__ */ new Map(),\n snapshot: void 0,\n policy: void 0,\n actors: [],\n actorsTracked: 0,\n actorScope: "tracked",\n paused: false,\n filter: "all",\n search: "",\n query: { kind: "all" },\n tab: "live",\n open: /* @__PURE__ */ new Set(),\n actor: void 0,\n rangeMs: 3e5,\n scoreScope: "run",\n editorRules: [],\n editorDirty: false,\n editorMode: "gui",\n guardDirty: false,\n bufferedWhilePaused: 0,\n laggedDrops: 0,\n feedPage: 0,\n feedFrozen: void 0,\n actorsPage: 0,\n feedPageSize: 50,\n actorsPageSize: 25,\n fromMs: void 0,\n toMs: void 0,\n caughtUp: 0\n };\n function ingest(entry) {\n const existing = state.byId.get(entry.requestId);\n if (existing !== void 0) {\n existing.entry = entry;\n existing.rev++;\n existing.text = void 0;\n return;\n }\n const row = { entry, rev: 0 };\n state.byId.set(entry.requestId, row);\n state.rows.push(row);\n if (state.rows.length > MAX_ROWS) {\n for (const dropped of state.rows.splice(0, state.rows.length - MAX_ROWS)) {\n state.byId.delete(dropped.entry.requestId);\n state.open.delete(dropped.entry.requestId);\n }\n }\n }\n function clearFeed() {\n state.rows = [];\n state.byId = /* @__PURE__ */ new Map();\n state.open.clear();\n state.actor = void 0;\n state.bufferedWhilePaused = 0;\n state.laggedDrops = 0;\n resetPaging();\n }\n function setSearch(value) {\n state.search = value;\n state.query = parseFilter(value);\n resetPaging();\n }\n function setTimeframe(from, to) {\n state.fromMs = from;\n state.toMs = to;\n resetPaging();\n }\n function resetPaging() {\n state.feedPage = 0;\n state.feedFrozen = void 0;\n }\n function textOf(row) {\n if (row.text === void 0) row.text = searchableText(row.entry);\n return row.text;\n }\n function sortRows() {\n state.rows.sort((a, b) => a.entry.at - b.entry.at);\n }\n function matches2(row) {\n if (state.fromMs !== void 0 && row.entry.at < state.fromMs) return false;\n if (state.toMs !== void 0 && row.entry.at > state.toMs) return false;\n return matchesFilter(state.filter, row.entry) && matches(state.query, row.entry, textOf(row));\n }\n function matchingRows(limit = Number.POSITIVE_INFINITY) {\n const shown = [];\n for (let i = state.rows.length - 1; i >= 0 && shown.length < limit; i--) {\n const row = state.rows[i];\n if (row !== void 0 && matches2(row)) shown.push(row);\n }\n return shown;\n }\n function feedPage(size) {\n const all = state.feedPage === 0 || state.feedFrozen === void 0 ? matchingRows() : state.feedFrozen;\n const pages = Math.max(1, Math.ceil(all.length / size));\n const page = Math.min(Math.max(0, state.feedPage), pages - 1);\n if (page !== state.feedPage) state.feedPage = page;\n return { rows: all.slice(page * size, page * size + size), page, pages, total: all.length };\n }\n function goToFeedPage(page) {\n const next = Math.max(0, page);\n if (next === 0) {\n state.feedFrozen = void 0;\n } else if (state.feedFrozen === void 0) {\n state.feedFrozen = matchingRows();\n }\n state.feedPage = next;\n }\n function matchingCount() {\n let count = 0;\n for (const row of state.rows) if (matches2(row)) count++;\n return count;\n }\n function oldestAt() {\n return state.rows[0]?.entry.at;\n }\n function bump(counter, key) {\n counter.set(key, (counter.get(key) ?? 0) + 1);\n }\n function aggregate(rows) {\n const totals = {\n detectors: /* @__PURE__ */ new Map(),\n actors: /* @__PURE__ */ new Map(),\n identities: /* @__PURE__ */ new Map(),\n paths: /* @__PURE__ */ new Map(),\n deniedPaths: /* @__PURE__ */ new Map(),\n guardStops: /* @__PURE__ */ new Map(),\n ruleHits: /* @__PURE__ */ new Map(),\n bypassed: /* @__PURE__ */ new Map()\n };\n for (const { entry } of rows) {\n for (const item of entry.evidence) bump(totals.detectors, item.detector);\n bump(totals.actors, entry.actor);\n if (entry.bypass !== void 0) {\n bump(totals.bypassed, `${entry.path} (${entry.bypass})`);\n continue;\n }\n bump(totals.paths, entry.path);\n if (entry.identity !== void 0 && entry.identity !== "") {\n bump(totals.identities, `${entry.identity} \xB7 ${entry.verdict === "verified-bot" ? "verified" : "claimed"}`);\n }\n if (outcome(entry) === "deny") bump(totals.deniedPaths, entry.path);\n if (entry.downgradedFrom !== void 0 && entry.rule !== void 0) bump(totals.guardStops, `${entry.rule} \u2192 ${entry.downgradedFrom}`);\n if (entry.rule !== void 0) bump(totals.ruleHits, entry.rule);\n }\n return totals;\n }\n\n // src/dashboard/client/api.ts\n async function getJson(path) {\n const response = await fetch(API + path);\n if (!response.ok) throw new Error(await errorFrom(response));\n return await response.json();\n }\n async function postJson(path, body) {\n const response = await fetch(API + path, {\n method: "POST",\n headers: { "content-type": "application/json" },\n body: JSON.stringify(body)\n });\n const data = await response.json().catch(() => ({}));\n return response.ok ? { ok: true, data } : { ok: false, data, error: String(data.error ?? "The server refused this.") };\n }\n async function errorFrom(response) {\n const body = await response.json().catch(() => ({}));\n return body.error ?? `${response.status} ${response.statusText}`;\n }\n\n // src/dashboard/client/actions.ts\n var armed = 0;\n function isConfirming() {\n return armed > 0;\n }\n function actorActions(key, after, current) {\n if (!BOOT.allowActing) return [];\n const forget = el("button", null, "Forget");\n forget.title = "Discard this actor\'s history \u2014 the cure for a false positive that has stuck";\n forget.addEventListener("click", () => {\n void act({ key, action: "forget" }, `Forgot ${key}`, "Its next request is assessed as a first request.", after);\n });\n const clear2 = el("button", null, "Clear as human");\n clear2.title = "Grant this actor human clearance for an hour, as though it had solved a challenge";\n clear2.addEventListener("click", () => {\n void act({ key, action: "clear", forMs: 60 * 6e4 }, `Cleared ${key}`, "Held as human for an hour, then reassessed.", after);\n });\n const label2 = labelControl(key, current, after);\n return [confirmingButton("Allowlist", `Allowlist ${key} \u2014 it stops being assessed at all`, () => allowlist(key, after)), forget, clear2, label2];\n }\n function labelControl(key, current, after) {\n const host = el("span", "label-edit");\n const button = el("button", null, current === void 0 ? "Label" : "Relabel");\n button.title = "Give this actor a name, for whoever reads this next. It never changes a verdict.";\n const commit = (value) => {\n const trimmed = value.trim().slice(0, 120);\n void act(\n { key, action: "label", ...trimmed === "" ? {} : { label: trimmed } },\n trimmed === "" ? `Cleared the label on ${key}` : `Labelled ${key}`,\n trimmed === "" ? "It shows as its address again." : `Shown as "${trimmed}" wherever it appears.`,\n after\n );\n };\n button.addEventListener("click", () => {\n const container = host.parentElement;\n clear(host);\n const input = el("input", "label-input");\n input.type = "text";\n input.value = current ?? "";\n input.placeholder = "A name for this client";\n input.setAttribute("aria-label", `Name for ${key}`);\n input.maxLength = 120;\n const save = el("button", "label-save", "Save");\n save.title = "Save this name";\n const cancel = el("button", null, "Cancel");\n cancel.title = "Leave the name as it was";\n input.title = "Enter to save, Escape to cancel";\n armed++;\n container?.classList.add("editing");\n let settled = false;\n const finish = (accept) => {\n if (settled) return;\n settled = true;\n armed--;\n container?.classList.remove("editing");\n if (accept) commit(input.value);\n else {\n clear(host);\n host.appendChild(button);\n }\n };\n for (const control of [input, save, cancel]) {\n control.addEventListener("keydown", (event) => {\n const pressed = event.key;\n if (pressed === "Escape") {\n event.preventDefault();\n finish(false);\n } else if (pressed === "Enter" && control === input) {\n event.preventDefault();\n finish(true);\n }\n });\n }\n for (const control of [save, cancel]) control.addEventListener("mousedown", (event) => event.preventDefault());\n save.addEventListener("click", () => finish(true));\n cancel.addEventListener("click", () => finish(false));\n host.addEventListener("focusout", (event) => {\n const next = event.relatedTarget;\n if (next !== null && host.contains(next)) return;\n finish(false);\n });\n host.append(input, save, cancel);\n input.focus();\n input.select();\n });\n host.appendChild(button);\n return host;\n }\n function confirmingButton(label2, confirmation, run2) {\n const button = el("button", "danger", label2);\n let pending2 = false;\n let timer2;\n const disarm = () => {\n if (!pending2) return;\n pending2 = false;\n armed--;\n button.textContent = label2;\n button.className = "danger";\n };\n button.addEventListener("click", () => {\n if (pending2) {\n if (timer2 !== void 0) clearTimeout(timer2);\n disarm();\n run2();\n return;\n }\n pending2 = true;\n armed++;\n button.textContent = confirmation;\n button.className = "danger primary";\n timer2 = setTimeout(disarm, 5e3);\n });\n return button;\n }\n async function allowlist(key, after) {\n const result = await postJson("/api/ranges", { name: "allowlist", add: [key] });\n if (!result.ok) {\n toast("bad", "Not allowlisted", result.error ?? "");\n return;\n }\n toast("warn", `Allowlisted ${key}`, "Requests from it are no longer assessed at all.");\n after();\n }\n async function act(body, title, detail, after) {\n const result = await postJson("/api/actor", body);\n if (!result.ok) {\n toast("bad", "Refused", result.error ?? "");\n return;\n }\n toast("ok", title, detail);\n after();\n }\n\n // src/dashboard/client/format.ts\n var numbers = new Intl.NumberFormat();\n function n(value) {\n return numbers.format(value ?? 0);\n }\n function pct(part, whole) {\n return whole > 0 ? `${Math.round(part / whole * 100)}%` : "\u2014";\n }\n function ms(value) {\n return value >= 10 ? `${value.toFixed(1)}ms` : `${value.toFixed(2)}ms`;\n }\n function uptime(milliseconds) {\n const seconds = Math.max(0, Math.round(milliseconds / 1e3));\n if (seconds < 90) return `${seconds}s`;\n const minutes = Math.round(seconds / 60);\n if (minutes < 90) return `${minutes}m`;\n return `${Math.round(minutes / 60)}h`;\n }\n function rangeLabel(milliseconds) {\n const seconds = Math.round(milliseconds / 1e3);\n if (seconds < 90) return `${seconds}s`;\n const minutes = Math.round(seconds / 60);\n return minutes < 90 ? `${minutes} min` : `${Math.round(minutes / 60)}h`;\n }\n function clockTime(at) {\n const when = new Date(at);\n return `${pad(when.getHours())}:${pad(when.getMinutes())}:${pad(when.getSeconds())}`;\n }\n function clockDate(at) {\n const when = new Date(at);\n return `${pad(when.getDate())}-${pad(when.getMonth() + 1)}-${when.getFullYear()}`;\n }\n function clockStamp(at) {\n return `${clockDate(at)} ${clockTime(at)}`;\n }\n function pad(value) {\n return String(value).padStart(2, "0");\n }\n function windowLabel(count, oldestAt2, now) {\n if (count === 0) return "this window \xB7 empty";\n const span = oldestAt2 === void 0 ? 0 : Math.max(0, now - oldestAt2);\n return `last ${n(count)} requests \xB7 ${rangeLabel(span)}`;\n }\n\n // src/dashboard/client/bars.ts\n function drawBars(target, rows, emptyText) {\n clear(target);\n const filtered = [...rows].filter(([, value]) => value > 0);\n if (filtered.length === 0) {\n target.appendChild(el("div", "note", emptyText));\n return;\n }\n filtered.sort((a, b) => b[1] - a[1]);\n const max = filtered[0]?.[1] ?? 1;\n for (const [label2, value] of filtered.slice(0, 14)) {\n const bar = el("div", "bar");\n const track = el("div", "track");\n const fill = el("div", "fill");\n fill.style.width = `${Math.max(2, Math.round(value / max * 100))}%`;\n track.appendChild(fill);\n track.appendChild(el("div", "lbl", label2));\n bar.appendChild(track);\n bar.appendChild(el("div", "v tnum", n(value)));\n target.appendChild(bar);\n }\n }\n function pairs(record) {\n return Object.entries(record ?? {});\n }\n\n // src/dashboard/client/actor.ts\n function initActor() {\n if (!SECTIONS.actors) return;\n $("actor-close").addEventListener("click", closeActor);\n }\n function openActor(key) {\n state.actor = key;\n app.drawNow();\n $("actor-panel").scrollIntoView({ block: "nearest" });\n }\n function closeActor() {\n state.actor = void 0;\n app.drawNow();\n }\n function drawActor() {\n if (!SECTIONS.actors) return;\n const panel = $("actor-panel");\n if (state.actor === void 0) {\n panel.hidden = true;\n return;\n }\n panel.hidden = false;\n $("actor-key").textContent = state.actor;\n const mine = state.rows.filter((row) => row.entry.actor === state.actor).map((row) => row.entry);\n const latest = mine[mine.length - 1];\n const stats = latest?.actorStats;\n const gaps = [];\n for (let i = 1; i < mine.length; i++) gaps.push(mine[i].at - mine[i - 1].at);\n const meanGap = gaps.length > 0 ? gaps.reduce((a, b) => a + b, 0) / gaps.length : 0;\n const box = $("actor-stats");\n clear(box);\n const rows = [\n ["In this window", `${n(mine.length)} requests`],\n ["Engine sees", stats !== void 0 ? `${n(stats.requests)} requests, ${n(stats.distinctPaths)} distinct paths` : "\u2014"],\n ["Prior confirmations", stats !== void 0 ? n(stats.priorConfirmations) : "\u2014"],\n ["Holds clearance", stats !== void 0 ? stats.cleared ? "yes" : "no" : "\u2014"],\n ["First seen", stats !== void 0 ? clockStamp(stats.firstSeen) : "\u2014"],\n ["Mean gap", gaps.length > 0 ? `${Math.round(meanGap)}ms over ${n(gaps.length)} gaps` : "one request only"]\n ];\n for (const [key, value] of rows) {\n box.appendChild(el("dt", null, key));\n box.appendChild(el("dd", null, value));\n }\n const verdicts = /* @__PURE__ */ new Map();\n const actions = /* @__PURE__ */ new Map();\n for (const entry of mine) {\n verdicts.set(entry.verdict, (verdicts.get(entry.verdict) ?? 0) + 1);\n if (entry.action !== void 0) actions.set(entry.action, (actions.get(entry.action) ?? 0) + 1);\n }\n drawBars($("actor-mix"), [...verdicts, ...actions], "Nothing yet.");\n const bar = $("actor-actions");\n clear(bar);\n const buttons = actorActions(state.actor, () => app.drawNow());\n bar.hidden = buttons.length === 0;\n for (const button of buttons) bar.appendChild(button);\n }\n\n // src/dashboard/client/saved.ts\n var FILTERS_KEY = "bothandler.filters";\n var MAX_SAVED = 50;\n function read(key, fallback) {\n if (isEmbedded()) return fallback;\n try {\n const raw = localStorage.getItem(key);\n if (raw === null) return fallback;\n const parsed = JSON.parse(raw);\n return Array.isArray(parsed) ? parsed : fallback;\n } catch {\n return fallback;\n }\n }\n function write(key, value) {\n if (isEmbedded()) return;\n try {\n localStorage.setItem(key, JSON.stringify(value));\n } catch {\n }\n }\n function savedFilters() {\n return read(FILTERS_KEY, []).filter((entry) => typeof entry?.name === "string");\n }\n function saveFilter(entry) {\n const kept = savedFilters().filter((existing) => existing.name !== entry.name);\n const next = [entry, ...kept].slice(0, MAX_SAVED);\n write(FILTERS_KEY, next);\n return next;\n }\n function deleteFilter(name) {\n const next = savedFilters().filter((entry) => entry.name !== name);\n write(FILTERS_KEY, next);\n return next;\n }\n\n // src/dashboard/client/pager.ts\n var painted = /* @__PURE__ */ new WeakMap();\n function renderPager(host, model, options) {\n const signature = JSON.stringify([model.page, model.from, model.to, model.total, model.atStart, model.atEnd, model.held ?? "", options.withSize, model.size?.current]);\n if (painted.get(host) === signature && host.childElementCount > 0) return;\n painted.set(host, signature);\n clear(host);\n const steps = [\n { to: model.page - 1, glyph: "\u2039", label: "Previous page", disabled: model.atStart },\n { to: model.page + 1, glyph: "\u203A", label: "Next page", disabled: model.atEnd }\n ];\n const [previous, next] = steps;\n host.appendChild(stepButton(previous, model));\n const range = model.total === void 0 ? `${n(model.from)}\u2013${n(model.to)}` : `${n(model.from)}\u2013${n(model.to)} of ${n(model.total)}`;\n const where = el("span", "where", range);\n where.setAttribute("aria-live", "polite");\n host.appendChild(where);\n host.appendChild(stepButton(next, model));\n if (model.held !== void 0) {\n const held = el("span", "held", model.held);\n held.title = "New requests are still arriving and are still counted. They appear when you return to the first page.";\n host.appendChild(held);\n }\n if (options.withSize && model.size !== void 0) {\n const size = model.size;\n const label2 = el("label", "size");\n label2.appendChild(document.createTextNode("Per page"));\n const select2 = document.createElement("select");\n for (const choice of size.choices) {\n const option = document.createElement("option");\n option.value = String(choice);\n option.textContent = String(choice);\n option.selected = choice === size.current;\n select2.appendChild(option);\n }\n select2.addEventListener("change", () => {\n const chosen = Number(select2.value);\n if (Number.isFinite(chosen) && chosen > 0) size.set(chosen);\n });\n label2.appendChild(select2);\n host.appendChild(label2);\n }\n }\n function stepButton(step, model) {\n const button = el("button", "step", step.glyph);\n const element = button;\n element.type = "button";\n element.disabled = step.disabled;\n button.setAttribute("aria-label", step.label);\n button.title = step.label;\n button.addEventListener("click", () => model.go(Math.max(0, step.to)));\n return button;\n }\n\n // src/dashboard/client/replay.ts\n function replayLine(entry) {\n const headers = {};\n for (const [name, value] of entry.headers ?? []) headers[name] = value;\n const query = Object.keys(entry.query).map((name) => `${encodeURIComponent(name)}=${encodeURIComponent(entry.query[name] ?? "")}`).join("&");\n return JSON.stringify({\n method: entry.method,\n url: entry.path + (query === "" ? "" : `?${query}`),\n headers,\n ip: entry.actor,\n timestamp: new Date(entry.at).toISOString(),\n protocol: entry.protocol ?? "https",\n httpVersion: entry.httpVersion ?? "1.1"\n });\n }\n function replayFile(entries) {\n return entries.map(replayLine).join("\\n");\n }\n function corpusCase(entry) {\n const headers = (entry.headers ?? []).map((pair) => ` [${JSON.stringify(pair[0])}, ${JSON.stringify(pair[1])}]`).join(",\\n");\n return [\n "bot({",\n ` id: ${JSON.stringify(`case-${entry.requestId.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`)},`,\n ` title: ${JSON.stringify(`${entry.method} ${entry.path} from ${entry.userAgent.slice(0, 60)}`)},`,\n \' audience: "unwanted-bot", // human | benign-bot | declared-bot | unwanted-bot | hostile | infrastructure\',\n \' category: "observed",\',\n ` provenance: "Captured from the live dashboard on ${new Date(entry.at).toISOString().slice(0, 10)}",`,\n " requests: [",\n " {",\n " headers: [",\n headers,\n " ],",\n ` protocol: ${JSON.stringify(entry.protocol ?? "https")},`,\n ` httpVersion: ${JSON.stringify(entry.httpVersion ?? "1.1")},`,\n ` path: ${JSON.stringify(entry.path)},`,\n " },",\n " ],",\n ` expect: { verdict: ${JSON.stringify(entry.verdict)}, certain: ${String(entry.certain)} },`,\n "}),"\n ].join("\\n");\n }\n\n // src/dashboard/client/result.ts\n function showResult(kind, build) {\n const box = $("policy-result");\n box.hidden = false;\n box.className = `result ${kind}`;\n clear(box);\n build(box);\n }\n function renderPreview(preview, notes = []) {\n showResult(preview.newDenials > 0 ? "warn" : "ok", (box) => {\n const head = el("div");\n head.appendChild(el("b", null, `${n(preview.changed)} of ${n(preview.evaluated)} requests would be treated differently.`));\n box.appendChild(head);\n for (const note of notes) box.appendChild(el("div", "ev-meta", note));\n if (preview.newDenials > 0) {\n box.appendChild(el("div", null, `${n(preview.newDenials)} request(s) that are served today would be denied. Read the samples before applying this.`));\n }\n for (const warning of preview.warnings) box.appendChild(el("div", "ev-meta", warning));\n const dead = preview.ruleHits.filter((row) => row.hits === 0 && row.rule !== "default");\n if (dead.length > 0) box.appendChild(el("div", "ev-meta", `Never matched in this window: ${dead.map((row) => row.rule).join(", ")}`));\n if (preview.samples.length > 0) {\n const table = el("table", "diff");\n const head2 = el("tr");\n for (const label2 of ["Request", "Now", "Would be"]) head2.appendChild(el("th", null, label2));\n table.appendChild(head2);\n for (const sample of preview.samples) {\n const row = el("tr");\n const what = el("td");\n what.appendChild(el("div", "mono", sample.path));\n what.appendChild(el("div", "ev-meta", `${sample.verdict} \xB7 ${sample.userAgent.slice(0, 48)}`));\n row.appendChild(what);\n const from = el("td", "from");\n from.appendChild(el("div", null, sample.from));\n from.appendChild(el("div", "ev-meta", sample.fromRule));\n row.appendChild(from);\n const kind = sample.to === "block" || sample.to === "drop" || sample.to === "redirect" ? "deny" : sample.to === "allow" ? "allow" : "";\n const to = el("td", `to ${kind}`);\n to.appendChild(el("div", null, sample.to));\n to.appendChild(el("div", "ev-meta", sample.toRule));\n row.appendChild(to);\n table.appendChild(row);\n }\n box.appendChild(table);\n } else if (preview.evaluated === 0) {\n box.appendChild(el("div", "ev-meta", "No traffic in the window to preview against \u2014 send some requests first."));\n }\n });\n }\n\n // src/dashboard/client/guard.ts\n var draft;\n var MODE_NOTES = {\n strict: "A terminal action survives only on proven evidence. Nothing is ever denied on a guess. This is the default, and it is the claim this library makes about itself.",\n balanced: "A terminal action also survives on a probabilistic verdict that clears the score threshold with at least two independent strong signals. Real people do trip two signals \u2014 a hardened browser behind a corporate proxy is the usual pair \u2014 so this setting will eventually deny somebody who should have been served.",\n aggressive: "The guard is off. Every rule does exactly what it says, on proof or on suspicion alike, and the people it turns away first are the ones with the most unusual and most legitimate setups."\n };\n function drawGuard() {\n if (!SECTIONS.guard) return;\n const document_ = state.policy;\n const snapshot = state.snapshot;\n if (snapshot === void 0) return;\n const editable = document_?.guardEditable === true;\n const panel = $("stat-policy");\n clear(panel);\n if (!editable || document_ === void 0) {\n const rows = [\n ["False-positive policy", snapshot.policy.falsePositivePolicy],\n ["Fallback when the guard stops a rule", snapshot.policy.fallbackAction],\n ["Terminal score threshold", String(snapshot.policy.terminalScoreThreshold)],\n ["Suspect threshold", String(snapshot.policy.suspectThreshold)],\n ["Action when no rule matches", snapshot.policy.defaultAction],\n ["Challenge configured", snapshot.policy.challengeEnabled ? "yes" : "no"],\n ["Range sets", snapshot.ranges.length === 0 ? "none" : snapshot.ranges.map((range) => `${range.name} (${range.size})`).join(", ")]\n ];\n for (const [key, value] of rows) {\n const line = el("div", "stat-row");\n line.appendChild(el("span", "k", key));\n line.appendChild(el("span", "v", value));\n panel.appendChild(line);\n }\n $("guard-mode").textContent = "not editable here";\n $("guard-note").textContent = "These are fixed at construction on this dashboard. It can change which rules exist; it cannot change how far a rule is allowed to go, because relaxing that is the one edit that can start denying people. Enable it deliberately with controls: { editGuard: true }.";\n return;\n }\n if (draft === void 0) draft = { ...document_.guard };\n const vocabulary = document_.vocabulary;\n $("guard-mode").textContent = state.guardDirty ? "unsaved changes" : "editable";\n $("guard-note").textContent = "Changing these changes what every rule is allowed to do, on the next request. Preview it against the traffic in the window first \u2014 that is the only view you get of who it would start turning away.";\n panel.appendChild(\n guardField(\n // "Mode" rather than "Guard": the panel is already called that, and a field\n // repeating its own panel\'s name reads as a heading rather than as a control.\n "Mode",\n segmented(\n vocabulary.falsePositivePolicies.map((mode) => [mode, mode]),\n draft.falsePositivePolicy,\n (value) => {\n setDraft({ falsePositivePolicy: value });\n drawGuard();\n }\n )\n )\n );\n panel.appendChild(el("div", "note guard-explains", MODE_NOTES[draft.falsePositivePolicy] ?? ""));\n panel.appendChild(\n guardField(\n "Fallback",\n select(vocabulary.fallbackActions, draft.fallbackAction, (value) => setDraft({ fallbackAction: value })),\n "what a stopped rule becomes \u2014 the terminal actions are absent because a terminal fallback would deny the request the guard just protected"\n )\n );\n panel.appendChild(\n guardField(\n "Default action",\n select(vocabulary.actions, draft.defaultAction, (value) => setDraft({ defaultAction: value })),\n "when no rule matches"\n )\n );\n panel.appendChild(\n guardField(\n "Terminal score",\n number(draft.terminalScoreThreshold, (value) => setDraft({ terminalScoreThreshold: value })),\n "balanced mode only: the score a probabilistic verdict must clear"\n )\n );\n panel.appendChild(\n guardField(\n "Suspect at",\n number(draft.suspectThreshold, (value) => setDraft({ suspectThreshold: value })),\n "the score at which a request becomes suspected-bot"\n )\n );\n const bar = el("div", "bar-actions");\n const preview = el("button", null, "Preview");\n preview.addEventListener("click", () => {\n void previewGuard();\n });\n const apply2 = el("button", "primary", "Apply");\n apply2.addEventListener("click", () => {\n void applyGuard();\n });\n const revert = el("button", null, "Revert");\n revert.addEventListener("click", () => {\n draft = { ...document_.guard };\n state.guardDirty = false;\n drawGuard();\n });\n bar.appendChild(preview);\n bar.appendChild(apply2);\n bar.appendChild(revert);\n panel.appendChild(bar);\n }\n function setDraft(change) {\n if (draft === void 0) return;\n draft = { ...draft, ...change };\n state.guardDirty = true;\n $("guard-mode").textContent = "unsaved changes";\n }\n function resetGuardDraft() {\n draft = void 0;\n state.guardDirty = false;\n }\n function liveRules() {\n return (state.policy?.rules ?? []).filter((row) => row.editable && row.rule !== void 0).map((row) => row.rule);\n }\n async function previewGuard() {\n if (draft === void 0) return;\n const result = await postJson("/api/policy/preview", { rules: liveRules(), guard: draft });\n if (!result.ok) {\n showResult("bad", (box) => box.appendChild(el("div", null, result.error ?? "")));\n toast("bad", "Refused", result.error ?? "");\n return;\n }\n renderPreview(result.data, state.editorDirty ? ["Previewed against the rules currently in force, not the unsaved edits in the editor."] : []);\n app.showTab("policy");\n }\n async function applyGuard() {\n if (draft === void 0) return;\n const result = await postJson("/api/guard", draft);\n if (!result.ok) {\n showResult("bad", (box) => box.appendChild(el("div", null, result.error ?? "")));\n toast("bad", "Guard unchanged", result.error ?? "");\n return;\n }\n state.guardDirty = false;\n draft = { ...result.data.guard };\n if (state.policy !== void 0) state.policy.guard = { ...result.data.guard };\n toast("ok", "Guard changed", `${result.data.guard.falsePositivePolicy}, falling back to ${result.data.guard.fallbackAction}.`);\n showResult("warn", (box) => {\n box.appendChild(el("div", null, "The guard changed. It applies from the next request, and it is in the notices panel and in your logs."));\n if (result.data.guard.falsePositivePolicy !== "strict") {\n box.appendChild(\n el(\n "div",\n "ev-meta",\n "Requests can now be denied without proof. The guard-stop count is the series to watch: every stop that no longer happens is a request that used to be recoverable and is not any more."\n )\n );\n }\n });\n app.draw();\n }\n function guardField(name, control, hint) {\n const row = el("div", "field");\n row.appendChild(label(name, control));\n const right = el("div", "field-row");\n right.appendChild(control);\n if (hint !== void 0) right.appendChild(el("span", "hint", hint));\n row.appendChild(right);\n return row;\n }\n function segmented(options, value, onChange) {\n const box = el("div", "seg");\n for (const [label2, option] of options) {\n const button = el("button", null, label2);\n button.type = "button";\n button.setAttribute("aria-pressed", String(option === value));\n button.addEventListener("click", () => onChange(option));\n box.appendChild(button);\n }\n return box;\n }\n function select(options, value, onChange) {\n const node = el("select");\n for (const option of options) {\n const item = el("option", null, option);\n item.value = option;\n if (option === value) item.selected = true;\n node.appendChild(item);\n }\n node.addEventListener("change", () => onChange(node.value));\n return node;\n }\n function number(value, onChange) {\n const input = el("input");\n input.type = "number";\n input.min = "1";\n input.max = "100";\n input.value = String(value);\n input.addEventListener("input", () => {\n if (input.value !== "") onChange(Number(input.value));\n });\n return input;\n }\n\n // src/dashboard/client/ranges.ts\n var sets = [];\n async function loadRanges() {\n if (!SECTIONS.ranges) return;\n try {\n const body = await getJson("/api/ranges");\n sets = body.ranges;\n drawRanges();\n } catch {\n }\n }\n function drawRanges() {\n if (!SECTIONS.ranges) return;\n const body = $("ranges-body");\n clear(body);\n $("ranges-mode").textContent = BOOT.allowActing ? "editable" : "read-only";\n $("ranges-note").textContent = BOOT.allowActing ? "An address on the allowlist is not judged leniently \u2014 it is not judged at all. Detection does not run on it, no evidence is produced, and no rule sees it." : "These come from the code that constructed the handler. Enable controls.editRanges to add an address from here.";\n if (sets.length === 0) {\n body.appendChild(el("div", "note", "No range sets are configured. Add an address to the allowlist and one appears."));\n }\n for (const set of sets) {\n const block = el("div", "rangeset");\n const heading = el("h3");\n heading.appendChild(document.createTextNode(set.name));\n heading.appendChild(el("span", null, `${n(set.size)} entr${set.size === 1 ? "y" : "ies"}`));\n block.appendChild(heading);\n const list = el("div", "cidrs");\n for (const entry of set.entries) {\n const chip = el("span", BOOT.allowActing ? "cidr" : "cidr readonly");\n chip.appendChild(document.createTextNode(entry));\n if (BOOT.allowActing) {\n const remove = el("button", null, "\xD7");\n remove.title = `Remove ${entry} from ${set.name}`;\n remove.setAttribute("aria-label", `Remove ${entry} from ${set.name}`);\n remove.addEventListener("click", () => void update(set.name, { remove: [entry] }));\n chip.appendChild(remove);\n }\n list.appendChild(chip);\n }\n if (set.entries.length === 0) list.appendChild(el("span", "hint", "empty"));\n block.appendChild(list);\n body.appendChild(block);\n }\n if (!BOOT.allowActing) return;\n const form = el("div", "rangeset");\n const row = el("div", "field-row");\n const name = el("input", "mono-input");\n name.type = "text";\n name.value = "allowlist";\n name.setAttribute("aria-label", "Range set");\n name.style.maxWidth = "150px";\n const value = el("input", "mono-input");\n value.type = "text";\n value.placeholder = "203.0.113.0/24";\n value.setAttribute("aria-label", "Address or CIDR to add");\n const add = el("button", null, "Add");\n const submit = () => {\n const entry = value.value.trim();\n if (entry === "") return;\n value.value = "";\n void update(name.value.trim(), { add: [entry] });\n };\n add.addEventListener("click", submit);\n value.addEventListener("keydown", (event) => {\n if (event.key === "Enter") submit();\n });\n row.appendChild(name);\n row.appendChild(value);\n row.appendChild(add);\n form.appendChild(row);\n body.appendChild(form);\n }\n async function update(name, change) {\n const result = await postJson("/api/ranges", { name, ...change });\n if (!result.ok) {\n toast("bad", "Ranges unchanged", result.error ?? "");\n return;\n }\n toast(name === "allowlist" && change.add !== void 0 ? "warn" : "ok", `\u201C${name}\u201D updated`, `${n(result.data.entries?.length ?? 0)} entr${result.data.entries?.length === 1 ? "y" : "ies"} now.`);\n await loadRanges();\n }\n\n // src/dashboard/client/draft.ts\n function draftRule(entry, existingIds = []) {\n const match = {};\n let because;\n let stem;\n const proven = entry.evidence.filter((item) => item.certainty === "certain" && item.direction !== "human");\n const detectors = [...new Set((proven.length > 0 ? proven : entry.evidence.filter((item) => item.direction !== "human")).map((item) => item.detector))];\n if (entry.identity !== void 0 && entry.identity !== "") {\n match["identity"] = [entry.identity];\n if (entry.certain) match["certain"] = true;\n stem = entry.identity;\n because = entry.certain ? `Matched on the identity \u201C${entry.identity}\u201D, and on proof \u2014 so a client merely claiming that name does not match.` : `Matched on the claimed identity \u201C${entry.identity}\u201D. Nothing has verified it, so this matches anything that says so.`;\n } else if (proven.length > 0) {\n match["detector"] = detectors;\n match["certain"] = true;\n stem = detectors[0] ?? "proven";\n because = `Matched on proof from ${detectors.join(", ")}. Only requests that carry the same proof match.`;\n } else if (detectors.length > 0) {\n match["verdict"] = [entry.verdict];\n match["detector"] = detectors;\n match["minScore"] = Math.max(0, Math.floor(entry.score / 10) * 10);\n stem = detectors[0] ?? entry.verdict;\n because = `Matched on ${entry.verdict} at score ${String(match["minScore"])} or more, from ${detectors.join(", ")}. Every one of those is probabilistic, so the guard will not let this rule deny anybody.`;\n } else {\n match["verdict"] = [entry.verdict];\n stem = entry.verdict;\n because = `Nothing fired on this request, so there is nothing sharper to match on than the verdict itself. Narrow it before you use it.`;\n }\n return {\n rule: {\n id: uniqueId(`from-${slug(stem)}`, existingIds),\n match,\n action: "tag",\n reason: "Drafted from a request on the dashboard.",\n _open: true\n },\n because\n };\n }\n function slug(value) {\n const cleaned = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");\n return cleaned === "" ? "request" : cleaned.slice(0, 40);\n }\n function uniqueId(wanted, taken) {\n if (!taken.includes(wanted)) return wanted;\n for (let suffix = 2; suffix < 1e3; suffix++) {\n const candidate = `${wanted}-${suffix}`;\n if (!taken.includes(candidate)) return candidate;\n }\n return `${wanted}-${Date.now()}`;\n }\n\n // src/dashboard/client/policy.ts\n async function loadPolicy() {\n if (!SECTIONS.policy) return;\n try {\n const document_ = await getJson("/api/policy");\n state.policy = document_;\n if (!state.editorDirty) {\n setEditorRules(document_.rules.filter((row) => row.editable).map((row) => row.rule));\n }\n if (!state.guardDirty) resetGuardDraft();\n drawPolicyTab();\n } catch {\n }\n }\n function setEditorRules(rules) {\n state.editorRules = JSON.parse(JSON.stringify(rules ?? []));\n renderEditor();\n }\n function markDirty() {\n state.editorDirty = true;\n $("policy-dirty").hidden = false;\n }\n function editorRules() {\n if (state.editorMode === "json") {\n try {\n const parsed = JSON.parse(byId("policy-json").value);\n if (!Array.isArray(parsed)) return { error: "The JSON must be an array of rules." };\n return { rules: parsed };\n } catch (error) {\n return { error: `The editor does not contain valid JSON: ${String(error)}` };\n }\n }\n return { rules: state.editorRules };\n }\n function cleanRule(rule) {\n const match = {};\n for (const [key, value] of Object.entries(rule.match ?? {})) {\n if (value === void 0 || value === null || value === "") continue;\n if (Array.isArray(value) && value.length === 0) continue;\n match[key] = value;\n }\n const params = {};\n for (const [key, value] of Object.entries(rule.params ?? {})) {\n if (value === void 0 || value === null || value === "") continue;\n if (key === "limit") {\n const limit = value;\n if (limit.max === void 0 || limit.windowMs === void 0) continue;\n }\n params[key] = value;\n }\n const out = { id: rule.id, match, action: rule.action };\n if (Object.keys(params).length > 0) out["params"] = params;\n if (rule.reason !== void 0 && rule.reason !== "") out["reason"] = rule.reason;\n return out;\n }\n function cleanRules(rules) {\n return rules.map(cleanRule);\n }\n function field(name, control, hint) {\n const row = el("div", "field");\n row.appendChild(label(name, control));\n const right = el("div", "field-row");\n for (const node of Array.isArray(control) ? control : [control]) right.appendChild(node);\n if (hint !== void 0) right.appendChild(el("span", "hint", hint));\n row.appendChild(right);\n return row;\n }\n function chipSelect(options, selected, onChange) {\n const box = el("div", "chips-select");\n const chosen = Array.isArray(selected) ? [...selected] : selected === void 0 ? [] : [String(selected)];\n for (const option of options) {\n const button = el("button", null, option);\n button.type = "button";\n button.setAttribute("aria-pressed", String(chosen.includes(option)));\n button.addEventListener("click", () => {\n const at = chosen.indexOf(option);\n if (at === -1) chosen.push(option);\n else chosen.splice(at, 1);\n button.setAttribute("aria-pressed", String(at === -1));\n onChange(chosen.length === 0 ? void 0 : [...chosen]);\n });\n box.appendChild(button);\n }\n return box;\n }\n function segmented2(options, value, onChange) {\n const box = el("div", "seg");\n for (const [label2, option] of options) {\n const button = el("button", null, label2);\n button.type = "button";\n button.setAttribute("aria-pressed", String(option === value));\n button.addEventListener("click", () => {\n for (const other of Array.from(box.children)) other.setAttribute("aria-pressed", "false");\n button.setAttribute("aria-pressed", "true");\n onChange(option);\n });\n box.appendChild(button);\n }\n return box;\n }\n function textInput(value, placeholder, onChange, mono = false) {\n const input = el("input", mono ? "mono-input" : null);\n input.type = "text";\n input.value = value === void 0 || value === null ? "" : String(value);\n input.placeholder = placeholder;\n input.addEventListener("input", () => onChange(input.value.trim() === "" ? void 0 : input.value));\n return input;\n }\n function listInput(value, placeholder, onChange) {\n const current = value === void 0 ? "" : Array.isArray(value) ? value.join(", ") : String(value);\n const input = textInput(current, placeholder, () => {\n }, true);\n input.addEventListener("input", () => {\n const parts = input.value.split(",").map((part) => part.trim()).filter(Boolean);\n onChange(parts.length === 0 ? void 0 : parts.length === 1 ? parts[0] : parts);\n });\n return input;\n }\n function numberInput(value, placeholder, onChange) {\n const input = el("input");\n input.type = "number";\n input.value = value === void 0 || value === null ? "" : String(value);\n input.placeholder = placeholder;\n input.addEventListener("input", () => onChange(input.value === "" ? void 0 : Number(input.value)));\n return input;\n }\n function matchSummary(rule) {\n const box = el("div", "rule-summary");\n const match = rule.match ?? {};\n const parts = [];\n for (const key of ["verdict", "botClass", "category", "identity", "detector", "method", "path"]) {\n const value = match[key];\n if (value === void 0) continue;\n parts.push([key, Array.isArray(value) ? value.join(", ") : String(value)]);\n }\n if (match["certain"] !== void 0) parts.push(["certain", String(match["certain"])]);\n if (match["minScore"] !== void 0 || match["maxScore"] !== void 0) {\n parts.push(["score", `${String(match["minScore"] ?? 0)}\u2013${String(match["maxScore"] ?? 99)}`]);\n }\n if (match["minPriorConfirmations"] !== void 0) parts.push(["prior", String(match["minPriorConfirmations"])]);\n if (match["minUnsolvedChallenges"] !== void 0) parts.push(["unsolved", String(match["minUnsolvedChallenges"])]);\n if (parts.length === 0) {\n box.appendChild(el("span", "none", "matches everything"));\n return box;\n }\n for (const [key, value] of parts.slice(0, 4)) box.appendChild(el("span", "t", `${key}: ${value}`));\n if (parts.length > 4) box.appendChild(el("span", "k", `+${parts.length - 4} more`));\n return box;\n }\n function ruleCard(rule, index) {\n const vocabulary = state.policy?.vocabulary;\n const open = rule._open === true;\n const card = el("div", `rule${open ? "" : " collapsed"}`);\n const head = el("div", "rule-head");\n const chevron = el("button", "chev", open ? "\u25BE" : "\u25B8");\n chevron.title = open ? "Collapse" : "Expand";\n chevron.setAttribute("aria-expanded", String(open));\n chevron.addEventListener("click", () => {\n rule._open = !open;\n renderEditor();\n });\n head.appendChild(chevron);\n head.appendChild(el("span", "ord", index + 1));\n const id = textInput(rule.id, "rule-id", (value) => {\n rule.id = value ?? "";\n markDirty();\n }, true);\n id.setAttribute("aria-label", "Rule id");\n head.appendChild(id);\n if (open) {\n const action = el("select");\n action.setAttribute("aria-label", "Action");\n for (const name of vocabulary?.actions ?? []) {\n const option = el("option", null, name);\n option.value = name;\n if (name === rule.action) option.selected = true;\n action.appendChild(option);\n }\n action.addEventListener("change", () => {\n rule.action = action.value;\n rule.params = {};\n markDirty();\n renderEditor();\n });\n head.appendChild(action);\n } else {\n head.appendChild(matchSummary(rule));\n head.appendChild(el("span", `act-pill ${actionKind(rule.action)}`, rule.action));\n }\n const up = el("button", "icon", "\u2191");\n up.title = "Move earlier \u2014 the first matching rule wins";\n up.addEventListener("click", () => moveRule(index, -1));\n const down = el("button", "icon", "\u2193");\n down.title = "Move later";\n down.addEventListener("click", () => moveRule(index, 1));\n const remove = el("button", "icon danger", "Remove");\n remove.addEventListener("click", () => {\n state.editorRules.splice(index, 1);\n markDirty();\n renderEditor();\n });\n head.appendChild(up);\n head.appendChild(down);\n head.appendChild(remove);\n card.appendChild(head);\n if (!open) return card;\n const body = el("div", "rule-body");\n rule.match = rule.match ?? {};\n const match = rule.match;\n body.appendChild(field("Verdict", chipSelect(vocabulary?.verdicts ?? [], match["verdict"], (value) => {\n match["verdict"] = value;\n markDirty();\n })));\n body.appendChild(field("Bot class", chipSelect(vocabulary?.botClasses ?? [], match["botClass"], (value) => {\n match["botClass"] = value;\n markDirty();\n })));\n body.appendChild(field("Category", chipSelect(vocabulary?.categories ?? [], match["category"], (value) => {\n match["category"] = value;\n markDirty();\n })));\n body.appendChild(field("Detector", chipSelect(vocabulary?.detectors ?? [], match["detector"], (value) => {\n match["detector"] = value;\n markDirty();\n })));\n body.appendChild(field("Method", chipSelect(vocabulary?.methods ?? [], match["method"], (value) => {\n match["method"] = value;\n markDirty();\n })));\n body.appendChild(\n field(\n "Evidence",\n segmented2(\n [\n ["any", void 0],\n ["proven", true],\n ["unproven", false]\n ],\n match["certain"],\n (value) => {\n match["certain"] = value;\n markDirty();\n }\n ),\n "proven means at least one piece of certain evidence \u2014 including a proven human"\n )\n );\n body.appendChild(\n field("Score", [\n numberInput(match["minScore"], "min", (value) => {\n match["minScore"] = value;\n markDirty();\n }),\n el("span", "hint", "to"),\n numberInput(match["maxScore"], "max", (value) => {\n match["maxScore"] = value;\n markDirty();\n })\n ])\n );\n body.appendChild(field("Identity", listInput(match["identity"], "googlebot, gptbot", (value) => {\n match["identity"] = value;\n markDirty();\n })));\n body.appendChild(field("Path", listInput(match["path"], "/api/, /search", (value) => {\n match["path"] = value;\n markDirty();\n }), "a string matches as a prefix"));\n body.appendChild(\n field(\n "Prior bots",\n numberInput(match["minPriorConfirmations"], "0", (value) => {\n match["minPriorConfirmations"] = value;\n markDirty();\n }),\n "times this actor was already proven a bot"\n )\n );\n body.appendChild(\n field(\n "Unsolved",\n numberInput(match["minUnsolvedChallenges"], "0", (value) => {\n match["minUnsolvedChallenges"] = value;\n markDirty();\n }),\n "challenges issued to this actor that were never answered \u2014 solving one clears the count"\n )\n );\n rule.params = rule.params ?? {};\n const params = rule.params;\n for (const node of paramFields(rule.action, params)) body.appendChild(node);\n body.appendChild(field("Reason", textInput(rule.reason, "shown in the decision and in your logs", (value) => {\n rule.reason = value ?? "";\n markDirty();\n })));\n card.appendChild(body);\n return card;\n }\n function paramFields(action, params) {\n switch (action) {\n case "block":\n return [\n field("Status", numberInput(params["status"], "403", (value) => {\n params["status"] = value;\n markDirty();\n })),\n field("Body", textInput(params["body"], "Automated traffic is not served here.", (value) => {\n params["body"] = value;\n markDirty();\n }))\n ];\n case "redirect":\n return [field("Location", textInput(params["location"], "/too-fast", (value) => {\n params["location"] = value;\n markDirty();\n }, true))];\n case "delay":\n return [field("Delay", numberInput(params["delayMs"], "250", (value) => {\n params["delayMs"] = value;\n markDirty();\n }), "milliseconds")];\n case "rate-limit": {\n const limit = params["limit"] ?? {};\n params["limit"] = limit;\n return [\n field("Limit", [\n numberInput(limit["max"], "60", (value) => {\n limit["max"] = value;\n markDirty();\n }),\n el("span", "hint", "requests per"),\n numberInput(limit["windowMs"], "60000", (value) => {\n limit["windowMs"] = value;\n markDirty();\n }),\n el("span", "hint", "ms")\n ])\n ];\n }\n case "custom":\n return [field("Handler", textInput(params["handler"], "handler-id", (value) => {\n params["handler"] = value;\n markDirty();\n }, true), "id of a handler you registered")];\n default:\n return [];\n }\n }\n function lockedCard(row) {\n const card = el("div", "rule locked");\n const head = el("div", "rule-head");\n head.appendChild(el("span", "ord", `#${row.index + 1}`));\n head.appendChild(el("span", "mono", row.id));\n head.appendChild(el("span", "grow"));\n head.appendChild(el("span", "pill", "predicate \u2014 locked"));\n card.appendChild(head);\n card.appendChild(\n el("div", "rule-body", "This rule matches with a function, which cannot be represented here or sent over HTTP. It stays exactly as it is, at this position, whatever else you change.")\n );\n return card;\n }\n function moveRule(index, delta) {\n const target = index + delta;\n if (target < 0 || target >= state.editorRules.length) return;\n const moved = state.editorRules.splice(index, 1)[0];\n if (moved === void 0) return;\n state.editorRules.splice(target, 0, moved);\n markDirty();\n renderEditor();\n }\n function renderEditor() {\n if (!SECTIONS.policy) return;\n const list = $("rulelist");\n clear(list);\n const locked = (state.policy?.rules ?? []).filter((row) => !row.editable);\n if (state.editorRules.length === 0 && locked.length === 0) {\n list.appendChild(el("div", "note", "No rules. Every request takes the default action \u2014 add one, or import a set."));\n }\n const rendered2 = state.editorRules.map((rule, index) => ruleCard(rule, index));\n for (const row of locked) rendered2.splice(Math.min(row.index, rendered2.length), 0, lockedCard(row));\n for (const node of rendered2) list.appendChild(node);\n if (state.editorMode === "json") byId("policy-json").value = JSON.stringify(cleanRules(state.editorRules), null, 2);\n }\n function drawPolicyTab() {\n const document_ = state.policy;\n if (document_ === void 0) return;\n const editable = document_.editable;\n $("policy-apply").hidden = !editable;\n byId("policy-json").readOnly = !editable;\n $("policy-mode").textContent = editable ? "editable" : "read-only";\n $("rule-add").hidden = !editable;\n $("policy-import").hidden = !editable;\n const preserved = document_.rules.filter((row) => !row.editable);\n let note = editable ? "First match wins \u2014 order matters." : "Read-only. Enable controls.editPolicy to change these.";\n if (preserved.length > 0) note += ` ${preserved.length} rule(s) use a predicate function and are locked.`;\n $("policy-note").textContent = note;\n renderPresetButtons();\n drawGuard();\n drawRanges();\n if (SECTIONS.robots) {\n $("robots-preview").textContent = document_.robots === "" ? "(this policy declines no crawler by name)" : document_.robots;\n const notes = $("robots-notes");\n clear(notes);\n for (const note_ of document_.robotsNotes) notes.appendChild(el("div", "ev-meta", `${note_.rule}: ${note_.reason}`));\n }\n const rules = $("stat-rules");\n clear(rules);\n const installed = state.snapshot?.rules ?? [];\n if (installed.length === 0) rules.appendChild(el("div", "note", "No rules configured \u2014 every request takes the default action."));\n else installed.forEach((rule, index) => rules.appendChild(el("span", "chip", `${index + 1}. ${rule}`)));\n }\n async function draftIntoEditor(entry) {\n if (state.policy === void 0) await loadPolicy();\n const drafted = draftRule(entry, state.editorRules.map((rule) => rule.id));\n state.editorRules.push(drafted.rule);\n markDirty();\n if (state.editorMode === "json") byId("policy-json").value = JSON.stringify(cleanRules(state.editorRules), null, 2);\n renderEditor();\n app.showTab("policy");\n const notes = [\n `Drafted \u201C${drafted.rule.id}\u201D, tagging only. Nothing is applied \u2014 read it, choose the action, then apply.`,\n drafted.because,\n "It was added last, which is the only position that cannot change what an existing rule does. Move it up with \u2191 if it needs to win."\n ];\n showResult("warn", (box) => {\n for (const note of notes) box.appendChild(el("div", note === notes[0] ? null : "ev-meta", note));\n });\n toast("ok", "Rule drafted", "In the editor, tagging only, not applied.");\n await runPreview(notes);\n }\n function switchToGui() {\n if (state.editorMode === "gui") return;\n const parsed = editorRules();\n if (parsed.error !== void 0) {\n toast("bad", "That JSON will not parse", parsed.error);\n return;\n }\n state.editorRules = parsed.rules ?? [];\n state.editorMode = "gui";\n $("mode-gui").setAttribute("aria-pressed", "true");\n $("mode-json").setAttribute("aria-pressed", "false");\n $("editor-gui").hidden = false;\n $("editor-json").hidden = true;\n renderEditor();\n }\n function switchToJson() {\n state.editorMode = "json";\n $("mode-gui").setAttribute("aria-pressed", "false");\n $("mode-json").setAttribute("aria-pressed", "true");\n $("editor-gui").hidden = true;\n $("editor-json").hidden = false;\n byId("policy-json").value = JSON.stringify(cleanRules(state.editorRules), null, 2);\n }\n async function exportSettings() {\n try {\n const response = await fetch(`${API}/api/settings`);\n const text = await response.text();\n if (!response.ok) throw new Error(text);\n download(text, `bothandler-settings-${today()}.json`, "application/json");\n toast("ok", "Settings exported", "Rules, plus a record of the configuration around them.");\n } catch (error) {\n toast("bad", "Export failed", String(error));\n }\n }\n function readSettingsFile(file) {\n const reader = new FileReader();\n reader.onload = () => {\n try {\n applyImported(JSON.parse(String(reader.result)), file.name);\n } catch (error) {\n toast("bad", "That file is not JSON", String(error));\n }\n };\n reader.onerror = () => toast("bad", "Could not read that file", "");\n reader.readAsText(file);\n }\n function applyImported(document_, name) {\n const rules = Array.isArray(document_) ? document_ : Array.isArray(document_.rules) ? document_.rules : void 0;\n if (rules === void 0) {\n toast("bad", "Nothing to import", "Expected an array of rules, or a settings file with a rules array.");\n return;\n }\n setEditorRules(rules);\n markDirty();\n switchToGui();\n const ignored = [];\n const readOnly = document_.readOnly;\n if (readOnly !== void 0) {\n ignored.push("the guard, the detectors, the ranges and the audit \u2014 those come from the code that built the handler, not from a file");\n if (Array.isArray(readOnly.lockedRules) && readOnly.lockedRules.length > 0) {\n ignored.push(`${readOnly.lockedRules.length} predicate rule(s), which stay as they are`);\n }\n }\n showResult("warn", (box) => {\n box.appendChild(el("div", null, `Loaded ${rules.length} rule(s) from ${name}. Nothing has been applied yet \u2014 preview it first.`));\n for (const line of ignored) box.appendChild(el("div", "ev-meta", `Ignored: ${line}`));\n });\n toast("ok", `Imported ${rules.length} rule(s)`, "Review, preview, then apply.");\n }\n function collectForSubmit() {\n const parsed = editorRules();\n if (parsed.error !== void 0) {\n showResult("bad", (box) => box.appendChild(el("div", null, parsed.error ?? "")));\n toast("bad", "That JSON will not parse", parsed.error);\n return void 0;\n }\n const cleaned = cleanRules(parsed.rules ?? []);\n const blank = cleaned.filter((rule) => rule["id"] === void 0 || rule["id"] === "").length;\n if (blank > 0) {\n toast("bad", "Every rule needs an id", "It is what every decision and log line names.");\n return void 0;\n }\n return cleaned;\n }\n async function runPreview(notes = []) {\n const rules = collectForSubmit();\n if (rules === void 0) return;\n const result = await postJson("/api/policy/preview", { rules });\n if (!result.ok) {\n showResult("bad", (box) => box.appendChild(el("div", null, result.error ?? "")));\n toast("bad", "Refused", result.error ?? "");\n return;\n }\n renderPreview(result.data, notes);\n }\n async function applyPolicy() {\n const rules = collectForSubmit();\n if (rules === void 0) return;\n const result = await postJson("/api/policy/apply", { rules });\n if (!result.ok) {\n showResult("bad", (box) => box.appendChild(el("div", null, result.error ?? "")));\n toast("bad", "Refused", result.error ?? "");\n return;\n }\n state.editorDirty = false;\n $("policy-dirty").hidden = true;\n await loadPolicy();\n toast("ok", "Applied", `${n(result.data.rules)} rule(s) now in force.`);\n showResult("ok", (box) => {\n box.appendChild(el("div", null, `Applied. ${n(result.data.rules)} rule(s) are now in force.`));\n for (const warning of result.data.warnings ?? []) box.appendChild(el("div", "ev-meta", warning));\n });\n }\n function renderPresetButtons() {\n const box = $("preset-buttons");\n const presets = state.policy?.vocabulary.presets ?? [];\n if (box.childElementCount === presets.length) return;\n clear(box);\n for (const preset of presets) {\n const button = el("button", null, preset);\n button.addEventListener("click", () => {\n void (async () => {\n const result = await postJson("/api/policy/preview", { preset });\n if (!result.ok) {\n toast("bad", "Refused", result.error ?? "");\n return;\n }\n renderPreview(result.data);\n })();\n });\n box.appendChild(button);\n }\n }\n function initPolicy() {\n if (!SECTIONS.policy) return;\n $("mode-gui").addEventListener("click", switchToGui);\n $("mode-json").addEventListener("click", switchToJson);\n $("rule-add").addEventListener("click", () => {\n state.editorRules.push({ id: `new-rule-${state.editorRules.length + 1}`, match: {}, action: "tag", params: {}, _open: true });\n markDirty();\n renderEditor();\n });\n $("rule-expand").addEventListener("click", () => {\n const anyClosed = state.editorRules.some((rule) => rule._open !== true);\n for (const rule of state.editorRules) rule._open = anyClosed;\n $("rule-expand").textContent = anyClosed ? "Collapse all" : "Expand all";\n renderEditor();\n });\n byId("policy-json").addEventListener("input", markDirty);\n $("policy-preview").addEventListener("click", () => void runPreview());\n $("policy-apply").addEventListener("click", () => void applyPolicy());\n $("policy-revert").addEventListener("click", () => {\n state.editorDirty = false;\n $("policy-dirty").hidden = true;\n $("policy-result").hidden = true;\n resetGuardDraft();\n void loadPolicy();\n });\n $("policy-export").addEventListener("click", () => void exportSettings());\n $("policy-import").addEventListener("click", () => byId("policy-file").click());\n byId("policy-file").addEventListener("change", () => {\n const input = byId("policy-file");\n const file = input.files?.[0];\n if (file !== void 0) readSettingsFile(file);\n input.value = "";\n });\n const panel = $("editor-panel");\n for (const name of ["dragenter", "dragover"]) {\n panel.addEventListener(name, (event) => {\n if (state.policy?.editable !== true) return;\n event.preventDefault();\n panel.classList.add("drop");\n });\n }\n for (const name of ["dragleave", "drop"]) {\n panel.addEventListener(name, (event) => {\n panel.classList.remove("drop");\n if (name !== "drop") return;\n event.preventDefault();\n const file = event.dataTransfer?.files?.[0];\n if (file !== void 0) readSettingsFile(file);\n });\n }\n }\n\n // src/dashboard/client/feed.ts\n var FILTERS = [\n ["all", "All"],\n ["proven", "Proven"],\n ["suspected", "Suspected"],\n ["human", "Human"],\n ["guard", "Guard stops"],\n ["deny", "Denied"],\n ["mitigate", "Mitigated"],\n ["allow", "Served"]\n ];\n var rendered = /* @__PURE__ */ new Map();\n function initFeed() {\n const loadThem = byId("feed-load-skipped");\n loadThem.addEventListener("click", () => {\n loadThem.disabled = true;\n void loadSkipped().finally(() => {\n loadThem.disabled = false;\n });\n });\n const filters = $("filters");\n for (const [name, label2] of FILTERS) {\n const button = el("button", null, label2);\n button.setAttribute("aria-pressed", String(name === state.filter));\n button.dataset["filter"] = name;\n button.addEventListener("click", () => {\n state.filter = name;\n resetPaging();\n for (const other of Array.from(filters.children)) other.setAttribute("aria-pressed", "false");\n button.setAttribute("aria-pressed", "true");\n app.syncUrl();\n app.drawNow();\n });\n filters.appendChild(button);\n }\n const search = byId("search");\n search.addEventListener("input", () => {\n setSearch(search.value.trim());\n app.syncUrl();\n app.drawNow();\n showSuggestions(search);\n });\n initSuggestions(search);\n initTimeframe();\n initSavedFilters(search);\n const exportShown = byId("feed-export");\n exportShown.hidden = !SECTIONS.evidence;\n exportShown.addEventListener("click", () => {\n const rows = matchingRows();\n if (rows.length === 0) {\n toast("warn", "Nothing to export", "No request in the window matches this filter.");\n return;\n }\n const entries = rows.map((row) => row.entry).reverse();\n download(`${replayFile(entries)}\n`, `bothandler-feed-${today()}.jsonl`, "application/x-ndjson");\n toast("ok", `Exported ${n(entries.length)} request(s)`, "Replay them with `bothandlerjs replay`.");\n });\n }\n function reflectFilterButtons() {\n for (const button of Array.from($("filters").children)) {\n button.setAttribute("aria-pressed", String(button instanceof HTMLElement && button.dataset["filter"] === state.filter));\n }\n const search = byId("search");\n if (search.value !== state.search) search.value = state.search;\n }\n async function loadSkipped() {\n const before = state.rows.length;\n let served;\n try {\n const body = await getJson("/api/feed");\n for (const entry of body.entries) ingest(entry);\n served = body.skipped;\n sortRows();\n } catch {\n toast("bad", "Could not load them", "The dashboard did not answer. The entries are still in the window; try again.");\n return;\n }\n state.caughtUp = (served ?? state.snapshot?.skipped ?? 0) + state.laggedDrops;\n const added = state.rows.length - before;\n resetPaging();\n app.drawNow();\n toast(\n added > 0 ? "ok" : "warn",\n added > 0 ? `Loaded ${n(added)}` : "Nothing left to load",\n added > 0 ? "They are in the feed now, in the order they happened." : "The window no longer holds them; the ring had already rotated past."\n );\n }\n var highlighted = -1;\n function suggestionList() {\n return $("search-suggest");\n }\n function closeSuggestions(input) {\n const list = suggestionList();\n list.hidden = true;\n clear(list);\n highlighted = -1;\n input.setAttribute("aria-expanded", "false");\n }\n function showSuggestions(input) {\n const caret = input.selectionStart ?? input.value.length;\n const { options, from, to } = suggestFor(input.value, caret);\n const list = suggestionList();\n if (options.length === 0 || input.value.slice(from, to) === options[0]) {\n closeSuggestions(input);\n return;\n }\n clear(list);\n highlighted = -1;\n options.slice(0, 12).forEach((option, index) => {\n const item = el("li", null, option);\n item.setAttribute("role", "option");\n item.setAttribute("aria-selected", "false");\n item.addEventListener("mousedown", (event) => {\n event.preventDefault();\n apply(input, option, from, to);\n });\n item.dataset["index"] = String(index);\n list.appendChild(item);\n });\n list.hidden = false;\n input.setAttribute("aria-expanded", "true");\n }\n function apply(input, option, from, to) {\n const before = input.value.slice(0, from);\n const after = input.value.slice(to);\n input.value = `${before}${option}${after}`;\n const caret = before.length + option.length;\n input.setSelectionRange(caret, caret);\n setSearch(input.value.trim());\n app.syncUrl();\n app.drawNow();\n closeSuggestions(input);\n showSuggestions(input);\n input.focus();\n }\n function initSuggestions(input) {\n input.addEventListener("keydown", (event) => {\n const list = suggestionList();\n const items = Array.from(list.querySelectorAll("li"));\n if (list.hidden || items.length === 0) return;\n if (event.key === "Escape") {\n closeSuggestions(input);\n return;\n }\n if (event.key === "ArrowDown" || event.key === "ArrowUp") {\n event.preventDefault();\n highlighted = (highlighted + (event.key === "ArrowDown" ? 1 : items.length - 1) + (highlighted === -1 && event.key === "ArrowUp" ? 1 : 0)) % items.length;\n items.forEach((item, index) => item.setAttribute("aria-selected", String(index === highlighted)));\n return;\n }\n if ((event.key === "Enter" || event.key === "Tab") && highlighted >= 0) {\n const chosen = items[highlighted]?.textContent ?? "";\n const caret = input.selectionStart ?? input.value.length;\n const { from, to } = suggestFor(input.value, caret);\n event.preventDefault();\n apply(input, chosen, from, to);\n }\n });\n input.addEventListener("blur", () => {\n setTimeout(() => closeSuggestions(input), 120);\n });\n input.addEventListener("focus", () => showSuggestions(input));\n }\n function initSavedFilters(input) {\n const host = $("saved-filters");\n const redraw = () => {\n clear(host);\n const entries = savedFilters();\n const select2 = document.createElement("select");\n select2.setAttribute("aria-label", "Saved filters");\n const first = document.createElement("option");\n first.value = "";\n first.textContent = entries.length === 0 ? "No saved filters" : "Saved filters\u2026";\n select2.appendChild(first);\n for (const entry of entries) {\n const option = document.createElement("option");\n option.value = entry.name;\n option.textContent = entry.name;\n select2.appendChild(option);\n }\n select2.addEventListener("change", () => {\n const chosen = entries.find((entry) => entry.name === select2.value);\n if (chosen === void 0) return;\n input.value = chosen.query;\n setSearch(chosen.query);\n state.filter = chosen.filter;\n reflectFilterButtons();\n resetPaging();\n app.syncUrl();\n app.drawNow();\n });\n host.appendChild(select2);\n const save = el("button", null, "Save");\n save.type = "button";\n save.title = "Save this filter, in this browser, under a name";\n save.addEventListener("click", () => {\n const name = prompt("Save this filter as:")?.trim();\n if (name === void 0 || name === "") return;\n saveFilter({ name, query: input.value.trim(), filter: state.filter });\n redraw();\n toast("ok", "Filter saved", `"${name}" is in this browser. It is not shared with anybody else.`);\n });\n host.appendChild(save);\n if (select2.value !== "") {\n const remove = el("button", null, "Delete");\n remove.type = "button";\n remove.addEventListener("click", () => {\n deleteFilter(select2.value);\n redraw();\n });\n host.appendChild(remove);\n }\n };\n redraw();\n }\n function initTimeframe() {\n const from = byId("from-at");\n const to = byId("to-at");\n const clearButton = byId("timeframe-clear");\n const read2 = (input) => {\n if (input.value === "") return void 0;\n const parsed = new Date(input.value).getTime();\n return Number.isFinite(parsed) ? parsed : void 0;\n };\n const apply2 = () => {\n const start2 = read2(from);\n const end = read2(to);\n if (start2 !== void 0 && end !== void 0 && end < start2) {\n toast("warn", "That window runs backwards", "The end is before the start, so nothing can fall inside it.");\n }\n setTimeframe(start2, end);\n clearButton.hidden = start2 === void 0 && end === void 0;\n app.drawNow();\n };\n from.addEventListener("change", apply2);\n to.addEventListener("change", apply2);\n clearButton.addEventListener("click", () => {\n from.value = "";\n to.value = "";\n apply2();\n });\n }\n var FEED_PAGE_SIZES = [25, 50, 100, 200];\n function drawPager(paged) {\n const size = state.feedPageSize;\n const hidden = paged.pages <= 1;\n const model = {\n page: paged.page,\n from: paged.page * size + 1,\n to: Math.min(paged.total, (paged.page + 1) * size),\n total: paged.total,\n atStart: paged.page === 0,\n atEnd: paged.page >= paged.pages - 1,\n ...paged.page > 0 ? { held: "held while you read" } : {},\n go: (page) => {\n goToFeedPage(page);\n app.drawNow();\n },\n size: {\n current: size,\n choices: FEED_PAGE_SIZES,\n set: (next) => {\n state.feedPageSize = next;\n resetPaging();\n app.drawNow();\n }\n }\n };\n for (const [id, withSize] of [\n ["feed-pager-top", true],\n ["feed-pager", false]\n ]) {\n const host = $(id);\n host.hidden = hidden;\n if (hidden) clear(host);\n else renderPager(host, model, { withSize });\n }\n }\n function drawFeed() {\n const body = byId("rows");\n const paged = feedPage(state.feedPageSize);\n const shown = paged.rows;\n let index = 0;\n const place = (node) => {\n const current = body.childNodes[index] ?? null;\n if (current !== node) body.insertBefore(node, current);\n index++;\n };\n for (const row of shown) {\n const id = row.entry.requestId;\n const open = state.open.has(id);\n let cached = rendered.get(id);\n if (cached === void 0 || cached.rev !== row.rev || cached.open !== open) {\n cached = {\n row: buildRow(row.entry, open),\n detail: open ? buildDetail(row.entry) : void 0,\n rev: row.rev,\n open\n };\n rendered.set(id, cached);\n }\n place(cached.row);\n if (cached.detail !== void 0) place(cached.detail);\n }\n while (body.childNodes.length > index) body.removeChild(body.childNodes[index]);\n if (rendered.size > shown.length * 2 + 100) {\n const live = new Set(shown.map((row) => row.entry.requestId));\n for (const id of Array.from(rendered.keys())) if (!live.has(id)) rendered.delete(id);\n }\n const total = state.rows.length;\n const matching = matchingCount();\n $("empty").hidden = total > 0;\n $("feed-count").textContent = matching === total ? `${n(total)} in this window` : `${n(matching)} of ${n(total)}`;\n drawPager(paged);\n const skipped = Math.max(0, (state.snapshot?.skipped ?? 0) + state.laggedDrops - state.caughtUp);\n const note = $("feed-skipped");\n note.hidden = skipped === 0;\n note.textContent = `${n(skipped)} not streamed`;\n byId("feed-load-skipped").hidden = skipped === 0;\n note.title = state.laggedDrops > 0 ? `${n(state.laggedDrops)} were skipped because this connection could not keep up, and the rest by the rate cap. All of them are still in the window, the preview and the export.` : "Entries the rate cap kept off this stream. They are still in the window, the preview and the export \u2014 raise maxEventsPerSecond to see them live.";\n }\n function resetFeedCache() {\n rendered.clear();\n }\n function buildRow(entry, open) {\n const out = outcome(entry);\n const tr = el("tr", `row a-${entry.downgradedFrom !== void 0 ? "guard" : out}${open ? " open" : ""}`);\n tr.appendChild(el("td", "num mono tnum when", clockTime(entry.at)));\n const request = el("td", "edge req");\n const toggle = el("button", "row-toggle", `${entry.method} ${entry.path}`);\n toggle.type = "button";\n toggle.setAttribute("aria-expanded", String(open));\n toggle.setAttribute("aria-label", `${entry.method} ${entry.path}, ${entry.verdict}. Evidence.`);\n toggle.dataset["request"] = entry.requestId;\n request.appendChild(toggle);\n const ua = el("span", "ua");\n if (SECTIONS.actors) {\n const actorLink = el("a", null, entry.actor);\n actorLink.href = "#actor";\n actorLink.title = "Show everything from this actor";\n actorLink.addEventListener("click", (event) => {\n event.preventDefault();\n event.stopPropagation();\n openActor(entry.actor);\n });\n ua.appendChild(actorLink);\n ua.appendChild(document.createTextNode(` \xB7 ${entry.userAgent}`));\n } else {\n ua.appendChild(document.createTextNode(`${entry.actor} \xB7 ${entry.userAgent}`));\n }\n ua.title = `${entry.actor} \xB7 ${entry.userAgent}`;\n request.appendChild(ua);\n tr.appendChild(request);\n const verdictCell = el("td");\n const [badgeClass, badgeLabel] = verdictBadge(entry);\n verdictCell.appendChild(el("span", `badge ${badgeClass}`, badgeLabel));\n if (entry.identity !== void 0) verdictCell.appendChild(el("span", "sub", entry.identity));\n tr.appendChild(verdictCell);\n tr.appendChild(el("td", "num mono tnum", entry.certain ? "proven" : String(entry.score)));\n const actionCell = el("td");\n if (entry.action !== void 0) {\n const kind = out === "deny" ? "act-deny" : out === "mitigate" ? "act-mitigate" : out === "allow" ? "act-allow" : "act-tag";\n actionCell.appendChild(el("span", `act ${kind}`, entry.action));\n if (entry.rule !== void 0) {\n const ruleLabel = el("span", "sub", entry.rule);\n ruleLabel.title = entry.rule;\n actionCell.appendChild(ruleLabel);\n }\n if (entry.downgradedFrom !== void 0) actionCell.appendChild(el("span", "guard", `guard stopped ${entry.downgradedFrom}`));\n } else {\n actionCell.appendChild(el("span", "sub", "assessed only"));\n }\n tr.appendChild(actionCell);\n tr.appendChild(el("td", "num mono tnum", entry.durationMs.toFixed(2)));\n tr.dataset["request"] = entry.requestId;\n const flip = () => {\n if (state.open.has(entry.requestId)) state.open.delete(entry.requestId);\n else state.open.add(entry.requestId);\n app.drawNow();\n rootNode().querySelector(`button.row-toggle[data-request="${cssEscape(entry.requestId)}"]`)?.focus();\n };\n toggle.addEventListener("click", (event) => {\n event.stopPropagation();\n flip();\n });\n tr.addEventListener("click", flip);\n return tr;\n }\n function buildDetail(entry) {\n const tr = el("tr", "detail");\n const cell = el("td");\n cell.colSpan = 6;\n if (!SECTIONS.evidence) {\n cell.appendChild(\n el(\n "div",\n "ev-meta",\n "The evidence section is switched off on this dashboard, so the reasons behind this verdict are not sent to it. What fired, and why, is on a dashboard that has `sections: { evidence: true }`."\n )\n );\n } else if (entry.evidence.length === 0) {\n cell.appendChild(\n el(\n "div",\n "ev-meta",\n entry.bypass !== void 0 ? `Detection was skipped for this request: ${entry.bypass}.` : "No detector produced any evidence. This is what ordinary traffic looks like."\n )\n );\n } else {\n const list = el("div", "ev");\n for (const item of entry.evidence) {\n const row = el("div", `ev-item${item.direction === "human" ? " human" : ""}${item.shadow === true ? " shadow" : ""}`);\n row.appendChild(el("div", `tier t-${item.certainty}`, item.certainty));\n const body = el("div");\n body.appendChild(el("div", null, item.summary));\n let meta = `${item.detector} \xB7 points to ${item.direction}`;\n if (item.family !== void 0) meta += ` \xB7 family \u201C${item.family}\u201D, counted once with its siblings`;\n if (item.shadow === true) meta += " \xB7 shadowed: counted, and part of no decision";\n body.appendChild(el("div", "ev-meta", meta));\n if (item.deterministicBasis !== void 0) body.appendChild(el("div", "basis", item.deterministicBasis));\n row.appendChild(body);\n list.appendChild(row);\n }\n cell.appendChild(list);\n }\n if (entry.shadowVerdict !== void 0) {\n const would = entry.shadowVerdict;\n const changed = would.verdict !== entry.verdict;\n cell.appendChild(\n el(\n "div",\n `ev-meta shadow-verdict${changed ? " changed" : ""}`,\n changed ? `With the shadowed detectors counted, this request would have been ${would.verdict} at ${would.score} instead of ${entry.verdict} at ${entry.score}.` : `With the shadowed detectors counted, this request would still have been ${would.verdict}${would.score === entry.score ? "" : `, at ${would.score} rather than ${entry.score}`}.`\n )\n );\n }\n for (const failure of entry.failures) {\n cell.appendChild(el("div", "ev-meta", `Detector ${failure.detector} ${failure.reason}: ${failure.message}`));\n }\n if (entry.downgradeReason !== void 0) cell.appendChild(el("div", "basis", `Guard: ${entry.downgradeReason}`));\n const queryNames = Object.keys(entry.query);\n if (queryNames.length > 0) {\n const queryTable = el("table", "hdr");\n for (const name of queryNames) {\n const value = entry.query[name] ?? "";\n const row = el("tr");\n row.appendChild(el("td", "n", `?${name}`));\n row.appendChild(el("td", `v${value === "[redacted]" ? " red" : ""}`, value));\n queryTable.appendChild(row);\n }\n cell.appendChild(queryTable);\n }\n if (entry.headers !== void 0 && entry.headers.length > 0) {\n const table = el("table", "hdr");\n for (const [name, value] of entry.headers) {\n const row = el("tr");\n row.appendChild(el("td", "n", `${name}:`));\n row.appendChild(el("td", `v${value === "[redacted]" ? " red" : ""}`, value));\n table.appendChild(row);\n }\n cell.appendChild(table);\n }\n const tools = el("div", "tools");\n if (SECTIONS.evidence) {\n tools.appendChild(copyButton("Copy replay line", () => replayLine(entry)));\n tools.appendChild(downloadButton("Download replay line", () => replayLine(entry), `request-${entry.requestId}.jsonl`));\n tools.appendChild(copyButton("Copy corpus case", () => corpusCase(entry)));\n }\n if (SECTIONS.policy) {\n const draft2 = el("button", null, "Draft a rule");\n draft2.title = "Start a rule from this request, in the policy editor";\n draft2.addEventListener("click", (event) => {\n event.stopPropagation();\n void draftIntoEditor(entry);\n });\n tools.appendChild(draft2);\n }\n if (SECTIONS.actors) {\n const actorButton = el("button", null, "Show this actor");\n actorButton.addEventListener("click", (event) => {\n event.stopPropagation();\n openActor(entry.actor);\n });\n tools.appendChild(actorButton);\n }\n cell.appendChild(tools);\n const foot = el("div", "detail-foot");\n foot.appendChild(el("span", null, clockTime(entry.at)));\n foot.appendChild(el("span", null, `actor ${entry.actor}`));\n if (entry.rule !== void 0) foot.appendChild(el("span", null, `rule \u201C${entry.rule}\u201D`));\n foot.appendChild(el("span", null, `assessed in ${entry.durationMs.toFixed(3)}ms`));\n foot.appendChild(el("span", "mono", entry.requestId));\n cell.appendChild(foot);\n tr.appendChild(cell);\n return tr;\n }\n function copyButton(label2, produce) {\n const button = el("button", null, label2);\n button.addEventListener("click", (event) => {\n event.stopPropagation();\n const text = produce();\n const done = () => {\n button.textContent = "Copied";\n setTimeout(() => {\n button.textContent = label2;\n }, 1200);\n };\n if (navigator.clipboard?.writeText !== void 0) navigator.clipboard.writeText(text).then(done, () => showText(text));\n else showText(text);\n });\n return button;\n }\n function downloadButton(label2, produce, filename) {\n const button = el("button", null, label2);\n button.addEventListener("click", (event) => {\n event.stopPropagation();\n download(`${produce()}\n`, filename, "application/x-ndjson");\n });\n return button;\n }\n function showText(text) {\n const box = el("pre", "code", text);\n const host = $("policy-result");\n host.hidden = false;\n host.className = "result";\n clear(host);\n host.appendChild(el("div", "ev-meta", "Copying needs a secure context; here is the text."));\n host.appendChild(box);\n const selection = getSelection();\n if (selection !== null) {\n const range = document.createRange();\n range.selectNodeContents(box);\n selection.removeAllRanges();\n selection.addRange(range);\n }\n }\n\n // src/dashboard/client/stream.ts\n var source;\n function connectStream() {\n if (!SECTIONS.feed) {\n $("dot").className = "dot";\n $("conn").textContent = "feed off";\n return;\n }\n if (source !== void 0) return;\n source = new EventSource(`${API}/api/stream`);\n source.addEventListener("open", () => {\n $("dot").className = "dot on";\n $("conn").textContent = "live";\n });\n source.addEventListener("sync", (event) => {\n const detail = JSON.parse(event.data);\n if (!detail.replace) return;\n clearFeed();\n resetFeedCache();\n });\n source.addEventListener("entry", (event) => {\n ingest(JSON.parse(event.data));\n if (state.paused) {\n state.bufferedWhilePaused++;\n $("pause").textContent = `Resume (${state.bufferedWhilePaused})`;\n }\n app.draw();\n });\n source.addEventListener("update", (event) => {\n ingest(JSON.parse(event.data));\n app.draw();\n });\n source.addEventListener("reset", () => {\n clearFeed();\n resetFeedCache();\n app.draw();\n });\n source.addEventListener("lagged", (event) => {\n const detail = JSON.parse(event.data);\n state.laggedDrops += detail.dropped;\n app.draw();\n });\n source.addEventListener("stats", (event) => {\n state.snapshot = JSON.parse(event.data);\n app.draw();\n });\n source.addEventListener("error", () => {\n $("dot").className = "dot off";\n $("conn").textContent = "reconnecting\u2026";\n });\n }\n async function loadInitialSnapshot() {\n try {\n state.snapshot = await getJson("/api/stats");\n app.drawNow();\n } catch {\n }\n }\n\n // src/dashboard/client/panels.ts\n var paintedSnapshot;\n function drawTiles(force = false) {\n if (!SECTIONS.statistics) return;\n const box = $("tiles");\n const snapshot = state.snapshot;\n if (snapshot === void 0) return;\n if (!force && paintedSnapshot === snapshot) return;\n paintedSnapshot = snapshot;\n const metrics = snapshot.metrics;\n clear(box);\n if (metrics === void 0) {\n box.appendChild(el("div", "note", "Counters are switched off on this handler (metrics: false). The live feed still works."));\n return;\n }\n const actions = metrics.actions;\n const denied = actions.block + actions.drop + actions.redirect;\n const mitigated = actions.challenge + actions["rate-limit"] + actions.delay;\n const served = actions.allow + actions.tag + actions.log;\n const total = metrics.requests;\n const unremarkable = metrics.verdicts.unknown + metrics.verdicts.human;\n const provenBotCount = provenBots(metrics.verdicts);\n const actors = SECTIONS.registry ? { tab: "actors", label: "Actors" } : void 0;\n const tiles = [\n ["", n(total), "Requests", "since start", void 0],\n ["proven", n(provenBotCount), "Proven bots", `${pct(provenBotCount, total)} of traffic`, void 0],\n ["warn", n(metrics.verdicts["suspected-bot"]), "Suspected", "never denied alone", void 0],\n ["", n(unremarkable), "Unremarkable", `${pct(unremarkable, total)} of traffic`, void 0],\n ["warn", n(metrics.downgrades), "Guard stops", metrics.downgrades > 0 ? "a rule over-reached" : "no rule overreached", void 0],\n ["crit", n(denied), "Denied", `${pct(denied, total)} of traffic`, void 0],\n [\n "",\n n(mitigated),\n "Mitigated",\n metrics.challenges.issued > 0 ? `${n(metrics.challenges.solved)} of ${n(metrics.challenges.issued)} challenges solved` : "challenged or limited",\n void 0\n ],\n ["good", n(served), "Served", `${pct(served, total)} of traffic`, void 0],\n ["", n(metrics.actorsTracked), "Actors tracked", "in the registry now", actors]\n ];\n for (const [kind, value, key, sub, goes] of tiles) {\n const tile = goes === void 0 ? el("div", `tile ${kind}`) : el("button", `tile ${kind} go`);\n tile.appendChild(el("div", "v tnum", value));\n tile.appendChild(el("div", "k", key));\n tile.appendChild(el("div", "s", sub));\n if (goes !== void 0) {\n tile.type = "button";\n tile.appendChild(el("span", "sr-only", `. Show the ${goes.label} screen`));\n tile.addEventListener("click", () => app.showTab(goes.tab, { replace: false }));\n }\n box.appendChild(tile);\n }\n }\n function drawChips() {\n const snapshot = state.snapshot;\n if (snapshot === void 0) return;\n const box = $("chips");\n clear(box);\n const facts = [\n // Which process this is, first, because everything after it is a fact about this\n // process and nothing else. Behind a load balancer there are as many of these\n // dashboards as there are pods, each showing its own share of the traffic.\n ["instance", snapshot.instance],\n ["guard", snapshot.policy.falsePositivePolicy],\n ["suspect at", String(snapshot.policy.suspectThreshold)]\n ];\n if (SECTIONS.statistics) facts.push(["detectors", String(snapshot.detectors.length)]);\n if (SECTIONS.policy) facts.push(["rules", String(snapshot.rules.length)]);\n facts.push(["uptime", uptime(snapshot.now - snapshot.startedAt)]);\n for (const [label2, value] of facts) {\n const fact = el("span");\n fact.appendChild(document.createTextNode(`${label2} `));\n fact.appendChild(el("b", null, value));\n box.appendChild(fact);\n }\n }\n function updateWindowLabels() {\n const label2 = windowLabel(state.rows.length, oldestAt(), Date.now());\n for (const node of Array.from(rootNode().querySelectorAll(".win"))) node.textContent = label2;\n }\n function drawLivePanels() {\n if (!SECTIONS.statistics) return;\n const totals = aggregate(state.rows);\n drawBars($("live-detectors"), totals.detectors, "Nothing has fired in this window.");\n if (SECTIONS.actors) drawBars($("live-actors"), totals.actors, "No traffic in this window.");\n }\n function drawStatsPanels() {\n const snapshot = state.snapshot;\n if (snapshot === void 0) return;\n const metrics = snapshot.metrics;\n if (metrics !== void 0) {\n drawBars($("stat-verdicts"), pairs(metrics.verdicts), "Nothing assessed yet.");\n drawBars($("stat-actions"), pairs(metrics.actions), "No decisions yet.");\n drawBars($("stat-classes"), pairs(metrics.botClasses), "Nothing classified yet.");\n drawBars($("stat-detectors"), pairs(metrics.detectorFirings), "No detector has produced evidence yet.");\n const challenges = $("stat-challenges");\n clear(challenges);\n if (!snapshot.policy.challengeEnabled) {\n challenges.appendChild(el("div", "note", "No challenge is configured, so a rule asking for one degrades to a tag. Set challenge.secrets to enable it."));\n } else {\n const funnel = [\n ["Issued", n(metrics.challenges.issued)],\n ["Solved", n(metrics.challenges.solved)],\n ["Rejected", n(metrics.challenges.rejected)],\n ["Solve rate", metrics.challenges.issued > 0 ? pct(metrics.challenges.solved, metrics.challenges.issued) : "\u2014"]\n ];\n for (const [key, value] of funnel) challenges.appendChild(statRow(key, value));\n }\n const health = $("stat-health");\n clear(health);\n const bypassed = metrics.bypassed.allowlist + metrics.bypassed["ignored-path"];\n const rows = [\n ["Requests assessed", n(metrics.requests)],\n ["Bypassed \u2014 allowlist", n(metrics.bypassed.allowlist)],\n ["Bypassed \u2014 ignored path", n(metrics.bypassed["ignored-path"])],\n ["Detection ran on", pct(metrics.requests - bypassed, metrics.requests)],\n ["Actors tracked", n(metrics.actorsTracked)],\n ["Guard stops", n(metrics.downgrades)]\n ];\n const shadowed = pairs(metrics.shadowFirings);\n if (shadowed.length > 0) {\n const moved = Object.entries(metrics.shadowChanges).filter(([, count]) => count > 0);\n rows.push(["Shadowed findings", n(shadowed.reduce((total, [, count]) => total + count, 0))]);\n for (const [verdict, count] of moved) rows.push([`Would have become ${verdict}`, n(count)]);\n if (moved.length === 0) rows.push(["Verdicts they would have changed", "none"]);\n }\n const failures = pairs(metrics.detectorFailures);\n for (const [detector, count] of failures) rows.push([`Detector failures \u2014 ${detector}`, n(count)]);\n for (const [key, value] of rows) health.appendChild(statRow(key, value));\n if (failures.length === 0) health.appendChild(el("div", "note", "No detector has thrown or timed out."));\n }\n const totals = aggregate(state.rows);\n if (SECTIONS.actors) drawBars($("stat-identities"), totals.identities, "No client has named itself in this window.");\n drawBars($("stat-paths"), totals.paths, "No traffic in this window.");\n drawBars($("stat-denied-paths"), totals.deniedPaths, "Nothing has been denied in this window.");\n drawBars($("stat-guard"), totals.guardStops, "No rule has asked for more than its evidence supports.");\n drawBars($("stat-bypassed"), totals.bypassed, "Nothing bypassed detection.");\n drawRuleHits(totals);\n const list = $("stat-detector-list");\n clear(list);\n $("detector-count").textContent = `${snapshot.detectors.length} installed`;\n for (const detector of snapshot.detectors) {\n const row = el("div", `det${detector.shadow === true ? " shadow" : ""}`);\n const left = el("div");\n left.appendChild(el("div", "mono", detector.id));\n left.appendChild(el("div", "d", detector.description));\n row.appendChild(left);\n const fires = (detector.shadow === true ? metrics?.shadowFirings[detector.id] : metrics?.detectorFirings[detector.id]) ?? 0;\n const timing = metrics?.detectorTimings[detector.id];\n let right = `${n(fires)}${detector.shadow === true ? " shadowed" : ""} \xB7 ${detector.cost} \xB7 ${detector.stage}`;\n if (timing !== void 0 && timing.count > 0) right += ` \xB7 ${ms(timing.totalMs / timing.count)} avg`;\n row.appendChild(el("div", "n", right));\n list.appendChild(row);\n }\n }\n function statRow(key, value) {\n const line = el("div", "stat-row");\n line.appendChild(el("span", "k", key));\n line.appendChild(el("span", "v", value));\n return line;\n }\n function drawRuleHits(totals) {\n const target = $("stat-rule-hits");\n clear(target);\n const rules = state.snapshot?.rules ?? [];\n if (rules.length === 0) {\n target.appendChild(el("div", "note", "No rules configured."));\n return;\n }\n let max = 1;\n for (const rule of rules) max = Math.max(max, totals.ruleHits.get(rule) ?? 0);\n for (const rule of rules) {\n const value = totals.ruleHits.get(rule) ?? 0;\n const bar = el("div", `bar${value === 0 ? " dead" : ""}`);\n const track = el("div", "track");\n if (value > 0) {\n const fill = el("div", "fill");\n fill.style.width = `${Math.max(2, Math.round(value / max * 100))}%`;\n track.appendChild(fill);\n }\n track.appendChild(el("div", "lbl", rule));\n bar.appendChild(track);\n bar.appendChild(el("div", "v tnum", value === 0 ? "never" : n(value)));\n target.appendChild(bar);\n }\n }\n function drawAudit() {\n const snapshot = state.snapshot;\n if (snapshot === void 0) return;\n const body = $("audit-body");\n const checks = $("audit-checks");\n clear(body);\n clear(checks);\n const audit = snapshot.audit;\n if (audit === void 0) {\n $("audit-spans").textContent = "";\n body.appendChild(\n el(\n "div",\n "note",\n "The traffic audit is switched off on this handler (audit: false). It watches the shape of your traffic rather than any one request \u2014 a spike in automation, a collapse in human traffic, a policy suddenly denying far more than usual."\n )\n );\n return;\n }\n const current = audit.window;\n const baseline = audit.baseline;\n $("audit-spans").textContent = `last ${rangeLabel(current.spanMs)} against the ${rangeLabel(baseline.spanMs)} before`;\n const rows = [\n ["Requests", n(current.requests), n(baseline.requests)],\n ["Rate", `${current.rate.toFixed(1)}/min`, `${baseline.rate.toFixed(1)}/min`],\n ["Bot share", `${Math.round(current.botShare * 100)}%`, `${Math.round(baseline.botShare * 100)}%`],\n ["Bots", n(current.bots), n(baseline.bots)],\n ["Humans", n(current.humans), n(baseline.humans)],\n ["Denials", n(current.denials), n(baseline.denials)],\n ["Challenges", n(current.challenges), n(baseline.challenges)],\n ["Guard stops", n(current.downgrades), n(baseline.downgrades)],\n ["Detector failures", n(current.failures), n(baseline.failures)],\n ["Bypassed", n(current.bypassed), n(baseline.bypassed)]\n ];\n for (const [key, now, was] of rows) {\n const line = el("div", "stat-row");\n line.appendChild(el("span", "k", key));\n const values = el("span", "v");\n values.appendChild(el("b", null, now));\n values.appendChild(el("span", "was", ` was ${was}`));\n line.appendChild(values);\n body.appendChild(line);\n }\n if (audit.checks.length === 0) {\n checks.appendChild(el("div", "note", "No checks are installed, so nothing here will ever raise an anomaly."));\n return;\n }\n for (const check of audit.checks) {\n const row = el("div", "det");\n const left = el("div");\n left.appendChild(el("div", "mono", check.id));\n left.appendChild(el("div", "d", check.description));\n row.appendChild(left);\n checks.appendChild(row);\n }\n }\n function drawNoticeBadge() {\n const notices = state.snapshot?.notices ?? [];\n const badge = $("notice-badge");\n badge.hidden = notices.length === 0 || !SECTIONS.notices;\n badge.textContent = String(notices.length);\n }\n function drawNotices() {\n const box = $("stat-notices");\n const notices = state.snapshot?.notices ?? [];\n clear(box);\n $("notice-count").textContent = notices.length > 0 ? `${notices.length} total` : "";\n if (notices.length === 0) {\n box.appendChild(el("div", "note", "Nothing to report: no startup warnings, no detector errors."));\n return;\n }\n for (const notice of notices.slice().reverse().slice(0, 40)) {\n const row = el("div", `notice ${notice.kind}`);\n const when = el("div", "when");\n when.appendChild(el("div", "tag", notice.kind));\n when.appendChild(el("div", null, clockTime(notice.at)));\n row.appendChild(when);\n const body = el("div");\n body.appendChild(el("div", null, notice.message));\n if (notice.source !== void 0) body.appendChild(el("div", "ev-meta", notice.source));\n row.appendChild(body);\n box.appendChild(row);\n }\n }\n function drawChanges() {\n if (!SECTIONS.changes) return;\n const box = $("stat-changes");\n const changes = state.snapshot?.changes ?? [];\n clear(box);\n $("change-count").textContent = changes.length > 0 ? `${changes.length} this run` : "";\n if (changes.length === 0) {\n box.appendChild(el("div", "note", "Nothing has been changed at runtime. Rules, guard and ranges are as the code that built this handler left them."));\n return;\n }\n for (const change of changes.slice().reverse()) {\n const row = el("div", "notice");\n const when = el("div", "when");\n when.appendChild(el("div", "tag", change.kind));\n when.appendChild(el("div", null, clockTime(change.at)));\n row.appendChild(when);\n const body = el("div");\n body.appendChild(el("div", null, change.summary));\n body.appendChild(el("div", "ev-meta", change.by === void 0 || change.by === "" ? "by an unnamed viewer \u2014 this listener\'s auth carries no identity" : `by ${change.by}`));\n row.appendChild(body);\n box.appendChild(row);\n }\n }\n function drawPeers() {\n const box = $("peers");\n if (BOOT.peers.length === 0) {\n box.hidden = true;\n return;\n }\n if (box.childElementCount > 0) return;\n box.appendChild(el("span", "hint", "also:"));\n for (const peer of BOOT.peers) {\n const link = el("a", "linkbtn", peer.label);\n link.href = peer.href;\n link.rel = "noreferrer noopener";\n box.appendChild(link);\n }\n }\n\n // src/dashboard/client/charts.ts\n var BUCKETS = 60;\n var LATENCY_BOUNDS = [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 25, 50, 100];\n var SCORE_BOUNDS = 10;\n function positionTip(tip, host, clientX, boxLeft) {\n tip.style.opacity = "1";\n tip.style.left = `${Math.min(host.clientWidth - 150, Math.max(4, clientX - boxLeft - 60))}px`;\n tip.style.top = "14px";\n }\n function tipRow(label2, value, colour) {\n const line = el("div", "r");\n const left = el("em");\n if (colour !== void 0) {\n const swatch = el("span", "swatch");\n swatch.style.background = colour;\n left.appendChild(swatch);\n }\n left.appendChild(document.createTextNode(label2));\n line.appendChild(left);\n line.appendChild(el("b", null, value));\n return line;\n }\n function timeline() {\n const bucketMs = state.rangeMs / BUCKETS;\n const now = Date.now();\n const start2 = now - state.rangeMs;\n const buckets = [];\n for (let i = 0; i < BUCKETS; i++) buckets.push({ at: start2 + i * bucketMs, served: 0, mitigated: 0, denied: 0, total: 0 });\n for (const { entry } of state.rows) {\n const index = Math.floor((entry.at - start2) / bucketMs);\n if (index < 0 || index >= BUCKETS) continue;\n const bucket = buckets[index];\n if (bucket === void 0) continue;\n bucket.total++;\n const out = outcome(entry);\n if (out === "deny") bucket.denied++;\n else if (out === "mitigate") bucket.mitigated++;\n else bucket.served++;\n }\n return buckets;\n }\n function drawTraffic() {\n const host = $("traffic-chart");\n const svg = $("traffic");\n const width = Math.max(320, host.clientWidth - 30);\n const height = 190;\n const padBottom = 26;\n const markerRow = 8;\n const plot = height - padBottom - markerRow;\n const buckets = timeline();\n const now = Date.now();\n const start2 = now - state.rangeMs;\n let peak = 1;\n for (const bucket of buckets) if (bucket.total > peak) peak = bucket.total;\n const max = Math.max(2, Math.ceil(peak / 2) * 2);\n clear(svg);\n svg.setAttribute("viewBox", `0 0 ${width} ${height}`);\n svg.setAttribute("height", String(height));\n const s1 = css("--s1");\n const s2 = css("--s2");\n const crit = css("--crit");\n const grid = css("--grid");\n const muted = css("--muted");\n const step = width / BUCKETS;\n const barWidth = Math.max(2, step - 2);\n for (const fraction of [0, 0.5, 1]) {\n const y = markerRow + plot - fraction * plot;\n svg.appendChild(svgEl("line", { class: "gridline", x1: 0, x2: width, y1: y, y2: y, stroke: grid, "stroke-width": 1 }));\n if (fraction > 0) svg.appendChild(svgText({ x: 2, y: y - 3, fill: muted, "font-size": 10 }, Math.round(max * fraction)));\n }\n buckets.forEach((bucket, index) => {\n const x = index * step + 1;\n const servedHeight = bucket.served / max * plot;\n const mitigatedHeight = bucket.mitigated / max * plot;\n let y = markerRow + plot;\n if (servedHeight > 0) {\n y -= servedHeight;\n svg.appendChild(svgEl("rect", { x, y, width: barWidth, height: servedHeight, fill: s1, rx: 2 }));\n }\n if (mitigatedHeight > 0) {\n y -= mitigatedHeight + (servedHeight > 0 ? 2 : 0);\n svg.appendChild(svgEl("rect", { x, y: Math.max(markerRow, y), width: barWidth, height: mitigatedHeight, fill: s2, rx: 2 }));\n }\n if (bucket.denied > 0) svg.appendChild(svgEl("rect", { x, y: 0, width: barWidth, height: 5, fill: crit, rx: 2 }));\n });\n svg.appendChild(svgEl("line", { x1: 0, x2: width, y1: markerRow + plot, y2: markerRow + plot, stroke: css("--line"), "stroke-width": 1 }));\n const span = rangeLabel(state.rangeMs);\n const labels = [\n [0, `${span} ago`],\n [BUCKETS / 2, rangeLabel(state.rangeMs / 2)],\n [BUCKETS - 1, "now"]\n ];\n for (const [position, text] of labels) {\n svg.appendChild(svgText({ x: Math.min(width - 26, Math.max(0, position * step)), y: height - 8, fill: muted, "font-size": 10 }, text));\n }\n const changes = (state.snapshot?.changes ?? []).filter((change) => change.at >= start2 && change.at <= now);\n const markColour = css("--proven-text");\n for (const change of changes) {\n const x = (change.at - start2) / state.rangeMs * width;\n svg.appendChild(svgEl("line", { x1: x, x2: x, y1: 0, y2: markerRow + plot, stroke: markColour, "stroke-width": 1.5, "stroke-dasharray": "3 2", opacity: 0.85 }));\n const dot = svgEl("circle", { cx: x, cy: 3, r: 3, fill: markColour });\n const title = svgEl("title");\n title.textContent = `${clockTime(change.at)} \xB7 ${change.kind}: ${change.summary}${change.by === void 0 ? "" : ` (by ${change.by})`}`;\n dot.appendChild(title);\n svg.appendChild(dot);\n }\n const hover = svgEl("rect", { x: 0, y: 0, width: 0, height: markerRow + plot, fill: css("--ink"), opacity: 0.06 });\n svg.appendChild(hover);\n const tip = $("traffic-tip");\n host.onmousemove = (event) => {\n const box = svg.getBoundingClientRect();\n const index = Math.floor((event.clientX - box.left) / box.width * BUCKETS);\n const bucket = buckets[index];\n if (bucket === void 0) {\n tip.style.opacity = "0";\n hover.setAttribute("width", "0");\n return;\n }\n hover.setAttribute("x", String(index * step));\n hover.setAttribute("width", String(step));\n clear(tip);\n tip.appendChild(el("div", "t", `${clockTime(bucket.at)} \xB7 ${Math.round(state.rangeMs / BUCKETS / 1e3)}s`));\n tip.appendChild(tipRow("Served", n(bucket.served), s1));\n tip.appendChild(tipRow("Mitigated", n(bucket.mitigated), s2));\n tip.appendChild(tipRow("Denied", n(bucket.denied), crit));\n positionTip(tip, host, event.clientX, box.left);\n };\n host.onmouseleave = () => {\n tip.style.opacity = "0";\n hover.setAttribute("width", "0");\n };\n const total = buckets.reduce((sum, bucket) => sum + bucket.total, 0);\n $("traffic-window").textContent = `last ${rangeLabel(state.rangeMs)}`;\n const served = buckets.reduce((sum, bucket) => sum + bucket.served, 0);\n const mitigated = buckets.reduce((sum, bucket) => sum + bucket.mitigated, 0);\n const denied = buckets.reduce((sum, bucket) => sum + bucket.denied, 0);\n const busiest = buckets.reduce((best, bucket) => bucket.total > best.total ? bucket : best, buckets[0] ?? { at: now, total: 0, served: 0, mitigated: 0, denied: 0 });\n $("traffic-alt").textContent = `Traffic over the last ${rangeLabel(state.rangeMs)}: ${n(total)} requests \u2014 ${n(served)} served, ${n(mitigated)} mitigated, ${n(denied)} denied. ` + (total === 0 ? "No traffic in this range." : `Busiest ${Math.round(state.rangeMs / BUCKETS / 1e3)}-second interval: ${n(busiest.total)} requests at ${clockTime(busiest.at)}.`) + (changes.length === 0 ? "" : ` ${n(changes.length)} runtime change${changes.length === 1 ? "" : "s"} in this range: ${changes.map((change) => `${change.kind}, ${change.summary}`).join("; ")}.`);\n const legend = $("traffic-legend");\n clear(legend);\n legend.appendChild(el("span", null, `${n(total)} requests in the last ${rangeLabel(state.rangeMs)} \xB7`));\n const oldest = oldestAt();\n if (oldest !== void 0 && Date.now() - oldest < state.rangeMs * 0.9) {\n legend.appendChild(el("span", null, `window holds ${rangeLabel(Date.now() - oldest)} \xB7`));\n }\n for (const [label2, colour] of [\n ["Served", s1],\n ["Mitigated \u2014 challenged, limited or delayed", s2],\n ["Denied", crit]\n ]) {\n const item = el("span");\n const swatch = el("span", "swatch");\n swatch.style.background = colour;\n item.appendChild(swatch);\n item.appendChild(document.createTextNode(label2));\n legend.appendChild(item);\n }\n if (changes.length > 0) {\n const item = el("span");\n item.appendChild(el("span", "mark"));\n item.appendChild(document.createTextNode(`${n(changes.length)} runtime change${changes.length === 1 ? "" : "s"} \u2014 hover a marker`));\n legend.appendChild(item);\n }\n }\n function differences(cumulative) {\n const counts = [];\n for (let i = 0; i < cumulative.length; i++) counts.push((cumulative[i] ?? 0) - (i > 0 ? cumulative[i - 1] ?? 0 : 0));\n return counts;\n }\n function drawScores() {\n const host = $("score-chart");\n const svg = $("scores");\n clear(svg);\n const metrics = state.snapshot?.metrics;\n const fromRun = state.scoreScope === "run" && metrics !== void 0;\n let buckets;\n let scored;\n let proven;\n if (fromRun && metrics !== void 0) {\n buckets = differences(metrics.scores.buckets);\n scored = metrics.scores.count;\n proven = metrics.proven;\n } else {\n buckets = new Array(SCORE_BOUNDS).fill(0);\n scored = 0;\n proven = 0;\n for (const { entry } of state.rows) {\n if (entry.bypass !== void 0) continue;\n if (entry.certain) {\n proven++;\n continue;\n }\n const index = Math.min(SCORE_BOUNDS - 1, Math.floor(entry.score / 10));\n buckets[index] = (buckets[index] ?? 0) + 1;\n scored++;\n }\n }\n const width = Math.max(280, host.clientWidth - 30);\n const height = 190;\n const padBottom = 30;\n const plot = height - padBottom;\n let max = 1;\n for (const value of buckets) if (value > max) max = value;\n svg.setAttribute("viewBox", `0 0 ${width} ${height}`);\n svg.setAttribute("height", String(height));\n const fill = css("--s1");\n const muted = css("--muted");\n const grid = css("--grid");\n const crit = css("--crit");\n svg.appendChild(svgEl("line", { class: "gridline", x1: 0, x2: width, y1: plot, y2: plot, stroke: grid, "stroke-width": 1 }));\n const step = width / SCORE_BOUNDS;\n buckets.forEach((value, index) => {\n const barHeight = value / max * (plot - 8);\n const barWidth = Math.max(2, Math.min(step - 6, 56));\n if (barHeight > 0) {\n svg.appendChild(svgEl("rect", { x: index * step + (step - barWidth) / 2, y: plot - barHeight, width: barWidth, height: barHeight, fill, rx: 3 }));\n }\n svg.appendChild(svgText({ x: index * step + 1, y: height - 14, fill: muted, "font-size": 9.5 }, index * 10));\n });\n const threshold = state.snapshot?.policy.suspectThreshold ?? 60;\n const x = threshold / 100 * width;\n svg.appendChild(svgEl("line", { x1: x, x2: x, y1: 0, y2: plot, stroke: crit, "stroke-width": 2, "stroke-dasharray": "4 3" }));\n svg.appendChild(svgText({ x: Math.min(width - 92, x + 5), y: 11, fill: crit, "font-size": 10 }, `suspect at ${threshold}`));\n svg.appendChild(svgText({ x: 0, y: height - 2, fill: muted, "font-size": 10 }, "score (probabilistic requests only)"));\n const tip = $("score-tip");\n host.onmousemove = (event) => {\n const box = svg.getBoundingClientRect();\n const index = Math.floor((event.clientX - box.left) / box.width * SCORE_BOUNDS);\n const value = buckets[index];\n if (value === void 0) {\n tip.style.opacity = "0";\n return;\n }\n clear(tip);\n tip.appendChild(el("div", "t", `score ${index * 10}\u2013${index * 10 + 9}`));\n tip.appendChild(tipRow("requests", n(value)));\n tip.appendChild(tipRow("share", pct(value, scored)));\n positionTip(tip, host, event.clientX, box.left);\n };\n host.onmouseleave = () => {\n tip.style.opacity = "0";\n };\n let over = 0;\n for (let bucket = 0; bucket < SCORE_BOUNDS; bucket++) if (bucket * 10 >= threshold) over += buckets[bucket] ?? 0;\n const legend = $("score-legend");\n clear(legend);\n legend.appendChild(el("span", null, `${n(scored)} scored \xB7 ${n(over)} at or over the threshold \xB7 ${n(proven)} proven, which carry no score`));\n $("score-alt").textContent = `Distribution of probabilistic scores ${fromRun ? "since start" : "in the retained window"}, with the suspect threshold at ${threshold}. ${n(scored)} scored requests, ${n(over)} at or over the threshold, ${n(proven)} proven and therefore unscored. ` + (scored === 0 ? "Nothing scored yet." : `By ten-point band: ${buckets.map((value, index) => `${index * 10}\u2013${index * 10 + 9}: ${n(value)}`).join(", ")}.`);\n $("score-window").textContent = fromRun ? "since start" : windowLabel(state.rows.length, oldestAt(), Date.now());\n if (state.scoreScope === "run" && metrics === void 0) {\n legend.appendChild(el("span", null, "\xB7 counters are off on this handler, so this is the retained window"));\n }\n }\n function drawLatency() {\n const host = $("latency-chart");\n const svg = $("latency");\n clear(svg);\n const metrics = state.snapshot?.metrics;\n if (metrics === void 0 || metrics.duration.count === 0) {\n $("latency-summary").textContent = "No assessments yet.";\n $("latency-alt").textContent = "Assessment latency: no assessments yet.";\n svg.setAttribute("viewBox", "0 0 100 40");\n svg.setAttribute("height", "40");\n svg.appendChild(svgText({ x: 0, y: 20, fill: css("--muted"), "font-size": 11 }, "No assessments yet."));\n return;\n }\n const cumulative = metrics.duration.buckets;\n const counts = differences(cumulative);\n const width = Math.max(280, host.clientWidth - 30);\n const height = 190;\n const padBottom = 30;\n const plot = height - padBottom;\n let max = 1;\n for (const value of counts) if (value > max) max = value;\n svg.setAttribute("viewBox", `0 0 ${width} ${height}`);\n svg.setAttribute("height", String(height));\n const fill = css("--s1");\n const muted = css("--muted");\n const grid = css("--grid");\n svg.appendChild(svgEl("line", { class: "gridline", x1: 0, x2: width, y1: plot, y2: plot, stroke: grid, "stroke-width": 1 }));\n const step = width / counts.length;\n counts.forEach((value, index) => {\n const barHeight = value / max * (plot - 6);\n if (barHeight > 0) {\n svg.appendChild(svgEl("rect", { x: index * step + 1, y: plot - barHeight, width: Math.max(2, step - 3), height: barHeight, fill, rx: 3 }));\n }\n if (index % 2 === 0) {\n svg.appendChild(svgText({ x: index * step + 1, y: height - 14, fill: muted, "font-size": 9.5 }, index < LATENCY_BOUNDS.length ? String(LATENCY_BOUNDS[index]) : "more"));\n }\n });\n svg.appendChild(svgText({ x: 0, y: height - 2, fill: muted, "font-size": 10 }, "milliseconds (upper bound of each bucket)"));\n const tip = $("latency-tip");\n host.onmousemove = (event) => {\n const box = svg.getBoundingClientRect();\n const index = Math.floor((event.clientX - box.left) / box.width * counts.length);\n const value = counts[index];\n if (value === void 0) {\n tip.style.opacity = "0";\n return;\n }\n clear(tip);\n tip.appendChild(el("div", "t", index < LATENCY_BOUNDS.length ? `\u2264 ${LATENCY_BOUNDS[index]}ms` : "over 100ms"));\n tip.appendChild(tipRow("requests", n(value)));\n tip.appendChild(tipRow("share", pct(value, metrics.duration.count)));\n positionTip(tip, host, event.clientX, box.left);\n };\n host.onmouseleave = () => {\n tip.style.opacity = "0";\n };\n const mean = metrics.duration.totalMs / metrics.duration.count;\n const p95 = percentile(cumulative, metrics.duration.count, 0.95);\n $("latency-summary").textContent = `Time spent in detection, per request \xB7 mean ${ms(mean)} \xB7 p95 ${ms(p95)} \xB7 max ${ms(metrics.duration.maxMs)}`;\n $("latency-alt").textContent = `Assessment latency since start over ${n(metrics.duration.count)} requests: mean ${ms(mean)}, 95th percentile ${ms(p95)}, maximum ${ms(metrics.duration.maxMs)}. By bucket: ${counts.map((value, index) => `${index < LATENCY_BOUNDS.length ? `up to ${LATENCY_BOUNDS[index]}ms` : "over 100ms"}: ${n(value)}`).join(", ")}.`;\n }\n function percentile(cumulative, count, fraction) {\n const target = count * fraction;\n const last = LATENCY_BOUNDS[LATENCY_BOUNDS.length - 1] ?? 100;\n for (let i = 0; i < cumulative.length; i++) {\n if ((cumulative[i] ?? 0) >= target) return i < LATENCY_BOUNDS.length ? LATENCY_BOUNDS[i] ?? last : last;\n }\n return last;\n }\n\n // src/dashboard/client/registry.ts\n var timer;\n async function loadActors() {\n if (!SECTIONS.registry) return;\n try {\n const body = await getJson(`/api/actors?limit=${state.actorsPageSize}&offset=${state.actorsPage * state.actorsPageSize}`);\n state.actors = body.actors;\n state.actorsTracked = body.tracked;\n drawActors();\n } catch {\n }\n }\n function trackActors() {\n if (timer !== void 0) clearInterval(timer);\n timer = void 0;\n if (state.tab !== "actors") return;\n void loadActors();\n timer = setInterval(() => {\n if (state.tab !== "actors" || state.paused || isConfirming()) return;\n if (state.actorScope === "feed") return;\n void loadActors();\n }, 4e3);\n }\n var ACTORS_PAGE_SIZES = [25, 50, 100, 200];\n function drawActorsPager(full) {\n const page = state.actorsPage;\n const hidden = page === 0 && !full;\n const from = page * state.actorsPageSize + 1;\n const model = {\n page,\n from,\n to: from + state.actors.length - 1,\n total: state.actorsTracked,\n atStart: page === 0,\n atEnd: !full,\n go: (next) => {\n state.actorsPage = Math.max(0, next);\n void loadActors();\n },\n size: {\n current: state.actorsPageSize,\n choices: ACTORS_PAGE_SIZES,\n set: (next) => {\n state.actorsPageSize = next;\n state.actorsPage = 0;\n void loadActors();\n }\n }\n };\n for (const [id, withSize] of [\n ["actors-pager-top", true],\n ["actors-pager", false]\n ]) {\n const host = $(id);\n host.hidden = hidden;\n if (hidden) clear(host);\n else renderPager(host, model, { withSize });\n }\n }\n function feedActors() {\n const byKey = /* @__PURE__ */ new Map();\n for (const row of matchingRows()) {\n const entry = row.entry;\n let seen = byKey.get(entry.actor);\n if (seen === void 0) {\n seen = { rows: 0, paths: /* @__PURE__ */ new Set(), agents: /* @__PURE__ */ new Set(), first: entry.at, last: entry.at };\n byKey.set(entry.actor, seen);\n }\n seen.rows++;\n seen.paths.add(entry.path);\n seen.agents.add(entry.userAgent);\n if (entry.at < seen.first) seen.first = entry.at;\n if (entry.at > seen.last) seen.last = entry.at;\n seen.stats = entry.actorStats ?? seen.stats;\n }\n const labels = new Map(state.actors.filter((actor) => actor.label !== void 0).map((actor) => [actor.key, actor.label]));\n const out = [];\n for (const [key, seen] of byKey) {\n const label2 = labels.get(key);\n out.push({\n key,\n ...label2 === void 0 ? {} : { label: label2 },\n requests: seen.rows,\n recentRate: Number.NaN,\n distinctPaths: seen.paths.size,\n distinctUserAgents: seen.agents.size,\n cadenceCv: void 0,\n // A dash where the feed cannot know, like the three columns below it. A confident\n // zero under a heading that means "how many times has this client been proven a bot"\n // is worse than an admission, and it was the only column here still guessing.\n priorConfirmations: seen.stats?.priorConfirmations ?? Number.NaN,\n unsolvedChallenges: Number.NaN,\n cleared: seen.stats?.cleared ?? false,\n firstSeen: seen.stats?.firstSeen ?? seen.first,\n lastSeen: seen.last\n });\n }\n return out.sort((a, b) => b.requests - a.requests);\n }\n function applyActorScope(scope) {\n if (!SECTIONS.registry) return;\n state.actorScope = scope;\n for (const [id, on] of [\n ["actors-scope-tracked", scope === "tracked"],\n ["actors-scope-feed", scope === "feed"]\n ]) {\n byId(id).className = on ? "on" : "";\n byId(id).setAttribute("aria-pressed", String(on));\n }\n drawActors();\n }\n function initActorScope() {\n if (!SECTIONS.registry) return;\n const choose = (scope) => {\n applyActorScope(scope);\n app.syncUrl({ replace: false });\n };\n $("actors-scope-tracked").addEventListener("click", () => choose("tracked"));\n $("actors-scope-feed").addEventListener("click", () => choose("feed"));\n }\n function drawActors() {\n if (!SECTIONS.registry) return;\n if (isConfirming()) return;\n const body = byId("actor-rows");\n clear(body);\n const fromFeed = state.actorScope === "feed";\n const actors = fromFeed ? feedActors() : state.actors;\n $("actors-count").textContent = fromFeed ? `${n(actors.length)} in the feed you are looking at \xB7 ${n(state.actorsTracked)} tracked` : `${n(actors.length)} shown \xB7 ${n(state.actorsTracked)} tracked`;\n drawActorsPager(!fromFeed && actors.length === state.actorsPageSize);\n if (fromFeed) for (const id of ["actors-pager-top", "actors-pager"]) byId(id).hidden = true;\n byId("actors-empty").hidden = actors.length > 0;\n for (const actor of actors) {\n const row = el("tr");\n const who = el("td", "who");\n if (actor.label === void 0) who.textContent = actor.key;\n else {\n who.appendChild(el("div", "label", actor.label));\n who.appendChild(el("div", "sub", actor.key));\n }\n row.appendChild(who);\n row.appendChild(el("td", "num tnum", n(actor.requests)));\n row.appendChild(el("td", "num tnum", Number.isNaN(actor.recentRate) ? "\u2014" : n(actor.recentRate)));\n row.appendChild(el("td", "num tnum", n(actor.distinctPaths)));\n const cadence = el("td", "num tnum", actor.cadenceCv === void 0 ? "\u2014" : actor.cadenceCv.toFixed(2));\n if (actor.cadenceCv !== void 0 && actor.cadenceCv < 0.15) cadence.className += " warn-text";\n row.appendChild(cadence);\n row.appendChild(el("td", "num tnum", Number.isNaN(actor.priorConfirmations) ? "\u2014" : n(actor.priorConfirmations)));\n const unsolved = el("td", "num tnum", Number.isNaN(actor.unsolvedChallenges) ? "\u2014" : n(actor.unsolvedChallenges));\n if (actor.unsolvedChallenges >= 3) unsolved.className += " warn-text";\n row.appendChild(unsolved);\n const stateCell = el("td");\n const tags = [];\n if (actor.cleared) tags.push("cleared as human");\n if (actor.distinctUserAgents > 1) tags.push(`${actor.distinctUserAgents} User-Agents`);\n tags.push(`first seen ${clockStamp(actor.firstSeen)}`);\n stateCell.appendChild(el("div", "tagline", tags.join(" \xB7 ")));\n row.appendChild(stateCell);\n const actions = el("td", "acts");\n const inFeed = el("button", null, "In feed");\n inFeed.title = "Show this actor\'s requests in the live feed";\n inFeed.addEventListener("click", () => {\n setSearch(`actor:${actor.key}`);\n const search = byId("search");\n search.value = state.search;\n app.showTab("live");\n app.syncUrl();\n });\n actions.appendChild(inFeed);\n for (const button of actorActions(actor.key, () => void loadActors(), actor.label)) actions.appendChild(button);\n row.appendChild(actions);\n body.appendChild(row);\n }\n }\n\n // src/dashboard/client/tester.ts\n function initTester() {\n if (!SECTIONS.tester) return;\n $("test-run").addEventListener("click", () => void run());\n byId("test-input").addEventListener("keydown", (event) => {\n if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {\n event.preventDefault();\n void run();\n }\n });\n }\n async function run() {\n const raw = byId("test-input").value;\n if (raw.trim() === "") {\n toast("warn", "Nothing to assess", "Paste a User-Agent, a curl command, or a header block.");\n return;\n }\n const result = await postJson("/api/test", {\n raw,\n ip: byId("test-ip").value.trim(),\n url: byId("test-url").value.trim()\n });\n const box = $("test-result");\n box.hidden = false;\n clear(box);\n if (!result.ok) {\n box.className = "result bad";\n box.appendChild(el("div", null, result.error ?? "The server could not read that."));\n return;\n }\n const { entry, reason, assumed } = result.data;\n const out = outcome(entry);\n box.className = `result ${out === "deny" ? "bad" : out === "mitigate" ? "warn" : "ok"}`;\n const head = el("div");\n const [badgeClass, badgeLabel] = verdictBadge(entry);\n head.appendChild(el("span", `badge ${badgeClass}`, badgeLabel));\n head.appendChild(document.createTextNode(" "));\n head.appendChild(el("b", null, entry.action ?? "no decision"));\n if (entry.rule !== void 0) head.appendChild(el("span", "ev-meta", ` via ${entry.rule}`));\n box.appendChild(head);\n box.appendChild(el("div", "ev-meta", `${entry.certain ? "proven" : `score ${entry.score}`} \xB7 assessed in ${ms(entry.durationMs)}`));\n if (entry.downgradedFrom !== void 0) {\n box.appendChild(el("div", "guard", `The guard stopped ${entry.downgradedFrom} here.`));\n if (entry.downgradeReason !== void 0) box.appendChild(el("div", "basis", entry.downgradeReason));\n }\n if (entry.evidence.length === 0) {\n box.appendChild(el("div", "ev-meta", entry.bypass !== void 0 ? `Detection was skipped: ${entry.bypass}.` : "No detector produced any evidence."));\n } else {\n const list = el("div", "ev");\n for (const item of entry.evidence) {\n const row = el("div", `ev-item${item.direction === "human" ? " human" : ""}`);\n row.appendChild(el("div", `tier t-${item.certainty}`, item.certainty));\n const detail = el("div");\n detail.appendChild(el("div", null, item.summary));\n detail.appendChild(el("div", "ev-meta", `${item.detector} \xB7 points to ${item.direction}`));\n if (item.deterministicBasis !== void 0) detail.appendChild(el("div", "basis", item.deterministicBasis));\n row.appendChild(detail);\n list.appendChild(row);\n }\n box.appendChild(list);\n }\n box.appendChild(el("div", "ev-meta", reason));\n const notes = [...assumed, "no history: this is assessed as a first request, so cadence and crawl breadth have nothing to read"];\n box.appendChild(el("div", "assumed", `Assumed \u2014 ${notes.join("; ")}.`));\n }\n\n // src/dashboard/client/index.ts\n var TABS = [\n ["tab-live", "live", SECTIONS.feed],\n ["tab-actors", "actors", SECTIONS.registry],\n ["tab-stats", "stats", SECTIONS.statistics],\n ["tab-policy", "policy", SECTIONS.policy]\n ];\n var available = TABS.filter(([, , enabled]) => enabled);\n var FIRST = available[0]?.[1] ?? "live";\n function tabIndexOf(name) {\n const at = available.findIndex(([, tab]) => tab === name);\n return at === -1 ? 0 : at;\n }\n function isTab(value) {\n return available.some(([, name]) => name === value);\n }\n function initSkipLink() {\n if (!isEmbedded()) return;\n const skip = rootNode().querySelector("a.skip");\n if (skip === null) return;\n skip.addEventListener("click", (event) => {\n event.preventDefault();\n const target = rootNode().querySelector(`#view-${state.tab}`) ?? rootNode().querySelector("#view-live");\n if (target === null) return;\n target.tabIndex = -1;\n target.focus();\n target.scrollIntoView({ block: "start" });\n });\n }\n function initHeader() {\n const box = $("links");\n for (const link of BOOT.links) {\n const anchor = el("a", "linkbtn", link.label);\n anchor.href = link.href;\n anchor.rel = "noreferrer noopener";\n box.appendChild(anchor);\n }\n let stored = null;\n try {\n stored = localStorage.getItem("bothandler-dashboard-theme");\n } catch {\n stored = null;\n }\n if (stored === "dark" || stored === "light") themeElement().setAttribute("data-theme", stored);\n $("theme").addEventListener("click", () => {\n let current = themeElement().getAttribute("data-theme");\n if (current === null) current = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";\n const next = current === "dark" ? "light" : "dark";\n themeElement().setAttribute("data-theme", next);\n try {\n localStorage.setItem("bothandler-dashboard-theme", next);\n } catch {\n }\n drawNow();\n });\n const pause = byId("pause");\n pause.hidden = !SECTIONS.feed;\n pause.addEventListener("click", () => {\n state.paused = !state.paused;\n pause.setAttribute("aria-pressed", String(state.paused));\n pause.textContent = state.paused ? `Resume${state.bufferedWhilePaused > 0 ? ` (${state.bufferedWhilePaused})` : ""}` : "Pause";\n if (!state.paused) {\n state.bufferedWhilePaused = 0;\n drawNow();\n }\n });\n if (BOOT.allowReset) {\n const reset = byId("reset");\n reset.hidden = false;\n reset.addEventListener("click", () => {\n reset.disabled = true;\n void fetch(`${API}/api/reset`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }).then(async (response) => {\n if (!response.ok) {\n const body = await response.json().catch(() => ({}));\n toast("bad", "Reset refused", body.error ?? String(response.status));\n return;\n }\n clearFeed();\n resetFeedCache();\n drawNow();\n }).catch(() => {\n }).finally(() => {\n reset.disabled = false;\n });\n });\n }\n const header = rootNode().querySelector("header");\n if (header !== null) {\n const apply2 = () => {\n themeElement().style.setProperty("--header-h", `${header.getBoundingClientRect().height}px`);\n };\n apply2();\n if (typeof ResizeObserver === "function") new ResizeObserver(apply2).observe(header);\n else addEventListener("resize", apply2);\n }\n }\n function syncUrl(replace = true) {\n if (isEmbedded()) return;\n const params = new URLSearchParams();\n if (state.filter !== "all") params.set("f", state.filter);\n if (state.search !== "") params.set("q", state.search);\n if (state.actorScope !== "tracked") params.set("a", state.actorScope);\n const query = params.toString();\n const hash = `#${state.tab}${query === "" ? "" : `?${query}`}`;\n if (location.hash === hash) return;\n history[replace ? "replaceState" : "pushState"]({ tab: state.tab }, "", hash);\n }\n function readUrl() {\n const raw = isEmbedded() ? "" : location.hash.slice(1);\n const split = raw.indexOf("?");\n const name = split === -1 ? raw : raw.slice(0, split);\n const params = new URLSearchParams(split === -1 ? "" : raw.slice(split + 1));\n const filter = params.get("f") ?? "all";\n return {\n tab: isTab(name) ? name : FIRST,\n filter,\n search: params.get("q") ?? "",\n // Anything but the one alternative reads as the default rather than as an error:\n // a hand-edited URL should land somewhere, and this is the somewhere it lands.\n actorScope: params.get("a") === "feed" ? "feed" : "tracked"\n };\n }\n function showTab(name, options = {}) {\n const target = isTab(name) ? name : FIRST;\n state.tab = target;\n for (const [id, tab, enabled] of TABS) {\n const selected = tab === target;\n const node = $(id);\n node.hidden = !enabled;\n node.setAttribute("aria-selected", String(selected));\n node.tabIndex = selected ? 0 : -1;\n $(`view-${tab}`).hidden = !selected || !enabled;\n }\n if (options.focus === true) $(available[tabIndexOf(target)]?.[0] ?? "tab-live").focus();\n if (target === "policy" && state.policy === void 0) {\n void loadPolicy();\n void loadRanges();\n }\n trackActors();\n if (options.push !== false) syncUrl(options.replace !== false);\n drawNow();\n }\n function initTabs() {\n available.forEach(([id, name], index) => {\n const tab = $(id);\n tab.addEventListener("click", () => showTab(name, { replace: false }));\n tab.addEventListener("keydown", (event) => {\n let next = -1;\n if (event.key === "ArrowRight") next = (index + 1) % available.length;\n else if (event.key === "ArrowLeft") next = (index - 1 + available.length) % available.length;\n else if (event.key === "Home") next = 0;\n else if (event.key === "End") next = available.length - 1;\n if (next === -1) return;\n event.preventDefault();\n showTab(available[next]?.[1] ?? FIRST, { focus: true, replace: false });\n });\n });\n if (isEmbedded()) return;\n addEventListener("popstate", () => {\n const url = readUrl();\n state.filter = url.filter;\n setSearch(url.search);\n reflectFilterButtons();\n applyActorScope(url.actorScope);\n showTab(url.tab, { push: false });\n });\n }\n function initKeyboard() {\n eventTarget().addEventListener("keydown", ((event) => {\n const target = event.target;\n const typing = target !== null && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT");\n if (event.key === "Escape") {\n if (typing && target?.id === "search" && target.value !== "") {\n target.value = "";\n setSearch("");\n syncUrl();\n drawNow();\n return;\n }\n if (typing) {\n target?.blur();\n return;\n }\n if (state.actor !== void 0) {\n state.actor = void 0;\n drawNow();\n return;\n }\n if (state.open.size > 0) {\n state.open.clear();\n drawNow();\n }\n return;\n }\n if (typing || event.metaKey || event.ctrlKey || event.altKey) return;\n if (event.key === "/" && SECTIONS.feed) {\n event.preventDefault();\n showTab("live");\n const search = byId("search");\n search.focus();\n search.select();\n return;\n }\n const digit = ["1", "2", "3", "4"].indexOf(event.key);\n if (digit !== -1 && digit < available.length) {\n event.preventDefault();\n showTab(available[digit]?.[1] ?? FIRST, { focus: true, replace: false });\n }\n }));\n }\n function initRanges() {\n const ranges = [\n ["1m", 6e4],\n ["5m", 3e5],\n ["15m", 9e5],\n ["1h", 36e5]\n ];\n const host = $("traffic-range");\n for (const [label2, value] of ranges) {\n const button = el("button", null, label2);\n button.setAttribute("aria-pressed", String(value === state.rangeMs));\n button.addEventListener("click", () => {\n state.rangeMs = value;\n for (const other of Array.from(host.children)) other.setAttribute("aria-pressed", "false");\n button.setAttribute("aria-pressed", "true");\n drawTraffic();\n });\n host.appendChild(button);\n }\n const scopes = [\n ["since start", "run"],\n ["this window", "window"]\n ];\n const scopeHost = $("score-scope");\n for (const [label2, value] of scopes) {\n const button = el("button", null, label2);\n button.setAttribute("aria-pressed", String(value === state.scoreScope));\n button.addEventListener("click", () => {\n state.scoreScope = value;\n for (const other of Array.from(scopeHost.children)) other.setAttribute("aria-pressed", "false");\n button.setAttribute("aria-pressed", "true");\n drawScores();\n });\n scopeHost.appendChild(button);\n }\n }\n var pending = false;\n function schedule() {\n if (state.paused || pending) return;\n pending = true;\n requestAnimationFrame(() => {\n pending = false;\n draw();\n });\n }\n function draw() {\n drawChips();\n drawTiles();\n drawNoticeBadge();\n updateWindowLabels();\n if (state.tab === "live") {\n drawActor();\n drawFeed();\n drawLivePanels();\n } else if (state.tab === "actors") {\n drawActors();\n } else if (state.tab === "stats") {\n drawTraffic();\n drawLatency();\n drawScores();\n drawStatsPanels();\n if (SECTIONS.audit) drawAudit();\n } else {\n drawPolicyTab();\n if (SECTIONS.notices) drawNotices();\n if (SECTIONS.changes) drawChanges();\n }\n }\n function drawNow() {\n draw();\n }\n function applySections() {\n const gated = [\n ["tiles", SECTIONS.statistics],\n ["tester-panel", SECTIONS.tester],\n ["ranges-panel", SECTIONS.ranges],\n ["changes-panel", SECTIONS.changes],\n ["actor-panel", SECTIONS.actors],\n ["live-actors-panel", SECTIONS.actors],\n ["audit-panel", SECTIONS.audit],\n ["audit-checks-panel", SECTIONS.audit],\n ["notices-panel", SECTIONS.notices],\n ["guard-panel", SECTIONS.guard],\n ["robots-panel", SECTIONS.robots],\n ["identities-panel", SECTIONS.actors],\n ["evidence-legend", SECTIONS.evidence]\n ];\n for (const [id, enabled] of gated) {\n const node = rootNode().querySelector(`#${id}`);\n if (node !== null && !enabled) node.remove();\n }\n }\n function start() {\n app.draw = schedule;\n app.drawNow = drawNow;\n app.showTab = showTab;\n app.syncUrl = (options) => syncUrl(options?.replace !== false);\n applySections();\n initHeader();\n drawPeers();\n initTabs();\n initSkipLink();\n initKeyboard();\n initRanges();\n if (SECTIONS.feed) initFeed();\n initActor();\n initActorScope();\n initTester();\n initPolicy();\n let resizeTimer;\n addEventListener("resize", () => {\n if (resizeTimer !== void 0) clearTimeout(resizeTimer);\n resizeTimer = setTimeout(() => {\n if (state.tab === "stats") {\n drawTraffic();\n drawLatency();\n drawScores();\n }\n }, 120);\n });\n const url = readUrl();\n state.filter = url.filter;\n setSearch(url.search);\n if (SECTIONS.feed) reflectFilterButtons();\n applyActorScope(url.actorScope);\n showTab(url.tab, { replace: true });\n suspendScrollAnchoring();\n void loadInitialSnapshot().finally(settleScrollAnchoring);\n connectStream();\n }\n function htmlElement() {\n const root2 = rootNode();\n return root2 instanceof Document ? root2.documentElement : void 0;\n }\n function suspendScrollAnchoring() {\n htmlElement()?.classList.add("settling");\n }\n function settleScrollAnchoring() {\n const html = htmlElement();\n if (html === void 0) return;\n requestAnimationFrame(() => {\n requestAnimationFrame(() => {\n html.classList.remove("settling");\n });\n });\n }\n start();\n})();\n';
|
|
5453
6312
|
}
|
|
5454
6313
|
});
|
|
5455
6314
|
|
|
@@ -5703,6 +6562,33 @@ button.tile {
|
|
|
5703
6562
|
cursor: pointer; appearance: none; transition: border-color .12s, box-shadow .12s;
|
|
5704
6563
|
}
|
|
5705
6564
|
button.tile:hover { border-color: var(--focus); }
|
|
6565
|
+
/* The suggestion list under the search box, positioned against the search wrapper. */
|
|
6566
|
+
.search { position: relative; }
|
|
6567
|
+
.suggest {
|
|
6568
|
+
position: absolute; top: calc(100% + 4px); left: 0; z-index: 30; margin: 0; padding: 4px;
|
|
6569
|
+
list-style: none; min-width: 220px; max-height: 260px; overflow-y: auto;
|
|
6570
|
+
background: var(--surface); border: 1px solid var(--line); border-radius: 9px; box-shadow: var(--shadow);
|
|
6571
|
+
}
|
|
6572
|
+
.suggest li { padding: 4px 9px; border-radius: 6px; cursor: pointer; font-size: 12px; }
|
|
6573
|
+
.suggest li[aria-selected="true"] { background: color-mix(in srgb, var(--focus) 18%, transparent); }
|
|
6574
|
+
|
|
6575
|
+
/* Saved filters: a list of small removable things. Quiet, because it is not what
|
|
6576
|
+
somebody came to the page to look at. */
|
|
6577
|
+
.saved { display: flex; align-items: center; gap: 6px; }
|
|
6578
|
+
/* Two open-ended bounds rather than a list of durations: "from the incident until now",
|
|
6579
|
+
"everything up to when it stopped" and "between these two moments" are the same control
|
|
6580
|
+
with one end left empty. */
|
|
6581
|
+
.timeframe { display: flex; align-items: center; gap: 8px; font-size: 11.5px; color: var(--muted); }
|
|
6582
|
+
.timeframe label { display: inline-flex; align-items: center; gap: 4px; }
|
|
6583
|
+
.timeframe input {
|
|
6584
|
+
font: inherit; font-size: 11.5px; padding: 2px 5px; border-radius: 6px;
|
|
6585
|
+
border: 1px solid var(--line); background: var(--surface); color: var(--ink);
|
|
6586
|
+
}
|
|
6587
|
+
.timeframe button { font: inherit; font-size: 11.5px; padding: 3px 9px; border-radius: 7px; border: 1px solid var(--line); background: var(--surface); color: var(--ink); cursor: pointer; }
|
|
6588
|
+
.saved select { font: inherit; font-size: 11.5px; padding: 3px 6px; border-radius: 7px; border: 1px solid var(--line); background: var(--surface); color: var(--ink); max-width: 170px; }
|
|
6589
|
+
.saved button { font: inherit; font-size: 11.5px; padding: 3px 9px; border-radius: 7px; border: 1px solid var(--line); background: var(--surface); color: var(--ink); cursor: pointer; }
|
|
6590
|
+
.saved button:hover { border-color: var(--focus); }
|
|
6591
|
+
|
|
5706
6592
|
/* The badge's companion: fetches the entries the stream skipped. Sits inline with the
|
|
5707
6593
|
heading, so it is styled to read as part of the sentence rather than as a form control. */
|
|
5708
6594
|
.load-skipped {
|
|
@@ -5724,6 +6610,10 @@ button.tile:hover { border-color: var(--focus); }
|
|
|
5724
6610
|
.pager button:hover:not(:disabled) { border-color: var(--focus); }
|
|
5725
6611
|
.pager button:disabled { opacity: .45; cursor: default; }
|
|
5726
6612
|
.pager .where { font-variant-numeric: tabular-nums; }
|
|
6613
|
+
/* A labelled actor leads with its name and keeps the key underneath: whoever named it did
|
|
6614
|
+
so because the key was not the useful part, and the key is still what you search for. */
|
|
6615
|
+
td.who .label { font-weight: 560; }
|
|
6616
|
+
td.who .sub { color: var(--muted); font-size: 11px; }
|
|
5727
6617
|
.pager.pager-top { padding: 2px 2px 9px; border-bottom: 1px solid var(--line); margin-bottom: 9px; }
|
|
5728
6618
|
/* The feed's upper pager rides in the toolbar rather than owning a row of its own, which
|
|
5729
6619
|
was thirty-six pixels of mostly empty rule above every screenful of requests. */
|
|
@@ -5844,7 +6734,29 @@ input[type="search"] {
|
|
|
5844
6734
|
}
|
|
5845
6735
|
input[type="search"]::placeholder { color: var(--muted); }
|
|
5846
6736
|
|
|
5847
|
-
|
|
6737
|
+
/* separate with zero spacing rather than collapse, and the difference is the whole
|
|
6738
|
+
reason the column headers work in Safari.
|
|
6739
|
+
|
|
6740
|
+
Collapsed borders and sticky table cells are a long-standing sore point in WebKit: the
|
|
6741
|
+
CSSWG has an open issue on collapsed borders not following a cell when it sticks
|
|
6742
|
+
(csswg-drafts#3136), and Safari is widely reported to drop the stickiness of a th
|
|
6743
|
+
altogether under a collapsed table. Separating the borders is the standard remedy.
|
|
6744
|
+
|
|
6745
|
+
What was actually measured: the header sticks correctly in Chromium and in Firefox,
|
|
6746
|
+
both before and after this change, and it was reported adrift in Safari — which is what
|
|
6747
|
+
a sticky element that has stopped sticking looks like. WebKit could not be run on the
|
|
6748
|
+
machine this was written on, so the Safari half of it rests on that report and on the
|
|
6749
|
+
documented behaviour rather than on a measurement taken here.
|
|
6750
|
+
|
|
6751
|
+
The rendering is all but unchanged. Every border in these tables is a bottom border on
|
|
6752
|
+
the cell itself, plus the per-cell left accent on td.edge; no border is shared between
|
|
6753
|
+
two cells, so there is nothing for collapsing to merge and nothing for separating to
|
|
6754
|
+
double, and zero spacing keeps the cells touching. The one measurable difference is the
|
|
6755
|
+
accent column, which moves two pixels: collapsing centres that 3px border on the cell
|
|
6756
|
+
edge and leaves half of it outside the box, while separating puts all of it inside.
|
|
6757
|
+
Measured rather than assumed, and the leftmost column starting two pixels earlier is
|
|
6758
|
+
both imperceptible and the more correct of the two. */
|
|
6759
|
+
table { width: 100%; border-collapse: separate; border-spacing: 0; }
|
|
5848
6760
|
thead th {
|
|
5849
6761
|
/* Measured at runtime — see trackHeaderHeight(). The literal is the fallback for
|
|
5850
6762
|
the instant before the first measurement, and for the tab strip wrapping. */
|
|
@@ -6136,9 +7048,44 @@ input::placeholder { color: color-mix(in srgb, var(--muted) 80%, transparent); }
|
|
|
6136
7048
|
#actor-rows td { font-size: 12.5px; }
|
|
6137
7049
|
#actor-rows td.who { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
|
|
6138
7050
|
#actor-rows td.acts { text-align: right; white-space: nowrap; }
|
|
7051
|
+
/* The tracked/shown toggle above the actors table. A segmented pair rather than a
|
|
7052
|
+
dropdown: there are two answers and both are worth reading at a glance. */
|
|
7053
|
+
.scope { display: flex; gap: 6px; padding: 0 14px 10px; }
|
|
7054
|
+
.scope button { font-size: 11.5px; padding: 4px 10px; }
|
|
7055
|
+
.scope button.on { background: var(--accent); color: var(--on-accent, #fff); border-color: var(--accent); }
|
|
7056
|
+
|
|
6139
7057
|
#actor-rows td.acts button { font-size: 11px; padding: 3px 8px; margin-left: 4px; }
|
|
7058
|
+
/* The Label control, which becomes a text box with a Save and a Cancel in place.
|
|
7059
|
+
|
|
7060
|
+
The cell does not wrap, so an editor that sat beside the row's other four buttons put
|
|
7061
|
+
Save off the right edge of the panel, where it could be seen and not clicked. While
|
|
7062
|
+
the editor is open it stands in for those buttons instead — which is also the right
|
|
7063
|
+
thing on its own, since Allowlist and Forget are not what somebody naming a client is
|
|
7064
|
+
reaching for. */
|
|
7065
|
+
.acts.editing > :not(.label-edit), .bar-actions.editing > :not(.label-edit) { display: none; }
|
|
7066
|
+
/* inline-flex rather than inline-block: the row is three fixed-size controls and a flex
|
|
7067
|
+
line is the layout that cannot spill them past its own edge. */
|
|
7068
|
+
.label-edit { display: inline-flex; align-items: center; gap: 4px; }
|
|
7069
|
+
.label-edit button { flex: 0 0 auto; }
|
|
7070
|
+
#actor-rows td.acts .label-save, #actor-actions .label-save { border-color: var(--accent); color: var(--accent); }
|
|
7071
|
+
/* Qualified with the element name on purpose: input[type="text"] { width: 100% } above
|
|
7072
|
+
outranks a bare class, so the width here was quietly ignored and the box grew to fill
|
|
7073
|
+
whatever it was in — which is what put Save and Cancel outside the panel. */
|
|
7074
|
+
input.label-input {
|
|
7075
|
+
font: inherit; font-size: 11px; padding: 3px 8px; width: 15ch; flex: 0 0 auto; box-sizing: border-box;
|
|
7076
|
+
color: var(--ink); background: var(--surface); border: 1px solid var(--accent); border-radius: 6px;
|
|
7077
|
+
}
|
|
7078
|
+
input.label-input:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
|
7079
|
+
|
|
6140
7080
|
/* A tag, not a warning: a cleared actor is a decision somebody made, and a metronomic
|
|
6141
7081
|
one is a measurement. Neither is a verdict, so neither gets a verdict's colour. */
|
|
7082
|
+
/* A shadowed finding: shown at full detail, and visibly not part of the decision. Dimmed
|
|
7083
|
+
and set behind a rule rather than coloured, because every colour on this page already
|
|
7084
|
+
means something about a verdict and this one took no part in a verdict. */
|
|
7085
|
+
.det.shadow { opacity: 0.72; }
|
|
7086
|
+
.ev-item.shadow { opacity: 0.72; border-left: 2px dashed var(--line); padding-left: 8px; }
|
|
7087
|
+
.shadow-verdict { margin-top: 8px; font-style: italic; }
|
|
7088
|
+
.shadow-verdict.changed { color: var(--ink-2); font-style: normal; }
|
|
6142
7089
|
.tagline { font-size: 11px; color: var(--muted); }
|
|
6143
7090
|
.tagline b { color: var(--ink-2); font-weight: 600; }
|
|
6144
7091
|
|
|
@@ -6236,8 +7183,16 @@ input::placeholder { color: color-mix(in srgb, var(--muted) 80%, transparent); }
|
|
|
6236
7183
|
<div class="toolbar">
|
|
6237
7184
|
<div class="filters" id="filters"></div>
|
|
6238
7185
|
<div class="search">
|
|
6239
|
-
<input type="search" id="search" placeholder="path:/api actor:203.0.113.4 -rule:allow-crawlers" spellcheck="false" autocomplete="off"
|
|
7186
|
+
<input type="search" id="search" placeholder="path:/api actor:203.0.113.4 -rule:allow-crawlers" spellcheck="false" autocomplete="off"
|
|
7187
|
+
role="combobox" aria-expanded="false" aria-controls="search-suggest" aria-autocomplete="list">
|
|
6240
7188
|
<kbd aria-hidden="true">/</kbd>
|
|
7189
|
+
<ul class="suggest" id="search-suggest" role="listbox" aria-label="Filter suggestions" hidden></ul>
|
|
7190
|
+
</div>
|
|
7191
|
+
<div class="saved" id="saved-filters"></div>
|
|
7192
|
+
<div class="timeframe" id="timeframe">
|
|
7193
|
+
<label>From <input type="datetime-local" id="from-at" step="1"></label>
|
|
7194
|
+
<label>To <input type="datetime-local" id="to-at" step="1"></label>
|
|
7195
|
+
<button type="button" id="timeframe-clear" hidden>Clear</button>
|
|
6241
7196
|
</div>
|
|
6242
7197
|
<button id="feed-export" title="Download every request matching this filter as replay JSONL">Export</button>
|
|
6243
7198
|
<div class="pager pager-inline" id="feed-pager-top" hidden></div>
|
|
@@ -6301,6 +7256,12 @@ input::placeholder { color: color-mix(in srgb, var(--muted) 80%, transparent); }
|
|
|
6301
7256
|
<div class="note" id="actors-note">Everyone the engine is currently remembering, busiest first — a far larger
|
|
6302
7257
|
population than the feed's ring, which holds requests rather than clients. This is
|
|
6303
7258
|
what <code>cadence</code>, <code>crawl-breadth</code> and <code>rate-anomaly</code> are reading.</div>
|
|
7259
|
+
<div class="scope" role="group" aria-label="Which actors to list">
|
|
7260
|
+
<button id="actors-scope-tracked" class="on" aria-pressed="true"
|
|
7261
|
+
title="Every client the engine is remembering, busiest first">Tracked</button>
|
|
7262
|
+
<button id="actors-scope-feed" aria-pressed="false"
|
|
7263
|
+
title="Only the clients that appear in the feed you are looking at, after its filter">Shown in the feed</button>
|
|
7264
|
+
</div>
|
|
6304
7265
|
<div class="pager pager-top" id="actors-pager-top" hidden></div>
|
|
6305
7266
|
<div class="feed-scroll">
|
|
6306
7267
|
<table>
|
|
@@ -6614,18 +7575,21 @@ function createFacts(input) {
|
|
|
6614
7575
|
const rawPath = queryStart === -1 ? url : url.slice(0, queryStart);
|
|
6615
7576
|
const headers = /* @__PURE__ */ Object.create(null);
|
|
6616
7577
|
for (const [name, value] of Object.entries(input.headers)) {
|
|
6617
|
-
const
|
|
6618
|
-
|
|
7578
|
+
const lower = name.toLowerCase();
|
|
7579
|
+
const joined = lower === "cookie" && Array.isArray(value) ? value.join("; ") : joinHeaderValue(value);
|
|
7580
|
+
if (joined !== void 0) headers[lower] = joined;
|
|
6619
7581
|
}
|
|
7582
|
+
const normalized = normalizePath(rawPath);
|
|
6620
7583
|
const facts = {
|
|
6621
7584
|
method: (input.method ?? "GET").toUpperCase(),
|
|
6622
|
-
path:
|
|
7585
|
+
path: normalized,
|
|
6623
7586
|
query: parseQuery(queryStart === -1 ? "" : url.slice(queryStart + 1)),
|
|
6624
7587
|
headers,
|
|
6625
7588
|
headerOrder: extractOrder(input.rawHeaders, headers),
|
|
6626
7589
|
ip: normalizeIp(input.ip) ?? input.ip,
|
|
6627
7590
|
timestamp: input.timestamp ?? Date.now()
|
|
6628
7591
|
};
|
|
7592
|
+
if (rawPath !== normalized) facts.rawPath = rawPath.length > MAX_RAW_PATH ? rawPath.slice(0, MAX_RAW_PATH) : rawPath;
|
|
6629
7593
|
const cookieHeader = headers["cookie"];
|
|
6630
7594
|
if (cookieHeader !== void 0) facts.cookies = parseCookies(cookieHeader);
|
|
6631
7595
|
if (input.protocol !== void 0) facts.protocol = input.protocol;
|
|
@@ -6658,12 +7622,23 @@ function parseQuery(search) {
|
|
|
6658
7622
|
const query = /* @__PURE__ */ Object.create(null);
|
|
6659
7623
|
if (search.length === 0) return query;
|
|
6660
7624
|
let count = 0;
|
|
6661
|
-
for (const [key, value] of new URLSearchParams(search)) {
|
|
7625
|
+
for (const [key, value] of new URLSearchParams(boundedSearch(search))) {
|
|
6662
7626
|
if (count++ >= MAX_QUERY_PARAMS) break;
|
|
6663
7627
|
query[key] = value.length > 1024 ? value.slice(0, 1024) : value;
|
|
6664
7628
|
}
|
|
6665
7629
|
return query;
|
|
6666
7630
|
}
|
|
7631
|
+
function boundedSearch(search) {
|
|
7632
|
+
let seen = 0;
|
|
7633
|
+
let at = search.charCodeAt(0) === 63 ? 1 : 0;
|
|
7634
|
+
while (at < search.length) {
|
|
7635
|
+
let end = search.indexOf("&", at);
|
|
7636
|
+
if (end === -1) end = search.length;
|
|
7637
|
+
if (end !== at && ++seen > MAX_QUERY_PARAMS) return search.slice(0, at - 1);
|
|
7638
|
+
at = end + 1;
|
|
7639
|
+
}
|
|
7640
|
+
return search;
|
|
7641
|
+
}
|
|
6667
7642
|
function extractOrder(rawHeaders, headers) {
|
|
6668
7643
|
if (!rawHeaders || rawHeaders.length === 0) return EMPTY_ORDER;
|
|
6669
7644
|
let isNodeStyle = rawHeaders.length % 2 === 0;
|
|
@@ -6699,13 +7674,14 @@ function isHeaderName(value) {
|
|
|
6699
7674
|
}
|
|
6700
7675
|
return true;
|
|
6701
7676
|
}
|
|
6702
|
-
var MAX_URL_LENGTH, MAX_QUERY_PARAMS, MAX_ORDERED_HEADERS, EMPTY_ORDER;
|
|
7677
|
+
var MAX_URL_LENGTH, MAX_RAW_PATH, MAX_QUERY_PARAMS, MAX_ORDERED_HEADERS, EMPTY_ORDER;
|
|
6703
7678
|
var init_facts = __esm({
|
|
6704
7679
|
"src/facts.ts"() {
|
|
6705
7680
|
"use strict";
|
|
6706
7681
|
init_http();
|
|
6707
7682
|
init_ip();
|
|
6708
7683
|
MAX_URL_LENGTH = 8192;
|
|
7684
|
+
MAX_RAW_PATH = 512;
|
|
6709
7685
|
MAX_QUERY_PARAMS = 64;
|
|
6710
7686
|
MAX_ORDERED_HEADERS = 64;
|
|
6711
7687
|
EMPTY_ORDER = Object.freeze([]);
|
|
@@ -6988,7 +7964,7 @@ function buildDashboard(handler, options, host) {
|
|
|
6988
7964
|
return send(response, 200, "application/json; charset=utf-8", JSON.stringify(snapshot()));
|
|
6989
7965
|
case "/api/feed":
|
|
6990
7966
|
if (!sections.feed) return sectionOff(response, "feed");
|
|
6991
|
-
return send(response, 200, "application/json; charset=utf-8", JSON.stringify({ entries: feed.backlog().map(project) }));
|
|
7967
|
+
return send(response, 200, "application/json; charset=utf-8", JSON.stringify({ entries: feed.backlog().map(project), skipped: feed.skipped }));
|
|
6992
7968
|
case "/api/stream":
|
|
6993
7969
|
if (!sections.feed) return sectionOff(response, "feed");
|
|
6994
7970
|
return stream(request, url, response);
|
|
@@ -7066,8 +8042,11 @@ function buildDashboard(handler, options, host) {
|
|
|
7066
8042
|
} else if (action === "clear") {
|
|
7067
8043
|
const forMs = typeof payload?.forMs === "number" && Number.isFinite(payload.forMs) ? Math.min(24 * 60 * 6e4, Math.max(0, payload.forMs)) : DEFAULT_CLEARANCE_MS;
|
|
7068
8044
|
handler.clearActor(key, forMs, { by });
|
|
8045
|
+
} else if (action === "label") {
|
|
8046
|
+
const label = typeof payload?.label === "string" ? payload.label : void 0;
|
|
8047
|
+
handler.labelActor(key, label, { by });
|
|
7069
8048
|
} else {
|
|
7070
|
-
sendError(response, 400, 'Expected `action` to be "forget" or "
|
|
8049
|
+
sendError(response, 400, 'Expected `action` to be "forget", "clear" or "label".');
|
|
7071
8050
|
return;
|
|
7072
8051
|
}
|
|
7073
8052
|
send(response, 200, "application/json; charset=utf-8", JSON.stringify({ ok: true }));
|
|
@@ -7502,7 +8481,7 @@ function headerValue(request, name) {
|
|
|
7502
8481
|
if (value === void 0) return void 0;
|
|
7503
8482
|
return (Array.isArray(value) ? value[0] : value)?.trim().toLowerCase();
|
|
7504
8483
|
}
|
|
7505
|
-
function
|
|
8484
|
+
function stripPort2(host) {
|
|
7506
8485
|
if (host.startsWith("[")) {
|
|
7507
8486
|
const end = host.indexOf("]");
|
|
7508
8487
|
return end === -1 ? host : host.slice(0, end + 1);
|
|
@@ -7514,15 +8493,15 @@ function stripPort(host) {
|
|
|
7514
8493
|
function resolveAllowedHosts(host, extra) {
|
|
7515
8494
|
if (extra?.includes("*")) return void 0;
|
|
7516
8495
|
if (!LOOPBACK_HOSTS.has(host)) return void 0;
|
|
7517
|
-
const allowed = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]", "::1",
|
|
7518
|
-
for (const entry of extra ?? []) allowed.add(
|
|
8496
|
+
const allowed = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]", "::1", stripPort2(host).toLowerCase()]);
|
|
8497
|
+
for (const entry of extra ?? []) allowed.add(stripPort2(entry).toLowerCase());
|
|
7519
8498
|
return allowed;
|
|
7520
8499
|
}
|
|
7521
8500
|
function hostAllowed(request, allowed) {
|
|
7522
8501
|
if (allowed === void 0) return true;
|
|
7523
8502
|
const host = headerValue(request, "host");
|
|
7524
8503
|
if (host === void 0) return false;
|
|
7525
|
-
return allowed.has(
|
|
8504
|
+
return allowed.has(stripPort2(host));
|
|
7526
8505
|
}
|
|
7527
8506
|
function isSameOrigin(request) {
|
|
7528
8507
|
const site = headerValue(request, "sec-fetch-site");
|
|
@@ -7565,7 +8544,7 @@ function rangeUpdate(body, handler) {
|
|
|
7565
8544
|
}
|
|
7566
8545
|
function resolveMountedHosts(extra) {
|
|
7567
8546
|
if (extra === void 0 || extra.length === 0 || extra.includes("*")) return void 0;
|
|
7568
|
-
return new Set(extra.map((name) =>
|
|
8547
|
+
return new Set(extra.map((name) => stripPort2(name).toLowerCase()));
|
|
7569
8548
|
}
|
|
7570
8549
|
function validateClients(entries) {
|
|
7571
8550
|
const set = new IpRangeSet(entries);
|
|
@@ -7843,6 +8822,7 @@ __export(index_exports, {
|
|
|
7843
8822
|
MAX_DIFFICULTY: () => MAX_DIFFICULTY,
|
|
7844
8823
|
MAX_USER_AGENT_LENGTH: () => MAX_USER_AGENT_LENGTH,
|
|
7845
8824
|
ManualClock: () => ManualClock,
|
|
8825
|
+
MarkerProbe: () => MarkerProbe,
|
|
7846
8826
|
MemoryStore: () => MemoryStore,
|
|
7847
8827
|
Metrics: () => Metrics,
|
|
7848
8828
|
MultiPatternMatcher: () => MultiPatternMatcher,
|
|
@@ -7853,6 +8833,7 @@ __export(index_exports, {
|
|
|
7853
8833
|
RedisStore: () => RedisStore,
|
|
7854
8834
|
SCORE_BUCKETS: () => SCORE_BUCKETS,
|
|
7855
8835
|
SPECIAL_USE_RANGES: () => SPECIAL_USE_RANGES,
|
|
8836
|
+
SiteProfile: () => SiteProfile,
|
|
7856
8837
|
TERMINAL_ACTIONS: () => TERMINAL_ACTIONS,
|
|
7857
8838
|
TRAP_FIELD_SOURCE: () => TRAP_FIELD_SOURCE,
|
|
7858
8839
|
TrafficAudit: () => TrafficAudit,
|
|
@@ -7863,9 +8844,12 @@ __export(index_exports, {
|
|
|
7863
8844
|
agentFor: () => agentFor,
|
|
7864
8845
|
allowCrawlers: () => allowCrawlers,
|
|
7865
8846
|
analyseMovement: () => analyseMovement,
|
|
8847
|
+
blendedIdentityDetector: () => blendedIdentityDetector,
|
|
7866
8848
|
browsingCoherenceDetector: () => browsingCoherenceDetector,
|
|
7867
8849
|
cachingResolver: () => cachingResolver,
|
|
7868
8850
|
cadenceDetector: () => cadenceDetector,
|
|
8851
|
+
challengeIntegrityDetector: () => challengeIntegrityDetector,
|
|
8852
|
+
challengeReactionDetector: () => challengeReactionDetector,
|
|
7869
8853
|
cidrContains: () => cidrContains,
|
|
7870
8854
|
claimsBrowser: () => claimsBrowser,
|
|
7871
8855
|
clampDifficulty: () => clampDifficulty,
|
|
@@ -7884,6 +8868,7 @@ __export(index_exports, {
|
|
|
7884
8868
|
declineAiTraining: () => declineAiTraining,
|
|
7885
8869
|
defaultDetectors: () => defaultDetectors,
|
|
7886
8870
|
defineHandler: () => defineHandler,
|
|
8871
|
+
distributedWalkDetector: () => distributedWalkDetector,
|
|
7887
8872
|
evidence: () => evidence,
|
|
7888
8873
|
executeAction: () => executeAction,
|
|
7889
8874
|
fetchAddressList: () => fetchAddressList,
|
|
@@ -7896,6 +8881,7 @@ __export(index_exports, {
|
|
|
7896
8881
|
headerOrderDetector: () => headerOrderDetector,
|
|
7897
8882
|
headerOrderFingerprint: () => headerOrderFingerprint,
|
|
7898
8883
|
idEnumerationDetector: () => idEnumerationDetector,
|
|
8884
|
+
identityDriftDetector: () => identityDriftDetector,
|
|
7899
8885
|
identityRotationDetector: () => identityRotationDetector,
|
|
7900
8886
|
independentStrongSignals: () => independentStrongSignals,
|
|
7901
8887
|
indexSignatures: () => indexSignatures,
|
|
@@ -7903,6 +8889,10 @@ __export(index_exports, {
|
|
|
7903
8889
|
ipIntelligenceDetector: () => ipIntelligenceDetector,
|
|
7904
8890
|
isSpecialUse: () => isSpecialUse,
|
|
7905
8891
|
issueToken: () => issueToken,
|
|
8892
|
+
markerFanoutDetector: () => markerFanoutDetector,
|
|
8893
|
+
markerIntegrityDetector: () => markerIntegrityDetector,
|
|
8894
|
+
markerPersistenceDetector: () => markerPersistenceDetector,
|
|
8895
|
+
missBaselineDetector: () => missBaselineDetector,
|
|
7906
8896
|
monitorOnly: () => monitorOnly,
|
|
7907
8897
|
networkKey: () => networkKey,
|
|
7908
8898
|
newChallenge: () => newChallenge,
|
|
@@ -7918,6 +8908,8 @@ __export(index_exports, {
|
|
|
7918
8908
|
parseInteractionReport: () => parseInteractionReport,
|
|
7919
8909
|
parseIp: () => parseIp,
|
|
7920
8910
|
parseUserAgent: () => parseUserAgent,
|
|
8911
|
+
pathCampaignDetector: () => pathCampaignDetector,
|
|
8912
|
+
pathNoveltyDetector: () => pathNoveltyDetector,
|
|
7921
8913
|
pickTranslation: () => pickTranslation,
|
|
7922
8914
|
probeShapeFor: () => probeShapeFor,
|
|
7923
8915
|
probeSignatureDetector: () => probeSignatureDetector,
|
|
@@ -7948,6 +8940,7 @@ __export(index_exports, {
|
|
|
7948
8940
|
startCrawlerRangeRefresh: () => startCrawlerRangeRefresh,
|
|
7949
8941
|
startDashboard: () => startDashboard,
|
|
7950
8942
|
systemClock: () => systemClock,
|
|
8943
|
+
targetIntegrityDetector: () => targetIntegrityDetector,
|
|
7951
8944
|
tlsFingerprintDetector: () => tlsFingerprintDetector,
|
|
7952
8945
|
toPrometheus: () => toPrometheus,
|
|
7953
8946
|
transportCoherenceDetector: () => transportCoherenceDetector,
|
|
@@ -8187,6 +9180,12 @@ function renderChallengePage(options) {
|
|
|
8187
9180
|
<meta charset="utf-8">
|
|
8188
9181
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
8189
9182
|
<meta name="robots" content="noindex, nofollow">
|
|
9183
|
+
<!-- An empty icon, so the browser does not go looking for /favicon.ico on its own. It
|
|
9184
|
+
is the browser that makes that request rather than this page, and under
|
|
9185
|
+
default-src 'none' it is refused \u2014 which Firefox reports to the console as a
|
|
9186
|
+
security error on a page whose entire purpose is to reassure somebody that nothing
|
|
9187
|
+
is wrong. Declaring one stops the request being made at all. -->
|
|
9188
|
+
<link rel="icon" href="data:,">
|
|
8190
9189
|
<title>${title}</title>
|
|
8191
9190
|
<style>
|
|
8192
9191
|
:root { color-scheme: light dark; --fg: #16181d; --muted: #5b6270; --bg: #fbfbfc; --line: #e2e5ea; --accent: #2f6feb; }
|
|
@@ -8372,8 +9371,8 @@ function parseAcceptLanguage(header) {
|
|
|
8372
9371
|
if (header === void 0 || header.trim() === "") return [];
|
|
8373
9372
|
const entries = [];
|
|
8374
9373
|
const parts = header.split(",").slice(0, MAX_TAGS);
|
|
8375
|
-
parts.forEach((
|
|
8376
|
-
const [rawTag, ...parameters] =
|
|
9374
|
+
parts.forEach((part2, order) => {
|
|
9375
|
+
const [rawTag, ...parameters] = part2.trim().split(";");
|
|
8377
9376
|
const tag = (rawTag ?? "").trim().toLowerCase();
|
|
8378
9377
|
if (tag === "" || tag === "*" || !/^[a-z]{1,8}(-[a-z\d]{1,8})*$/.test(tag)) return;
|
|
8379
9378
|
let q = 1;
|
|
@@ -8439,51 +9438,8 @@ function clampDifficulty(difficulty) {
|
|
|
8439
9438
|
return Math.min(MAX_DIFFICULTY, Math.max(1, Math.round(difficulty)));
|
|
8440
9439
|
}
|
|
8441
9440
|
|
|
8442
|
-
// src/challenge/
|
|
8443
|
-
|
|
8444
|
-
var MAX_TOKEN_LENGTH = 2048;
|
|
8445
|
-
function issueToken(payload, secrets) {
|
|
8446
|
-
const secret = secrets[0];
|
|
8447
|
-
if (secret === void 0) throw new Error("At least one signing secret is required to issue a token");
|
|
8448
|
-
const body = base64UrlEncode(JSON.stringify(payload));
|
|
8449
|
-
return `${body}.${sign(body, secret)}`;
|
|
8450
|
-
}
|
|
8451
|
-
function verifyToken(token, secrets, now, expectedSubject) {
|
|
8452
|
-
if (token.length === 0 || token.length > MAX_TOKEN_LENGTH) return { ok: false, reason: "malformed" };
|
|
8453
|
-
const separator = token.lastIndexOf(".");
|
|
8454
|
-
if (separator <= 0) return { ok: false, reason: "malformed" };
|
|
8455
|
-
const body = token.slice(0, separator);
|
|
8456
|
-
const signature = token.slice(separator + 1);
|
|
8457
|
-
let valid = false;
|
|
8458
|
-
for (const secret of secrets) {
|
|
8459
|
-
if (constantTimeEqual(signature, sign(body, secret))) valid = true;
|
|
8460
|
-
}
|
|
8461
|
-
if (!valid) return { ok: false, reason: "bad-signature" };
|
|
8462
|
-
let payload;
|
|
8463
|
-
try {
|
|
8464
|
-
const decoded = base64UrlDecode(body).toString("utf8");
|
|
8465
|
-
payload = JSON.parse(decoded);
|
|
8466
|
-
} catch {
|
|
8467
|
-
return { ok: false, reason: "malformed" };
|
|
8468
|
-
}
|
|
8469
|
-
if (typeof payload !== "object" || payload === null) return { ok: false, reason: "malformed" };
|
|
8470
|
-
if (typeof payload.exp !== "number" || typeof payload.sub !== "string") return { ok: false, reason: "malformed" };
|
|
8471
|
-
if (payload.exp <= now) return { ok: false, reason: "expired" };
|
|
8472
|
-
if (expectedSubject !== void 0) {
|
|
8473
|
-
let bound = false;
|
|
8474
|
-
for (const candidate of typeof expectedSubject === "string" ? [expectedSubject] : expectedSubject) {
|
|
8475
|
-
if (constantTimeEqual(payload.sub, candidate)) bound = true;
|
|
8476
|
-
}
|
|
8477
|
-
if (!bound) return { ok: false, reason: "wrong-actor" };
|
|
8478
|
-
}
|
|
8479
|
-
return { ok: true, payload };
|
|
8480
|
-
}
|
|
8481
|
-
function newChallenge(subject, difficulty, ttlMs, now) {
|
|
8482
|
-
return { v: 1, sub: subject, iat: now, exp: now + ttlMs, nonce: randomId(12), diff: difficulty };
|
|
8483
|
-
}
|
|
8484
|
-
function newClearance(subject, level, ttlMs, now) {
|
|
8485
|
-
return { v: 1, sub: subject, iat: now, exp: now + ttlMs, jti: randomId(9), lvl: level };
|
|
8486
|
-
}
|
|
9441
|
+
// src/challenge/index.ts
|
|
9442
|
+
init_token();
|
|
8487
9443
|
|
|
8488
9444
|
// src/challenge/interaction.ts
|
|
8489
9445
|
init_crypto();
|
|
@@ -8567,7 +9523,14 @@ function analyseMovement(path) {
|
|
|
8567
9523
|
timingVariation: coefficientOfVariation(gaps),
|
|
8568
9524
|
accelerationChanges,
|
|
8569
9525
|
straightness: pathLength === 0 ? 1 : Math.min(1, Math.hypot(netX, netY) / pathLength),
|
|
8570
|
-
|
|
9526
|
+
// Over the samples this actually looked at, not over everything that arrived. The
|
|
9527
|
+
// two differ by however many discontinuities were dropped above, and using the raw
|
|
9528
|
+
// count meant a path with pauses in it reported a *lower* fractional share than the
|
|
9529
|
+
// samples it was computed from — which reads as "these coordinates are integers"
|
|
9530
|
+
// when what happened is that most of them were never examined. It costs the people
|
|
9531
|
+
// most likely to have pauses: somebody who moved the pointer, stopped to read, and
|
|
9532
|
+
// moved again.
|
|
9533
|
+
fractionalShare: distances.length === 0 ? 0 : fractional / distances.length,
|
|
8571
9534
|
totalTurning
|
|
8572
9535
|
};
|
|
8573
9536
|
}
|
|
@@ -8662,9 +9625,10 @@ function verifyInteraction(report, elapsedMs, settings = DEFAULT_INTERACTION_SET
|
|
|
8662
9625
|
const failed = Object.keys(CAPABILITY_WEIGHTS).filter((name) => report.capabilities[name] !== true);
|
|
8663
9626
|
notes.push(failed.length === 0 ? "capabilities 100%" : `capabilities ${(capabilityScore * 100).toFixed(0)}% (missing: ${failed.join(", ")})`);
|
|
8664
9627
|
let score = capabilityScore * 0.6;
|
|
8665
|
-
const
|
|
9628
|
+
const analysis = analyseMovement(report.path);
|
|
9629
|
+
const measurable = report.via === "pointer" && analysis.samples >= 4;
|
|
8666
9630
|
if (measurable) {
|
|
8667
|
-
const movement = scoreMovement(
|
|
9631
|
+
const movement = scoreMovement(analysis);
|
|
8668
9632
|
notes.push(`movement ${(movement * 100).toFixed(0)}%`);
|
|
8669
9633
|
score += movement * 0.4;
|
|
8670
9634
|
} else {
|
|
@@ -8680,7 +9644,12 @@ function verifyInteraction(report, elapsedMs, settings = DEFAULT_INTERACTION_SET
|
|
|
8680
9644
|
// src/challenge/index.ts
|
|
8681
9645
|
init_http();
|
|
8682
9646
|
init_crypto();
|
|
9647
|
+
init_lru();
|
|
8683
9648
|
init_clock();
|
|
9649
|
+
init_token();
|
|
9650
|
+
var IMPLAUSIBLE_HASHES_PER_MS = 2e4;
|
|
9651
|
+
var MAX_TRACKED_TOKENS = 2e4;
|
|
9652
|
+
var MAX_BEARERS = 64;
|
|
8684
9653
|
var ChallengeService = class {
|
|
8685
9654
|
constructor(options) {
|
|
8686
9655
|
this.options = options;
|
|
@@ -8696,11 +9665,14 @@ var ChallengeService = class {
|
|
|
8696
9665
|
this.verifyPath = options.verifyPath ?? "/__bothandler/verify";
|
|
8697
9666
|
this.cookieName = options.cookieName ?? "__bh_clearance";
|
|
8698
9667
|
this.clock = options.clock ?? systemClock;
|
|
9668
|
+
this.bearers = new TtlLru(MAX_TRACKED_TOKENS, this.clearanceTtlMs, this.clock);
|
|
8699
9669
|
this.store = options.store;
|
|
8700
9670
|
this.interaction = !wantsGesture ? void 0 : { ...DEFAULT_INTERACTION_SETTINGS, ...options.interaction === true ? {} : options.interaction };
|
|
8701
9671
|
}
|
|
8702
9672
|
options;
|
|
8703
9673
|
secrets;
|
|
9674
|
+
/** Clearance token id to the actors that have presented it. Bounded both ways. */
|
|
9675
|
+
bearers;
|
|
8704
9676
|
difficulty;
|
|
8705
9677
|
challengeTtlMs;
|
|
8706
9678
|
clock;
|
|
@@ -8783,7 +9755,11 @@ var ChallengeService = class {
|
|
|
8783
9755
|
"cache-control": "no-store, private",
|
|
8784
9756
|
// The page carries one inline script and nothing else. Locking the policy
|
|
8785
9757
|
// this far down means the interstitial cannot be turned into a fetch primitive.
|
|
8786
|
-
|
|
9758
|
+
// `img-src data:` permits nothing off this machine — a data: URI is inline by
|
|
9759
|
+
// definition — and exists only so the empty icon the page declares is honoured.
|
|
9760
|
+
// Without it the browser asks for /favicon.ico by itself and is refused, which
|
|
9761
|
+
// Firefox prints as a security error in the console of every person challenged.
|
|
9762
|
+
"content-security-policy": `default-src 'none'; script-src 'nonce-${rendered.scriptNonce}'; style-src 'unsafe-inline'; img-src data:; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`,
|
|
8787
9763
|
"referrer-policy": "no-referrer",
|
|
8788
9764
|
"x-content-type-options": "nosniff",
|
|
8789
9765
|
"x-robots-tag": "noindex, nofollow"
|
|
@@ -8828,6 +9804,11 @@ var ChallengeService = class {
|
|
|
8828
9804
|
interactionScore = outcome.score;
|
|
8829
9805
|
notes = outcome.notes;
|
|
8830
9806
|
}
|
|
9807
|
+
const elapsedSinceIssue = this.clock.now() - verified.payload.iat;
|
|
9808
|
+
const floorMs = 2 ** verified.payload.diff / IMPLAUSIBLE_HASHES_PER_MS | 0;
|
|
9809
|
+
if (elapsedSinceIssue >= 0 && elapsedSinceIssue < floorMs) {
|
|
9810
|
+
return { ok: false, status: 400, reason: "solution returned faster than the puzzle allows", signal: "implausible-speed" };
|
|
9811
|
+
}
|
|
8831
9812
|
if (this.store) {
|
|
8832
9813
|
let claimed;
|
|
8833
9814
|
try {
|
|
@@ -8835,7 +9816,7 @@ var ChallengeService = class {
|
|
|
8835
9816
|
} catch {
|
|
8836
9817
|
claimed = true;
|
|
8837
9818
|
}
|
|
8838
|
-
if (!claimed) return { ok: false, status: 409, reason: "challenge already solved" };
|
|
9819
|
+
if (!claimed) return { ok: false, status: 409, reason: "challenge already solved", signal: "replay" };
|
|
8839
9820
|
}
|
|
8840
9821
|
return {
|
|
8841
9822
|
ok: true,
|
|
@@ -8865,10 +9846,55 @@ var ChallengeService = class {
|
|
|
8865
9846
|
}
|
|
8866
9847
|
/** Reads and validates the clearance cookie for an actor. Returns `undefined` if there is none valid. */
|
|
8867
9848
|
read(actorKey, cookies) {
|
|
9849
|
+
const inspected = this.inspect(actorKey, cookies);
|
|
9850
|
+
return inspected.claims;
|
|
9851
|
+
}
|
|
9852
|
+
/**
|
|
9853
|
+
* Reads a clearance token and says what became of it.
|
|
9854
|
+
*
|
|
9855
|
+
* `read` answers the only question the clearance detector used to ask — is this client
|
|
9856
|
+
* cleared — and throws away the reason when the answer is no. One of those reasons is
|
|
9857
|
+
* worth keeping: a token whose *signature* is ours but whose subject is somebody
|
|
9858
|
+
* else's has been moved between clients. Usually that is innocent and extremely
|
|
9859
|
+
* common, because the subject is derived from the address and a phone changing
|
|
9860
|
+
* networks changes its address. It stops being innocent when one token turns up under
|
|
9861
|
+
* a great many different actors, which is a token being handed around.
|
|
9862
|
+
*/
|
|
9863
|
+
inspect(actorKey, cookies) {
|
|
8868
9864
|
const token = cookies?.[this.cookieName];
|
|
8869
|
-
if (token === void 0) return
|
|
8870
|
-
const
|
|
8871
|
-
|
|
9865
|
+
if (token === void 0) return { presentedBy: 0 };
|
|
9866
|
+
const now = this.clock.now();
|
|
9867
|
+
const verified = verifyToken(token, this.secrets, now, this.subjectsFor(actorKey));
|
|
9868
|
+
if (verified.ok) return { claims: verified.payload, presentedBy: this.noteBearer(verified.payload.jti, actorKey) };
|
|
9869
|
+
if (verified.reason !== "wrong-actor") return { presentedBy: 0 };
|
|
9870
|
+
const claims = this.claimsOf(token);
|
|
9871
|
+
return claims === void 0 ? { presentedBy: 0 } : { boundElsewhere: true, presentedBy: this.noteBearer(claims.jti, actorKey) };
|
|
9872
|
+
}
|
|
9873
|
+
/** The claims inside a token whose signature has already been checked. */
|
|
9874
|
+
claimsOf(token) {
|
|
9875
|
+
const separator = token.lastIndexOf(".");
|
|
9876
|
+
if (separator <= 0) return void 0;
|
|
9877
|
+
try {
|
|
9878
|
+
const claims = JSON.parse(base64UrlDecode(token.slice(0, separator)).toString("utf8"));
|
|
9879
|
+
return typeof claims?.jti === "string" ? claims : void 0;
|
|
9880
|
+
} catch {
|
|
9881
|
+
return void 0;
|
|
9882
|
+
}
|
|
9883
|
+
}
|
|
9884
|
+
/**
|
|
9885
|
+
* Files this presentation under the token's own id, returning how many distinct actors
|
|
9886
|
+
* have now presented it. Bounded in both directions, and in process for the reason
|
|
9887
|
+
* given in `state.ts`: a store round trip per request buys precision nobody asked for.
|
|
9888
|
+
*/
|
|
9889
|
+
noteBearer(jti, actorKey) {
|
|
9890
|
+
if (this.bearers === void 0) return 0;
|
|
9891
|
+
let seen = this.bearers.get(jti);
|
|
9892
|
+
if (seen === void 0) {
|
|
9893
|
+
seen = /* @__PURE__ */ new Set();
|
|
9894
|
+
this.bearers.set(jti, seen);
|
|
9895
|
+
}
|
|
9896
|
+
if (seen.size < MAX_BEARERS) seen.add(actorKey);
|
|
9897
|
+
return seen.size;
|
|
8872
9898
|
}
|
|
8873
9899
|
/** A `Set-Cookie` that removes any clearance. Call it on logout. */
|
|
8874
9900
|
revoke() {
|
|
@@ -9390,6 +10416,10 @@ var NotificationHub = class {
|
|
|
9390
10416
|
init_policy();
|
|
9391
10417
|
init_dns();
|
|
9392
10418
|
init_clearance();
|
|
10419
|
+
init_challenge_reaction();
|
|
10420
|
+
init_challenge_integrity();
|
|
10421
|
+
init_site_baseline();
|
|
10422
|
+
init_marker2();
|
|
9393
10423
|
init_evidence();
|
|
9394
10424
|
init_known_bots();
|
|
9395
10425
|
init_types2();
|
|
@@ -9567,6 +10597,301 @@ function defineHandler(handler) {
|
|
|
9567
10597
|
init_ua();
|
|
9568
10598
|
init_pattern();
|
|
9569
10599
|
init_crypto();
|
|
10600
|
+
init_text();
|
|
10601
|
+
|
|
10602
|
+
// src/probe/index.ts
|
|
10603
|
+
init_marker();
|
|
10604
|
+
init_lru();
|
|
10605
|
+
init_ip();
|
|
10606
|
+
init_marker();
|
|
10607
|
+
var DEFAULT_TTL_MS = 12 * 60 * 6e4;
|
|
10608
|
+
var FANOUT_WORDS = 4;
|
|
10609
|
+
var FANOUT_BITS = FANOUT_WORDS * 32;
|
|
10610
|
+
function hashToBit(value) {
|
|
10611
|
+
let hash = 2166136261;
|
|
10612
|
+
for (let i = 0; i < value.length; i++) {
|
|
10613
|
+
hash ^= value.charCodeAt(i);
|
|
10614
|
+
hash = Math.imul(hash, 16777619);
|
|
10615
|
+
}
|
|
10616
|
+
return (hash >>> 0) % FANOUT_BITS;
|
|
10617
|
+
}
|
|
10618
|
+
function estimateDistinct(sketch, cap) {
|
|
10619
|
+
let set = 0;
|
|
10620
|
+
for (let word = 0; word < FANOUT_WORDS; word++) {
|
|
10621
|
+
let bits = sketch[word];
|
|
10622
|
+
while (bits !== 0) {
|
|
10623
|
+
bits &= bits - 1;
|
|
10624
|
+
set++;
|
|
10625
|
+
}
|
|
10626
|
+
}
|
|
10627
|
+
if (set >= FANOUT_BITS) return cap;
|
|
10628
|
+
const estimate = Math.round(-FANOUT_BITS * Math.log(1 - set / FANOUT_BITS));
|
|
10629
|
+
return Math.min(estimate, cap);
|
|
10630
|
+
}
|
|
10631
|
+
var MarkerProbe = class {
|
|
10632
|
+
cookieName;
|
|
10633
|
+
secrets;
|
|
10634
|
+
ttlMs;
|
|
10635
|
+
cookieOptions;
|
|
10636
|
+
clock;
|
|
10637
|
+
/**
|
|
10638
|
+
* Marker id to a 128-bit sketch of the networks it has been presented from.
|
|
10639
|
+
*
|
|
10640
|
+
* A `Set` of network strings is the obvious structure and measured at **55.6 MB** with
|
|
10641
|
+
* both caps full — twenty thousand markers each seen from a few dozen networks — which
|
|
10642
|
+
* is far too much to hand somebody for switching on a detector. The question being
|
|
10643
|
+
* asked is only ever "has this marker come from more than about sixteen networks", and
|
|
10644
|
+
* a bitmap answers that in sixteen bytes by linear counting: hash each network to a
|
|
10645
|
+
* bit, then estimate the distinct count from how many bits are set.
|
|
10646
|
+
*
|
|
10647
|
+
* The estimate carries a few percent of error in **either** direction — measured, 16
|
|
10648
|
+
* real networks read as 17 and 32 read as 33 — so the threshold it feeds is a soft
|
|
10649
|
+
* boundary rather than a hard one. That is honest for this signal in particular, which
|
|
10650
|
+
* cannot separate a proxy pool from a heavily mobile person at any resolution, and is
|
|
10651
|
+
* why it is capped at `moderate` and never denies anybody by itself.
|
|
10652
|
+
*/
|
|
10653
|
+
fanout;
|
|
10654
|
+
maxNetworks;
|
|
10655
|
+
/**
|
|
10656
|
+
* Markers already verified, by the exact cookie value that verified.
|
|
10657
|
+
*
|
|
10658
|
+
* A browsing session sends one identical cookie on every request, and verifying it is
|
|
10659
|
+
* an HMAC — which measured at roughly twenty microseconds, nearly doubling the cost of
|
|
10660
|
+
* an assessment to re-establish a fact that had not changed. The cache is only ever
|
|
10661
|
+
* populated with *successes*: caching failures would let anyone flood it with unique
|
|
10662
|
+
* junk, and a failure is cheap to reach anyway.
|
|
10663
|
+
*
|
|
10664
|
+
* Expiry is still checked on every hit, so a cached marker stops being accepted at the
|
|
10665
|
+
* moment it should. The key is the whole signed value, so a cache hit is only possible
|
|
10666
|
+
* for a string that already carried a valid signature.
|
|
10667
|
+
*/
|
|
10668
|
+
verified;
|
|
10669
|
+
constructor(options) {
|
|
10670
|
+
if (options.secrets.length === 0) throw new Error("A marker probe requires at least one secret");
|
|
10671
|
+
for (const secret of options.secrets) {
|
|
10672
|
+
if (secret.length < 32) {
|
|
10673
|
+
throw new Error("Each marker secret must be at least 32 characters; generate one with `crypto.randomBytes(32).toString('base64url')`");
|
|
10674
|
+
}
|
|
10675
|
+
}
|
|
10676
|
+
this.secrets = options.secrets;
|
|
10677
|
+
this.cookieName = options.cookieName ?? "__bh_m";
|
|
10678
|
+
this.ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
10679
|
+
this.clock = options.clock;
|
|
10680
|
+
const tracked = options.maxTrackedMarkers ?? 2e4;
|
|
10681
|
+
this.maxNetworks = options.maxNetworksPerMarker ?? 96;
|
|
10682
|
+
this.fanout = tracked > 0 ? new TtlLru(tracked, this.ttlMs, options.clock) : void 0;
|
|
10683
|
+
const cached = options.maxVerifiedMarkers ?? 5e3;
|
|
10684
|
+
this.verified = cached > 0 ? new TtlLru(cached, this.ttlMs, options.clock) : void 0;
|
|
10685
|
+
this.cookieOptions = {
|
|
10686
|
+
...options.sameSite === void 0 ? {} : { sameSite: options.sameSite },
|
|
10687
|
+
...options.secure === void 0 ? {} : { secure: options.secure },
|
|
10688
|
+
...options.domain === void 0 ? {} : { domain: options.domain }
|
|
10689
|
+
};
|
|
10690
|
+
try {
|
|
10691
|
+
markerCookie(this.cookieName, newMarker({ b: "x", o: "x", l: "x" }, this.ttlMs, 0), this.secrets, this.cookieOptions);
|
|
10692
|
+
} catch (error) {
|
|
10693
|
+
throw new Error(`The marker probe cannot issue a cookie with this configuration: ${error instanceof Error ? error.message : String(error)}`);
|
|
10694
|
+
}
|
|
10695
|
+
}
|
|
10696
|
+
/** Reads the marker this request carried, and measures it against the request. */
|
|
10697
|
+
observe(facts, ua) {
|
|
10698
|
+
const shape = identityShape(facts, ua);
|
|
10699
|
+
const reading = this.read(facts.cookies?.[this.cookieName]);
|
|
10700
|
+
const drift = reading.kind === "valid" ? driftBetween({ b: reading.claims.b, o: reading.claims.o, l: reading.claims.l }, shape) : void 0;
|
|
10701
|
+
const networks = reading.kind === "valid" ? this.noteNetwork(reading.claims.sub, facts.ip) : 0;
|
|
10702
|
+
return { reading, drift, shape, networks };
|
|
10703
|
+
}
|
|
10704
|
+
/**
|
|
10705
|
+
* Whether this response should carry a marker.
|
|
10706
|
+
*
|
|
10707
|
+
* Only when the client is not already holding a good one. An ordinary visitor is
|
|
10708
|
+
* therefore issued a cookie once and then browses with uncached-by-`Set-Cookie`
|
|
10709
|
+
* responses never again; a client that discards cookies is issued one every time,
|
|
10710
|
+
* which is itself the observation `marker-persistence` is built on.
|
|
10711
|
+
*/
|
|
10712
|
+
shouldIssue(observation) {
|
|
10713
|
+
return observation.reading.kind !== "valid";
|
|
10714
|
+
}
|
|
10715
|
+
/** Verifies a presented marker, reusing an earlier verification of the same value. */
|
|
10716
|
+
read(value) {
|
|
10717
|
+
if (value === void 0 || value.length === 0) return { kind: "absent" };
|
|
10718
|
+
const now = this.clock.now();
|
|
10719
|
+
const remembered = this.verified?.get(value);
|
|
10720
|
+
if (remembered !== void 0) return remembered.exp > now ? { kind: "valid", claims: remembered } : { kind: "expired" };
|
|
10721
|
+
const reading = readMarker(value, this.secrets, now);
|
|
10722
|
+
if (reading.kind === "valid") this.verified?.set(value, reading.claims);
|
|
10723
|
+
return reading;
|
|
10724
|
+
}
|
|
10725
|
+
/**
|
|
10726
|
+
* Files this presentation under the marker's own id and returns how many distinct
|
|
10727
|
+
* networks it has now come from.
|
|
10728
|
+
*
|
|
10729
|
+
* A `/24` rather than an address, because a single visitor's address changes for
|
|
10730
|
+
* ordinary reasons all day — a phone moving between cells, a router relearning a
|
|
10731
|
+
* lease — while the network it sits behind usually does not. Counting addresses would
|
|
10732
|
+
* report every commuter.
|
|
10733
|
+
*/
|
|
10734
|
+
noteNetwork(markerId, ip) {
|
|
10735
|
+
if (this.fanout === void 0) return 0;
|
|
10736
|
+
let sketch = this.fanout.get(markerId);
|
|
10737
|
+
if (sketch === void 0) {
|
|
10738
|
+
sketch = new Uint32Array(FANOUT_WORDS);
|
|
10739
|
+
this.fanout.set(markerId, sketch);
|
|
10740
|
+
}
|
|
10741
|
+
const bit = hashToBit(networkKey(ip));
|
|
10742
|
+
sketch[bit >>> 5] = sketch[bit >>> 5] | 1 << (bit & 31);
|
|
10743
|
+
return estimateDistinct(sketch, this.maxNetworks);
|
|
10744
|
+
}
|
|
10745
|
+
/** The `Set-Cookie` handing this client a marker bound to the identity it just claimed. */
|
|
10746
|
+
issue(observation) {
|
|
10747
|
+
return markerCookie(this.cookieName, newMarker(observation.shape, this.ttlMs, this.clock.now()), this.secrets, this.cookieOptions);
|
|
10748
|
+
}
|
|
10749
|
+
};
|
|
10750
|
+
|
|
10751
|
+
// src/site/index.ts
|
|
10752
|
+
init_lru();
|
|
10753
|
+
var WALK_BUCKETS = 1024;
|
|
10754
|
+
function bucketSet(record, bucket) {
|
|
10755
|
+
record.bits[bucket >>> 5] = record.bits[bucket >>> 5] | 1 << (bucket & 31);
|
|
10756
|
+
}
|
|
10757
|
+
function bucketGet(record, bucket) {
|
|
10758
|
+
return (record.bits[bucket >>> 5] & 1 << (bucket & 31)) !== 0;
|
|
10759
|
+
}
|
|
10760
|
+
function coarsen(record) {
|
|
10761
|
+
const merged = new Uint32Array(WALK_BUCKETS / 32);
|
|
10762
|
+
for (let bucket = 0; bucket < WALK_BUCKETS / 2; bucket++) {
|
|
10763
|
+
const low = bucket * 2;
|
|
10764
|
+
if (bucketGet(record, low) || bucketGet(record, low + 1)) {
|
|
10765
|
+
merged[bucket >>> 5] = merged[bucket >>> 5] | 1 << (bucket & 31);
|
|
10766
|
+
}
|
|
10767
|
+
}
|
|
10768
|
+
record.bits = merged;
|
|
10769
|
+
record.scale *= 2;
|
|
10770
|
+
}
|
|
10771
|
+
var DEFAULTS = {
|
|
10772
|
+
warmupRequests: 5e3,
|
|
10773
|
+
maxPaths: 5e4,
|
|
10774
|
+
maxTemplates: 256,
|
|
10775
|
+
maxActorsPerTemplate: 64,
|
|
10776
|
+
windowMs: 60 * 6e4,
|
|
10777
|
+
maxWatchedPaths: 2048,
|
|
10778
|
+
maxActorsPerPath: 64
|
|
10779
|
+
};
|
|
10780
|
+
var SiteProfile = class {
|
|
10781
|
+
options;
|
|
10782
|
+
paths;
|
|
10783
|
+
walks;
|
|
10784
|
+
watched;
|
|
10785
|
+
clock;
|
|
10786
|
+
observed = 0;
|
|
10787
|
+
misses = 0;
|
|
10788
|
+
answered = 0;
|
|
10789
|
+
constructor(options) {
|
|
10790
|
+
this.options = { ...DEFAULTS, ...stripUndefined2(options) };
|
|
10791
|
+
this.paths = new TtlLru(this.options.maxPaths, this.options.windowMs, options.clock);
|
|
10792
|
+
this.walks = new TtlLru(this.options.maxTemplates, this.options.windowMs, options.clock);
|
|
10793
|
+
this.clock = options.clock;
|
|
10794
|
+
this.watched = this.options.maxWatchedPaths > 0 ? new TtlLru(this.options.maxWatchedPaths, this.options.windowMs, options.clock) : void 0;
|
|
10795
|
+
}
|
|
10796
|
+
/**
|
|
10797
|
+
* Whether enough traffic has been seen for any of this to mean anything.
|
|
10798
|
+
*
|
|
10799
|
+
* Every reader checks this. A profile that answers during warmup is worse than one
|
|
10800
|
+
* that does not exist, because it answers confidently and wrongly.
|
|
10801
|
+
*/
|
|
10802
|
+
get warm() {
|
|
10803
|
+
return this.observed >= this.options.warmupRequests;
|
|
10804
|
+
}
|
|
10805
|
+
get requestsObserved() {
|
|
10806
|
+
return this.observed;
|
|
10807
|
+
}
|
|
10808
|
+
/** The share of answered requests that were misses, or `undefined` before warmup. */
|
|
10809
|
+
get missRate() {
|
|
10810
|
+
return this.warm && this.answered > 0 ? this.misses / this.answered : void 0;
|
|
10811
|
+
}
|
|
10812
|
+
/** Files a request. Called once per assessed request, before the detectors run. */
|
|
10813
|
+
record(path, actorKey) {
|
|
10814
|
+
if (this.observed < Number.MAX_SAFE_INTEGER) this.observed++;
|
|
10815
|
+
const seen = this.paths.get(path);
|
|
10816
|
+
this.paths.set(path, (seen ?? 0) + 1);
|
|
10817
|
+
if (this.watched === void 0 || !this.warm) return;
|
|
10818
|
+
let surge = this.watched.get(path);
|
|
10819
|
+
if (surge === void 0) {
|
|
10820
|
+
if (seen !== void 0) return;
|
|
10821
|
+
surge = { actors: /* @__PURE__ */ new Set(), firstSeen: this.clock.now(), answered: 0, misses: 0 };
|
|
10822
|
+
this.watched.set(path, surge);
|
|
10823
|
+
}
|
|
10824
|
+
if (surge.actors.size < this.options.maxActorsPerPath) surge.actors.add(actorKey);
|
|
10825
|
+
}
|
|
10826
|
+
/** Files what the application answered, for the site's miss rate and each watched path. */
|
|
10827
|
+
recordOutcome(path, status) {
|
|
10828
|
+
if (this.answered < Number.MAX_SAFE_INTEGER) this.answered++;
|
|
10829
|
+
const missed = status === 404 || status === 410;
|
|
10830
|
+
if (missed) this.misses++;
|
|
10831
|
+
const surge = this.watched?.get(path);
|
|
10832
|
+
if (surge === void 0) return;
|
|
10833
|
+
surge.answered++;
|
|
10834
|
+
if (missed) surge.misses++;
|
|
10835
|
+
}
|
|
10836
|
+
/** What has happened to a path since it first appeared. `undefined` if not watched. */
|
|
10837
|
+
surgeOf(path) {
|
|
10838
|
+
if (!this.warm) return void 0;
|
|
10839
|
+
const surge = this.watched?.get(path);
|
|
10840
|
+
if (surge === void 0) return void 0;
|
|
10841
|
+
return { clients: surge.actors.size, ageMs: this.clock.now() - surge.firstSeen, answered: surge.answered, misses: surge.misses };
|
|
10842
|
+
}
|
|
10843
|
+
/**
|
|
10844
|
+
* How many times the site has served this path, to anybody.
|
|
10845
|
+
*
|
|
10846
|
+
* `undefined` before warmup, and `0` for a path this process has not seen — which is
|
|
10847
|
+
* not the same as one the site does not have, and is why the detector reading this
|
|
10848
|
+
* needs a great many of them before it says anything.
|
|
10849
|
+
*/
|
|
10850
|
+
timesSeen(path) {
|
|
10851
|
+
return this.warm ? this.paths.get(path) ?? 0 : void 0;
|
|
10852
|
+
}
|
|
10853
|
+
/** Files one step of a numeric walk against the shape it belongs to. */
|
|
10854
|
+
recordWalk(template, id, actorKey) {
|
|
10855
|
+
let record = this.walks.get(template);
|
|
10856
|
+
if (record === void 0) {
|
|
10857
|
+
record = { actors: /* @__PURE__ */ new Set(), bits: new Uint32Array(WALK_BUCKETS / 32), scale: 1, min: id, max: id, visits: 0 };
|
|
10858
|
+
this.walks.set(template, record);
|
|
10859
|
+
}
|
|
10860
|
+
record.visits++;
|
|
10861
|
+
if (record.actors.size < this.options.maxActorsPerTemplate) record.actors.add(actorKey);
|
|
10862
|
+
if (id < record.min) record.min = id;
|
|
10863
|
+
if (id > record.max) record.max = id;
|
|
10864
|
+
while (Math.floor(record.max / record.scale) >= WALK_BUCKETS) coarsen(record);
|
|
10865
|
+
bucketSet(record, Math.floor(id / record.scale));
|
|
10866
|
+
}
|
|
10867
|
+
/** What the whole site has done with one numeric shape. `undefined` before warmup. */
|
|
10868
|
+
spreadOf(template) {
|
|
10869
|
+
if (!this.warm) return void 0;
|
|
10870
|
+
const record = this.walks.get(template);
|
|
10871
|
+
if (record === void 0) return void 0;
|
|
10872
|
+
const lowest = Math.floor(record.min / record.scale);
|
|
10873
|
+
const highest = Math.floor(record.max / record.scale);
|
|
10874
|
+
let touched = 0;
|
|
10875
|
+
for (let bucket = lowest; bucket <= highest; bucket++) if (bucketGet(record, bucket)) touched++;
|
|
10876
|
+
const window = highest - lowest + 1;
|
|
10877
|
+
return {
|
|
10878
|
+
actors: record.actors.size,
|
|
10879
|
+
ids: touched * record.scale,
|
|
10880
|
+
buckets: touched,
|
|
10881
|
+
scale: record.scale,
|
|
10882
|
+
visits: record.visits,
|
|
10883
|
+
coverage: touched / window
|
|
10884
|
+
};
|
|
10885
|
+
}
|
|
10886
|
+
};
|
|
10887
|
+
function stripUndefined2(value) {
|
|
10888
|
+
const out = {};
|
|
10889
|
+
for (const [key, entry] of Object.entries(value)) if (entry !== void 0) out[key] = entry;
|
|
10890
|
+
return out;
|
|
10891
|
+
}
|
|
10892
|
+
|
|
10893
|
+
// src/core.ts
|
|
10894
|
+
init_state();
|
|
9570
10895
|
init_config();
|
|
9571
10896
|
|
|
9572
10897
|
// src/internal/async.ts
|
|
@@ -9591,12 +10916,22 @@ function withTimeout(work, ms, fallback) {
|
|
|
9591
10916
|
// src/core.ts
|
|
9592
10917
|
init_ip();
|
|
9593
10918
|
var TIMED_OUT = /* @__PURE__ */ Symbol("bothandler.timeout");
|
|
10919
|
+
function sanitize(item) {
|
|
10920
|
+
const summary = safeSummary(item.summary);
|
|
10921
|
+
const basis = item.deterministicBasis === void 0 ? void 0 : safeSummary(item.deterministicBasis);
|
|
10922
|
+
if (summary === item.summary && basis === item.deterministicBasis) return item;
|
|
10923
|
+
return { ...item, summary, ...basis === void 0 ? {} : { deterministicBasis: basis } };
|
|
10924
|
+
}
|
|
9594
10925
|
var BotHandler = class {
|
|
9595
10926
|
config;
|
|
9596
10927
|
registry;
|
|
9597
10928
|
store;
|
|
9598
10929
|
policy;
|
|
9599
10930
|
challenge;
|
|
10931
|
+
/** The marker-cookie probe, when the operator asked for one. See `probe` in the config. */
|
|
10932
|
+
probe;
|
|
10933
|
+
/** The site-wide baseline, when the operator asked for one. See `site` in the config. */
|
|
10934
|
+
site;
|
|
9600
10935
|
notifications;
|
|
9601
10936
|
/**
|
|
9602
10937
|
* The traffic audit, or `undefined` when it was switched off with `audit: false`.
|
|
@@ -9612,6 +10947,8 @@ var BotHandler = class {
|
|
|
9612
10947
|
cheapDetectors = [];
|
|
9613
10948
|
ioDetectors = [];
|
|
9614
10949
|
confirmingDetectors = [];
|
|
10950
|
+
/** Hoisted from the resolved config: read once per detector per request. */
|
|
10951
|
+
shadowIds;
|
|
9615
10952
|
events;
|
|
9616
10953
|
ignoreExact;
|
|
9617
10954
|
ignorePatterns;
|
|
@@ -9626,12 +10963,15 @@ var BotHandler = class {
|
|
|
9626
10963
|
this.store = options.store ?? new MemoryStore({ clock: this.config.clock });
|
|
9627
10964
|
this.registry = new ActorRegistry(this.config.clock, { windowMs: this.config.actorWindowMs, maxActors: this.config.maxActors });
|
|
9628
10965
|
this.signatures = compileSignatures(this.config.signatures);
|
|
10966
|
+
this.shadowIds = this.config.shadowDetectors;
|
|
9629
10967
|
this.resolver = cachingResolver(options.resolver ?? nodeDnsResolver(this.config.detectorTimeoutMs));
|
|
9630
10968
|
this.handlers = new Map((options.handlers ?? []).map((handler) => [handler.id, handler]));
|
|
9631
10969
|
this.isHuman = options.isHuman;
|
|
9632
10970
|
this.meter = options.metrics === false ? void 0 : new Metrics(typeof options.metrics === "object" ? options.metrics : {});
|
|
9633
10971
|
this.timing = this.meter?.perDetectorTiming === true;
|
|
9634
10972
|
this.challenge = options.challenge ? new ChallengeService({ ...options.challenge, store: this.store, clock: this.config.clock }) : void 0;
|
|
10973
|
+
this.probe = options.probe !== void 0 ? new MarkerProbe({ ...options.probe, clock: this.config.clock }) : void 0;
|
|
10974
|
+
this.site = options.site !== void 0 ? new SiteProfile({ ...options.site, clock: this.config.clock }) : void 0;
|
|
9635
10975
|
this.notifications = new NotificationHub({
|
|
9636
10976
|
...options.notifications,
|
|
9637
10977
|
clock: this.config.clock,
|
|
@@ -9652,12 +10992,35 @@ var BotHandler = class {
|
|
|
9652
10992
|
const detectors = [...this.config.detectors];
|
|
9653
10993
|
if (this.challenge && !detectors.some((detector) => detector.id === "clearance")) {
|
|
9654
10994
|
detectors.unshift(clearanceDetector(this.challenge));
|
|
10995
|
+
if (!detectors.some((detector) => detector.id === "challenge-reaction")) {
|
|
10996
|
+
detectors.unshift(challengeReactionDetector());
|
|
10997
|
+
}
|
|
10998
|
+
if (!detectors.some((detector) => detector.id === "challenge-integrity")) {
|
|
10999
|
+
detectors.unshift(challengeIntegrityDetector());
|
|
11000
|
+
}
|
|
11001
|
+
}
|
|
11002
|
+
if (this.site !== void 0) {
|
|
11003
|
+
for (const detector of [distributedWalkDetector(), pathNoveltyDetector(), missBaselineDetector(), pathCampaignDetector()]) {
|
|
11004
|
+
if (!detectors.some((installed) => installed.id === detector.id)) detectors.unshift(detector);
|
|
11005
|
+
}
|
|
11006
|
+
}
|
|
11007
|
+
if (this.probe !== void 0) {
|
|
11008
|
+
for (const detector of [identityDriftDetector(), markerIntegrityDetector(), markerPersistenceDetector(), markerFanoutDetector()]) {
|
|
11009
|
+
if (!detectors.some((installed) => installed.id === detector.id)) detectors.unshift(detector);
|
|
11010
|
+
}
|
|
9655
11011
|
}
|
|
9656
11012
|
for (const detector of detectors) {
|
|
9657
11013
|
if (detector.stage === "confirming") this.confirmingDetectors.push(detector);
|
|
9658
11014
|
else if (detector.cost === "io") this.ioDetectors.push(detector);
|
|
9659
11015
|
else this.cheapDetectors.push(detector);
|
|
9660
11016
|
}
|
|
11017
|
+
for (const id of this.shadowIds) {
|
|
11018
|
+
if (!detectors.some((detector) => detector.id === id)) {
|
|
11019
|
+
this.warn(
|
|
11020
|
+
`shadowDetectors names "${id}", which is not an installed detector, so nothing is being shadowed by that entry. Installed: ${detectors.map((detector) => detector.id).join(", ")}.`
|
|
11021
|
+
);
|
|
11022
|
+
}
|
|
11023
|
+
}
|
|
9661
11024
|
if (options.shareConfirmations === true) {
|
|
9662
11025
|
this.registry.onFirstSight = (state) => this.loadSharedConfirmations(state);
|
|
9663
11026
|
}
|
|
@@ -9827,9 +11190,30 @@ var BotHandler = class {
|
|
|
9827
11190
|
* nothing else. The bundled Node adapter wires it up for you.
|
|
9828
11191
|
*/
|
|
9829
11192
|
recordOutcome(facts, status) {
|
|
11193
|
+
if (!this.isIgnoredPath(facts.path) && !this.isAllowlisted(facts.ip)) {
|
|
11194
|
+
this.site?.recordOutcome(facts.path, status);
|
|
11195
|
+
}
|
|
9830
11196
|
if (!Number.isFinite(status)) return;
|
|
9831
11197
|
this.registry.peek(this.actorKeyFor(facts))?.recordOutcome(status);
|
|
9832
11198
|
}
|
|
11199
|
+
/**
|
|
11200
|
+
* Gives an actor a name, or clears it with `undefined`.
|
|
11201
|
+
*
|
|
11202
|
+
* Detection never reads it — a label cannot make anybody more or less suspicious, and
|
|
11203
|
+
* that separation is deliberate: the moment a note changes a verdict, writing notes
|
|
11204
|
+
* becomes a way to be wrong about people at scale. It is for the humans reading the
|
|
11205
|
+
* dashboard, and it survives exactly as long as the actor does.
|
|
11206
|
+
*
|
|
11207
|
+
* Available from code so a deployment can label what it already knows — its own
|
|
11208
|
+
* monitoring, a partner's feed, the office egress — rather than waiting for somebody to
|
|
11209
|
+
* recognise the address twice.
|
|
11210
|
+
*/
|
|
11211
|
+
labelActor(key, label, context = {}) {
|
|
11212
|
+
const state = this.registry.peek(key);
|
|
11213
|
+
if (state === void 0) return;
|
|
11214
|
+
state.setLabel(label);
|
|
11215
|
+
this.warn(`Actor "${key}" was ${label === void 0 ? "unlabelled" : `labelled "${state.label ?? ""}"`} at runtime${attribute(context)}.`);
|
|
11216
|
+
}
|
|
9833
11217
|
/** Convenience for `updateRanges("crawler:<id>", …)`, matching a signature id. */
|
|
9834
11218
|
updateCrawlerRanges(signatureId, entries, context = {}) {
|
|
9835
11219
|
this.updateRanges(`crawler:${signatureId}`, entries, context);
|
|
@@ -9942,7 +11326,10 @@ var BotHandler = class {
|
|
|
9942
11326
|
id: detector.id,
|
|
9943
11327
|
description: detector.description,
|
|
9944
11328
|
cost: detector.cost ?? "cheap",
|
|
9945
|
-
stage: detector.stage ?? "always"
|
|
11329
|
+
stage: detector.stage ?? "always",
|
|
11330
|
+
// Present only when it is true, so a deployment shadowing nothing lists exactly
|
|
11331
|
+
// what it listed before.
|
|
11332
|
+
...this.shadowIds.has(detector.id) ? { shadow: true } : {}
|
|
9946
11333
|
}));
|
|
9947
11334
|
}
|
|
9948
11335
|
/** Recovers the client address from a socket address and headers, honouring the proxy config. */
|
|
@@ -9985,8 +11372,22 @@ var BotHandler = class {
|
|
|
9985
11372
|
const state = record ? this.registry.observe(actorKey, facts) : detachedActor(actorKey, facts);
|
|
9986
11373
|
const ua = parseUserAgent(facts.headers["user-agent"]);
|
|
9987
11374
|
const signatureMatches = ua.lower.length > 0 ? this.signatures.matchAll(ua.lower) : [];
|
|
11375
|
+
for (const match of signatureMatches) state.noteIdentity(match.id, match.category, match.verification.kind !== "none");
|
|
11376
|
+
const marker = this.probe?.observe(facts, ua);
|
|
11377
|
+
if (marker !== void 0 && record) {
|
|
11378
|
+
state.noteMarker(marker.reading.kind === "valid", marker.reading.kind === "forged", marker.drift);
|
|
11379
|
+
}
|
|
11380
|
+
if (this.site !== void 0 && record) {
|
|
11381
|
+
const seenBefore = this.site.timesSeen(facts.path);
|
|
11382
|
+
this.site.record(facts.path, actorKey);
|
|
11383
|
+
if (seenBefore === 0) state.noteNovelPath();
|
|
11384
|
+
const step = walkStepOf(facts.path);
|
|
11385
|
+
if (step !== void 0) this.site.recordWalk(step.template, step.id, actorKey);
|
|
11386
|
+
}
|
|
9988
11387
|
const context = {
|
|
9989
11388
|
facts,
|
|
11389
|
+
marker,
|
|
11390
|
+
site: this.site,
|
|
9990
11391
|
ua,
|
|
9991
11392
|
actor: state.snapshot(facts.timestamp),
|
|
9992
11393
|
state,
|
|
@@ -9998,21 +11399,22 @@ var BotHandler = class {
|
|
|
9998
11399
|
shared: /* @__PURE__ */ new Map()
|
|
9999
11400
|
};
|
|
10000
11401
|
const evidence2 = [];
|
|
11402
|
+
const shadowEvidence = [];
|
|
10001
11403
|
const failures = [];
|
|
10002
11404
|
let pending;
|
|
10003
11405
|
for (const detector of this.cheapDetectors) {
|
|
10004
|
-
const inFlight = this.run(detector, context, evidence2, failures, 0);
|
|
11406
|
+
const inFlight = this.run(detector, context, evidence2, shadowEvidence, failures, 0);
|
|
10005
11407
|
if (inFlight !== void 0) (pending ??= []).push(inFlight);
|
|
10006
11408
|
}
|
|
10007
11409
|
for (const detector of this.ioDetectors) {
|
|
10008
|
-
const inFlight = this.run(detector, context, evidence2, failures, this.config.detectorTimeoutMs);
|
|
11410
|
+
const inFlight = this.run(detector, context, evidence2, shadowEvidence, failures, this.config.detectorTimeoutMs);
|
|
10009
11411
|
if (inFlight !== void 0) (pending ??= []).push(inFlight);
|
|
10010
11412
|
}
|
|
10011
11413
|
if (pending !== void 0) await Promise.all(pending);
|
|
10012
11414
|
if (signatureMatches.length > 0 && this.confirmingDetectors.length > 0) {
|
|
10013
11415
|
let confirming;
|
|
10014
11416
|
for (const detector of this.confirmingDetectors) {
|
|
10015
|
-
const inFlight = this.run(detector, context, evidence2, failures, this.config.detectorTimeoutMs);
|
|
11417
|
+
const inFlight = this.run(detector, context, evidence2, shadowEvidence, failures, this.config.detectorTimeoutMs);
|
|
10016
11418
|
if (inFlight !== void 0) (confirming ??= []).push(inFlight);
|
|
10017
11419
|
}
|
|
10018
11420
|
if (confirming !== void 0) await Promise.all(confirming);
|
|
@@ -10032,11 +11434,23 @@ var BotHandler = class {
|
|
|
10032
11434
|
this.fail(error, "isHuman");
|
|
10033
11435
|
}
|
|
10034
11436
|
}
|
|
11437
|
+
if (evidence2.some((item) => item.detector === "probe-signature")) state.notePayloadProbe();
|
|
10035
11438
|
const combined = combineEvidence(evidence2, {
|
|
10036
11439
|
suspectThreshold: this.config.suspectThreshold,
|
|
10037
11440
|
strictEvidence: this.config.strictEvidence,
|
|
10038
11441
|
onEvidenceViolation: (message) => this.warn(message)
|
|
10039
11442
|
});
|
|
11443
|
+
let shadowVerdict;
|
|
11444
|
+
if (shadowEvidence.length > 0) {
|
|
11445
|
+
const wouldBe = combineEvidence([...evidence2, ...shadowEvidence], {
|
|
11446
|
+
suspectThreshold: this.config.suspectThreshold,
|
|
11447
|
+
strictEvidence: this.config.strictEvidence,
|
|
11448
|
+
onEvidenceViolation: (message, item) => {
|
|
11449
|
+
if (item.shadow === true) this.warn(message);
|
|
11450
|
+
}
|
|
11451
|
+
});
|
|
11452
|
+
shadowVerdict = { verdict: wouldBe.verdict, botClass: wouldBe.botClass, score: wouldBe.score, certain: wouldBe.certain };
|
|
11453
|
+
}
|
|
10040
11454
|
const actor = state.snapshot(facts.timestamp);
|
|
10041
11455
|
if (combined.verdict === "confirmed-bot" && record) {
|
|
10042
11456
|
state.confirmations++;
|
|
@@ -10052,10 +11466,13 @@ var BotHandler = class {
|
|
|
10052
11466
|
certain: combined.certain,
|
|
10053
11467
|
evidence: combined.botEvidence,
|
|
10054
11468
|
humanEvidence: combined.humanEvidence,
|
|
11469
|
+
shadowEvidence: sortEvidence(shadowEvidence),
|
|
11470
|
+
...shadowVerdict === void 0 ? {} : { shadowVerdict },
|
|
10055
11471
|
actor,
|
|
10056
11472
|
durationMs: this.config.clock.now() - started,
|
|
10057
11473
|
failures,
|
|
10058
|
-
facts
|
|
11474
|
+
facts,
|
|
11475
|
+
...marker === void 0 ? {} : { marker }
|
|
10059
11476
|
};
|
|
10060
11477
|
if (!record) return assessment;
|
|
10061
11478
|
this.meter?.recordAssessment(assessment);
|
|
@@ -10092,15 +11509,46 @@ var BotHandler = class {
|
|
|
10092
11509
|
onChallenge: (event) => {
|
|
10093
11510
|
this.meter?.recordChallenge(event);
|
|
10094
11511
|
const state = this.registry.peek(assessment.actor.key);
|
|
10095
|
-
if (state !== void 0)
|
|
11512
|
+
if (state !== void 0) {
|
|
11513
|
+
state.unsolvedChallenges++;
|
|
11514
|
+
state.noteChallengeIssued(
|
|
11515
|
+
this.config.clock.now(),
|
|
11516
|
+
assessment.marker?.shape ?? identityShape(assessment.facts, parseUserAgent(assessment.facts.headers["user-agent"]))
|
|
11517
|
+
);
|
|
11518
|
+
}
|
|
10096
11519
|
this.events.emit("challenge", { phase: event, actorKey: assessment.actor.key });
|
|
10097
11520
|
}
|
|
10098
11521
|
});
|
|
11522
|
+
const issued = this.markerFor(assessment);
|
|
11523
|
+
if (issued !== void 0) {
|
|
11524
|
+
if (outcome.kind === "continue" && outcome.responseHeaders?.["set-cookie"] === void 0) {
|
|
11525
|
+
outcome.responseHeaders = { ...outcome.responseHeaders, "set-cookie": issued };
|
|
11526
|
+
} else if (outcome.kind === "respond" && outcome.headers["set-cookie"] === void 0) {
|
|
11527
|
+
outcome.headers = { ...outcome.headers, "set-cookie": issued };
|
|
11528
|
+
}
|
|
11529
|
+
}
|
|
10099
11530
|
if (this.notifications.enabled && (outcome.kind !== "continue" || outcome.delayMs !== void 0)) {
|
|
10100
11531
|
this.notifications.emit({ type: "action", at: new Date(facts.timestamp).toISOString(), assessment, decision });
|
|
10101
11532
|
}
|
|
10102
11533
|
return { assessment, decision, outcome };
|
|
10103
11534
|
}
|
|
11535
|
+
/**
|
|
11536
|
+
* The `Set-Cookie` this response should carry, if any.
|
|
11537
|
+
*
|
|
11538
|
+
* Nothing is issued to a client that already holds a valid marker, because a
|
|
11539
|
+
* `Set-Cookie` on every response makes every response uncacheable by shared caches —
|
|
11540
|
+
* a detection feature is not worth a site's cache-hit ratio. Nothing is issued to a
|
|
11541
|
+
* verified crawler either: Googlebot does not keep cookies, so a marker sent to it is
|
|
11542
|
+
* a header that will never come back and an issuance count that means nothing.
|
|
11543
|
+
*/
|
|
11544
|
+
markerFor(assessment) {
|
|
11545
|
+
if (this.probe === void 0 || assessment.marker === void 0) return void 0;
|
|
11546
|
+
if (assessment.botClass === "verified-bot") return void 0;
|
|
11547
|
+
if (!this.probe.shouldIssue(assessment.marker)) return void 0;
|
|
11548
|
+
const state = this.registry.peek(assessment.actor.key);
|
|
11549
|
+
state?.noteMarkerIssued();
|
|
11550
|
+
return this.probe.issue(assessment.marker);
|
|
11551
|
+
}
|
|
10104
11552
|
/** True when this request is the challenge verification endpoint. */
|
|
10105
11553
|
isChallengeEndpoint(facts) {
|
|
10106
11554
|
return this.challenge !== void 0 && facts.method === "POST" && facts.path === this.challenge.verifyPath;
|
|
@@ -10119,6 +11567,9 @@ var BotHandler = class {
|
|
|
10119
11567
|
this.meter?.recordChallenge(outcome.ok ? "solved" : "rejected");
|
|
10120
11568
|
if (outcome.ok) this.meter?.recordClearance(outcome.level);
|
|
10121
11569
|
else this.meter?.recordChallengeRejection(outcome.reason);
|
|
11570
|
+
if (!outcome.ok && outcome.signal !== void 0) {
|
|
11571
|
+
this.registry.peek(actorKey)?.noteChallengeAnomaly(outcome.signal);
|
|
11572
|
+
}
|
|
10122
11573
|
if (outcome.interactionScore !== void 0) this.meter?.recordInteractionScore(outcome.interactionScore);
|
|
10123
11574
|
if (outcome.ok) this.audit?.recordChallengeSolved(this.config.clock.now());
|
|
10124
11575
|
this.events.emit("challenge", {
|
|
@@ -10155,7 +11606,9 @@ var BotHandler = class {
|
|
|
10155
11606
|
* await. Failures are absorbed here in both paths: a detector can throw, reject or
|
|
10156
11607
|
* hang, and none of those may reach the request.
|
|
10157
11608
|
*/
|
|
10158
|
-
run(detector, context, sink, failures, timeoutMs) {
|
|
11609
|
+
run(detector, context, sink, shadowSink, failures, timeoutMs) {
|
|
11610
|
+
const shadowed = this.shadowIds.has(detector.id);
|
|
11611
|
+
const target = shadowed ? shadowSink : sink;
|
|
10159
11612
|
const startedAt = this.timing ? this.config.clock.now() : 0;
|
|
10160
11613
|
let raw;
|
|
10161
11614
|
try {
|
|
@@ -10166,7 +11619,7 @@ var BotHandler = class {
|
|
|
10166
11619
|
return void 0;
|
|
10167
11620
|
}
|
|
10168
11621
|
if (!(raw instanceof Promise)) {
|
|
10169
|
-
this.collect(raw,
|
|
11622
|
+
this.collect(raw, target, shadowed);
|
|
10170
11623
|
if (startedAt !== 0) this.meter?.recordDetectorTiming(detector.id, this.config.clock.now() - startedAt);
|
|
10171
11624
|
return void 0;
|
|
10172
11625
|
}
|
|
@@ -10181,7 +11634,7 @@ var BotHandler = class {
|
|
|
10181
11634
|
this.events.emit("detector-failure", { detector: detector.id, reason: "timeout", message, requestId: "" });
|
|
10182
11635
|
return;
|
|
10183
11636
|
}
|
|
10184
|
-
this.collect(result,
|
|
11637
|
+
this.collect(result, target, shadowed);
|
|
10185
11638
|
},
|
|
10186
11639
|
(error) => {
|
|
10187
11640
|
if (startedAt !== 0) this.meter?.recordDetectorTiming(detector.id, this.config.clock.now() - startedAt);
|
|
@@ -10189,13 +11642,14 @@ var BotHandler = class {
|
|
|
10189
11642
|
}
|
|
10190
11643
|
);
|
|
10191
11644
|
}
|
|
10192
|
-
collect(result, sink) {
|
|
11645
|
+
collect(result, sink, shadowed = false) {
|
|
10193
11646
|
if (result === void 0 || result === null) return;
|
|
11647
|
+
const mark = (item) => shadowed ? { ...sanitize(item), shadow: true } : sanitize(item);
|
|
10194
11648
|
if (Array.isArray(result)) {
|
|
10195
|
-
for (let i = 0; i < result.length; i++) sink.push(result[i]);
|
|
11649
|
+
for (let i = 0; i < result.length; i++) sink.push(mark(result[i]));
|
|
10196
11650
|
return;
|
|
10197
11651
|
}
|
|
10198
|
-
sink.push(result);
|
|
11652
|
+
sink.push(mark(result));
|
|
10199
11653
|
}
|
|
10200
11654
|
recordFailure(detector, failures, error, requestId = "") {
|
|
10201
11655
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -10215,6 +11669,7 @@ var BotHandler = class {
|
|
|
10215
11669
|
certain: false,
|
|
10216
11670
|
evidence: [],
|
|
10217
11671
|
humanEvidence: [],
|
|
11672
|
+
shadowEvidence: [],
|
|
10218
11673
|
actor: existing?.snapshot(facts.timestamp) ?? {
|
|
10219
11674
|
key: actorKey,
|
|
10220
11675
|
requests: 0,
|
|
@@ -10311,11 +11766,41 @@ async function fetchPrefixes(source, options, limits) {
|
|
|
10311
11766
|
redirect: "follow"
|
|
10312
11767
|
});
|
|
10313
11768
|
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
|
|
10314
|
-
const text = await response
|
|
10315
|
-
if (text.length > MAX_BYTES) throw new Error(`the list is ${Math.round(text.length / 1024)} kB, which is not a list of prefixes`);
|
|
11769
|
+
const text = await readCapped(response);
|
|
10316
11770
|
const prefixes = text.trimStart().startsWith("{") ? fromJson(text) : fromLines(text);
|
|
10317
11771
|
return validate(prefixes, limits);
|
|
10318
11772
|
}
|
|
11773
|
+
async function readCapped(response) {
|
|
11774
|
+
const declared = Number(response.headers.get("content-length"));
|
|
11775
|
+
if (Number.isFinite(declared) && declared > MAX_BYTES) {
|
|
11776
|
+
throw new Error(`the list declares ${Math.round(declared / 1024)} kB, which is not a list of prefixes`);
|
|
11777
|
+
}
|
|
11778
|
+
const body = response.body;
|
|
11779
|
+
if (body === null || body === void 0 || typeof body.getReader !== "function") {
|
|
11780
|
+
const whole = await response.text();
|
|
11781
|
+
if (whole.length > MAX_BYTES) throw new Error(`the list is ${Math.round(whole.length / 1024)} kB, which is not a list of prefixes`);
|
|
11782
|
+
return whole;
|
|
11783
|
+
}
|
|
11784
|
+
const reader = body.getReader();
|
|
11785
|
+
const decoder = new TextDecoder();
|
|
11786
|
+
let text = "";
|
|
11787
|
+
let bytes = 0;
|
|
11788
|
+
try {
|
|
11789
|
+
while (true) {
|
|
11790
|
+
const { done, value } = await reader.read();
|
|
11791
|
+
if (done) break;
|
|
11792
|
+
bytes += value.byteLength;
|
|
11793
|
+
if (bytes > MAX_BYTES) {
|
|
11794
|
+
throw new Error(`the list is over ${Math.round(MAX_BYTES / 1024)} kB, which is not a list of prefixes`);
|
|
11795
|
+
}
|
|
11796
|
+
text += decoder.decode(value, { stream: true });
|
|
11797
|
+
}
|
|
11798
|
+
} finally {
|
|
11799
|
+
await reader.cancel().catch(() => {
|
|
11800
|
+
});
|
|
11801
|
+
}
|
|
11802
|
+
return text + decoder.decode();
|
|
11803
|
+
}
|
|
10319
11804
|
function fromJson(text) {
|
|
10320
11805
|
const document = JSON.parse(text);
|
|
10321
11806
|
if (!Array.isArray(document.prefixes)) throw new Error("no `prefixes` array in the document");
|
|
@@ -10407,11 +11892,40 @@ var RedisStore = class {
|
|
|
10407
11892
|
client;
|
|
10408
11893
|
prefix;
|
|
10409
11894
|
clock;
|
|
11895
|
+
/**
|
|
11896
|
+
* Counts one request into the fixed window `key` is bucketed by.
|
|
11897
|
+
*
|
|
11898
|
+
* This used to be `INCR`, then `PEXPIRE` when the count came back as 1 — which is
|
|
11899
|
+
* correct only if the process survives long enough to send the second command. A
|
|
11900
|
+
* deploy, an OOM kill or a dropped connection in between left a counter key behind
|
|
11901
|
+
* with no expiry at all, and nothing would ever clean it up: the next request falls
|
|
11902
|
+
* into the next bucket, under a different key, so the orphan is never touched again.
|
|
11903
|
+
* One per unlucky restart is nothing; the point is that it accumulates forever, in a
|
|
11904
|
+
* Redis the operator may well be running with `noeviction`.
|
|
11905
|
+
*
|
|
11906
|
+
* So the expiry is armed by the command that *creates* the key rather than by a
|
|
11907
|
+
* follow-up. `SET … PX … NX` writes the seed only if nothing is there, always with a
|
|
11908
|
+
* lifetime, and does nothing at all once the bucket exists — so it neither costs a
|
|
11909
|
+
* count nor re-arms a window under load. The `INCR` is issued without waiting for its
|
|
11910
|
+
* reply, so both commands are on the wire together and this stays one round trip.
|
|
11911
|
+
* Ordering holds because a Redis client writes commands to its connection in call
|
|
11912
|
+
* order and Redis executes them in arrival order, which means the key has a lifetime
|
|
11913
|
+
* from the instant it exists.
|
|
11914
|
+
*
|
|
11915
|
+
* Not a Lua script, which would make it a single command: `eval` is the one thing
|
|
11916
|
+
* `ioredis` and `node-redis` spell differently enough that this interface could not
|
|
11917
|
+
* describe both, and staying client-agnostic is worth more than the last round trip.
|
|
11918
|
+
*/
|
|
10410
11919
|
async increment(key, windowMs) {
|
|
10411
11920
|
const bucket = Math.floor(this.clock.now() / windowMs);
|
|
10412
11921
|
const full = `${this.prefix}c:${key}:${bucket}`;
|
|
11922
|
+
const remaining = (bucket + 1) * windowMs - this.clock.now();
|
|
11923
|
+
const ttl = Number.isFinite(remaining) && remaining > 0 ? remaining : windowMs;
|
|
11924
|
+
const armed = this.client.set(full, "0", "PX", ttl, "NX");
|
|
11925
|
+
armed.catch(() => {
|
|
11926
|
+
});
|
|
10413
11927
|
const count = await this.client.incr(full);
|
|
10414
|
-
|
|
11928
|
+
await armed;
|
|
10415
11929
|
return count;
|
|
10416
11930
|
}
|
|
10417
11931
|
async consumeOnce(key, ttlMs) {
|
|
@@ -10443,6 +11957,10 @@ function consoleNotifier(options = {}) {
|
|
|
10443
11957
|
return;
|
|
10444
11958
|
}
|
|
10445
11959
|
const { assessment, decision } = event;
|
|
11960
|
+
if (event.error !== void 0) {
|
|
11961
|
+
target.error(`[bothandler] error ${event.error.source} \u2014 ${event.error.message}`);
|
|
11962
|
+
return;
|
|
11963
|
+
}
|
|
10446
11964
|
if (assessment === void 0) {
|
|
10447
11965
|
target.warn(`[bothandler] ${event.type} ${event.anomaly?.id ?? "unknown"} \u2014 ${event.anomaly?.summary ?? ""}`);
|
|
10448
11966
|
return;
|
|
@@ -10615,6 +12133,7 @@ init_lru();
|
|
|
10615
12133
|
MAX_DIFFICULTY,
|
|
10616
12134
|
MAX_USER_AGENT_LENGTH,
|
|
10617
12135
|
ManualClock,
|
|
12136
|
+
MarkerProbe,
|
|
10618
12137
|
MemoryStore,
|
|
10619
12138
|
Metrics,
|
|
10620
12139
|
MultiPatternMatcher,
|
|
@@ -10625,6 +12144,7 @@ init_lru();
|
|
|
10625
12144
|
RedisStore,
|
|
10626
12145
|
SCORE_BUCKETS,
|
|
10627
12146
|
SPECIAL_USE_RANGES,
|
|
12147
|
+
SiteProfile,
|
|
10628
12148
|
TERMINAL_ACTIONS,
|
|
10629
12149
|
TRAP_FIELD_SOURCE,
|
|
10630
12150
|
TrafficAudit,
|
|
@@ -10635,9 +12155,12 @@ init_lru();
|
|
|
10635
12155
|
agentFor,
|
|
10636
12156
|
allowCrawlers,
|
|
10637
12157
|
analyseMovement,
|
|
12158
|
+
blendedIdentityDetector,
|
|
10638
12159
|
browsingCoherenceDetector,
|
|
10639
12160
|
cachingResolver,
|
|
10640
12161
|
cadenceDetector,
|
|
12162
|
+
challengeIntegrityDetector,
|
|
12163
|
+
challengeReactionDetector,
|
|
10641
12164
|
cidrContains,
|
|
10642
12165
|
claimsBrowser,
|
|
10643
12166
|
clampDifficulty,
|
|
@@ -10656,6 +12179,7 @@ init_lru();
|
|
|
10656
12179
|
declineAiTraining,
|
|
10657
12180
|
defaultDetectors,
|
|
10658
12181
|
defineHandler,
|
|
12182
|
+
distributedWalkDetector,
|
|
10659
12183
|
evidence,
|
|
10660
12184
|
executeAction,
|
|
10661
12185
|
fetchAddressList,
|
|
@@ -10668,6 +12192,7 @@ init_lru();
|
|
|
10668
12192
|
headerOrderDetector,
|
|
10669
12193
|
headerOrderFingerprint,
|
|
10670
12194
|
idEnumerationDetector,
|
|
12195
|
+
identityDriftDetector,
|
|
10671
12196
|
identityRotationDetector,
|
|
10672
12197
|
independentStrongSignals,
|
|
10673
12198
|
indexSignatures,
|
|
@@ -10675,6 +12200,10 @@ init_lru();
|
|
|
10675
12200
|
ipIntelligenceDetector,
|
|
10676
12201
|
isSpecialUse,
|
|
10677
12202
|
issueToken,
|
|
12203
|
+
markerFanoutDetector,
|
|
12204
|
+
markerIntegrityDetector,
|
|
12205
|
+
markerPersistenceDetector,
|
|
12206
|
+
missBaselineDetector,
|
|
10678
12207
|
monitorOnly,
|
|
10679
12208
|
networkKey,
|
|
10680
12209
|
newChallenge,
|
|
@@ -10690,6 +12219,8 @@ init_lru();
|
|
|
10690
12219
|
parseInteractionReport,
|
|
10691
12220
|
parseIp,
|
|
10692
12221
|
parseUserAgent,
|
|
12222
|
+
pathCampaignDetector,
|
|
12223
|
+
pathNoveltyDetector,
|
|
10693
12224
|
pickTranslation,
|
|
10694
12225
|
probeShapeFor,
|
|
10695
12226
|
probeSignatureDetector,
|
|
@@ -10720,6 +12251,7 @@ init_lru();
|
|
|
10720
12251
|
startCrawlerRangeRefresh,
|
|
10721
12252
|
startDashboard,
|
|
10722
12253
|
systemClock,
|
|
12254
|
+
targetIntegrityDetector,
|
|
10723
12255
|
tlsFingerprintDetector,
|
|
10724
12256
|
toPrometheus,
|
|
10725
12257
|
transportCoherenceDetector,
|