// author: simon-pierre boucher export interface EscalationInput { readonly status: number; readonly contentType: string; readonly body: string; } export type TierDecision = | { readonly action: "proceed" } | { readonly action: "escalate"; readonly reason: string } | { readonly action: "non-html"; readonly reason: string }; type Severity = "decisive" | "strong" | "moderate"; interface Signal { readonly severity: Severity; readonly reason: string; } const CHALLENGE_MARKERS = [ "challenge-platform", "cf-browser-verification", "_Incapsula_", "px-captcha", "datadome", ]; const EMPTY_ROOT_RE = /<[^>]+\bid=["']?(root|__next|app)["']?[^>]*>\s*<\/[a-z]+>/i; const NOSCRIPT_JS_RE = /]*>[\s\S]*?(enable\s+javascript|javascript\s+is\s+required)[\s\S]*?<\/noscript>/i; const TITLE_RE = /]*>\s*(just a moment|attention required|access denied)/i; const HREF_RE = /]*href\s*=/i; function isHtml(contentType: string): boolean { const ct = contentType.toLowerCase(); return ct.includes("text/html") || ct.includes("application/xhtml+xml") || ct === ""; } function textHtmlRatio(html: string): number { if (html.length === 0) return 0; const text = html .replace(//gi, "") .replace(//gi, "") .replace(/<[^>]+>/g, "") .replace(/\s+/g, " ") .trim(); return text.length / html.length; } /** * Decide whether a Tier 0 response should escalate to Tier 1 (§3.4). Pure, no I/O. * Decisive signals escalate on their own; two or more strong/moderate signals * escalate together. Non-HTML content types route to §9 instead of escalating. */ export function shouldEscalate(input: EscalationInput): TierDecision { if (!isHtml(input.contentType)) { return { action: "non-html", reason: `content-type:${input.contentType || "unknown"}` }; } const signals: Signal[] = []; const body = input.body; const bytes = Buffer.byteLength(body, "utf8"); if (input.status === 403 || input.status === 429 || input.status === 503) { signals.push({ severity: "decisive", reason: `status:${input.status}` }); } if (bytes < 2048 && EMPTY_ROOT_RE.test(body)) { signals.push({ severity: "decisive", reason: "empty-spa-root" }); } for (const marker of CHALLENGE_MARKERS) { if (body.includes(marker)) { signals.push({ severity: "decisive", reason: `challenge:${marker}` }); break; } } if (TITLE_RE.test(body)) { signals.push({ severity: "decisive", reason: "challenge-title" }); } if (textHtmlRatio(body) < 0.05) { signals.push({ severity: "strong", reason: "low-text-ratio" }); } if (NOSCRIPT_JS_RE.test(body)) { signals.push({ severity: "strong", reason: "noscript-js-required" }); } if (bytes > 10_240 && !HREF_RE.test(body)) { signals.push({ severity: "moderate", reason: "no-links" }); } const decisive = signals.find((s) => s.severity === "decisive"); if (decisive) { return { action: "escalate", reason: signals.map((s) => s.reason).join(",") }; } const weak = signals.filter((s) => s.severity === "strong" || s.severity === "moderate"); if (weak.length >= 2) { return { action: "escalate", reason: weak.map((s) => s.reason).join(",") }; } return { action: "proceed" }; }