diff options
| author | Yuriy Andamasov <yuriy@vyos.io> | 2026-07-10 17:14:13 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-07-10 16:14:13 +0200 |
| commit | 21689ef59b0eb34b1a29eda739dc10d33d25b44a (patch) | |
| tree | 95883c2ee70d4c09562ae3e0289e18f34999240c /workers/apex/src | |
| parent | 6d2d28d4411ff52ad0f9314cf3696336748ab89d (diff) | |
| download | vyos-documentation-21689ef59b0eb34b1a29eda739dc10d33d25b44a.tar.gz vyos-documentation-21689ef59b0eb34b1a29eda739dc10d33d25b44a.zip | |
docs: Cloudflare Workers hosting pipeline (apex, content workers, CI, previews) (#2140)
* docs-infra: scaffold Cloudflare workers workspace (versions.json v2, matrix, toolchain)
๐ค Generated by [robots](https://vyos.io)
* docs-infra: record full Phase-0 plan decision in workers/PLAN.md
๐ค Generated by [robots](https://vyos.io)
* docs-infra: shared content worker โ asset serving, cache classes, X-Docs-Build, canary no-store
๐ค Generated by [robots](https://vyos.io)
* docs-infra: run worker script before assets; test fetch entrypoint
๐ค Generated by [robots](https://vyos.io)
* docs-infra: apex manifest loader + dispatch map + runtime binding guard (TDD)
๐ค Generated by [robots](https://vyos.io)
* docs-infra: apex redirects (aliases, PDF, trailing-slash) + special paths (TDD)
๐ค Generated by [robots](https://vyos.io)
* docs-infra: PDF redirect honors pdf:null and preserves query
๐ค Generated by [robots](https://vyos.io)
* docs-infra: apex UA gate โ allowlist-wins, log-only AI crawlers, empty block list at launch
๐ค Generated by [robots](https://vyos.io)
* docs-infra: apex router (pipeline ยง3.2), themed 404/503, /kb seam, env configs + congruence test
๐ค Generated by [robots](https://vyos.io)
* docs-infra: add missing-User-Agent regression test for apex UA gate
๐ค Generated by [robots](https://vyos.io)
* docs-infra: R2-streaming preview worker โ MIME map, noindex, no-store (TDD)
๐ค Generated by [robots](https://vyos.io)
* docs-infra: preview 404 no-store + fetch handler tests
๐ค Generated by [robots](https://vyos.io)
* docs-infra: bootstrap script โ binding-target workers must exist before apex deploys
๐ค Generated by [robots](https://vyos.io)
* docs-infra: apex run_worker_first, lockfile for npm ci, PDF Location from manifest
๐ค Generated by [robots](https://vyos.io)
* docs-infra: derive html_baseurl from DOCS_VERSION_SLUG with RTD fallback (canonical gate prereq)
๐ค Generated by [robots](https://vyos.io)
* docs-infra: version picker + status banner + language scaffold (vanilla JS, TDD pure core)
๐ค Generated by [robots](https://vyos.io)
* docs-infra: picker preserves query+hash across switch; valid breadcrumb markup
๐ค Generated by [robots](https://vyos.io)
* docs-infra: Pagefind search wrapper with runtime base-path + preview prefix handling (TDD)
๐ค Generated by [robots](https://vyos.io)
* docs-infra: pagefind wrapper โ asset-failure notice + UI stylesheet load
๐ค Generated by [robots](https://vyos.io)
* docs-infra: gate Pagefind searchbox to CF builds (RTD keeps stock search until cutover)
๐ค Generated by [robots](https://vyos.io)
* docs-infra: deploy sanity gates โ limits, critical pages, count-delta, canonical (TDD)
๐ค Generated by [robots](https://vyos.io)
* docs-infra: hermetic gate tests via fixture versions.json
๐ค Generated by [robots](https://vyos.io)
* docs-infra: docs-build workflow โ candidate/smoke/promote two-stage deploy + registry + rollback
Two-stage CF Workers pipeline: build in pinned container, assemble artifact,
sanity gates, deploy candidate, scoped pre-traffic smoke via canary apex,
promote (rollback-id capture, hostname purge, registry upload), post-promote
probe + auto-rollback. DOCS_CF_LIVE repo variable gates every docs.vyos.io
production interaction pre-cutover.
scripts/docs_gates/smoke.py adds one authorized check beyond the spec: the
version's index.html probe asserts the #vyos-search mount div is present in
the response body, guarding CI silently forgetting DOCS_VERSION_SLUG (which
would otherwise ship stock RTD search without the Pagefind gate noticing).
๐ค Generated by [robots](https://vyos.io)
* docs-infra: build docs image in-workflow with buildx cache (v4.1 โ digest pin dropped)
Plan v4.1 amendment: the ghcr.io digest-pinned image does not exist (workflow
would hard-fail at the first docker step on every push). Replace the BUILD_IMAGE
env placeholder with an in-workflow docker build from docker/Dockerfile via
docker/setup-buildx-action@v3 + docker/build-push-action@v6 (context: docker/,
load: true, tags: docs-build:local, GHA cache from/to). The checked-out commit
is the pin; buildx GHA cache keeps repeat builds cheap. Sphinx-build step swaps
to docs-build:local; inner script unchanged.
๐ค Generated by [robots](https://vyos.io)
* docs-infra: apex/preview deploy workflow โ canary auto, production behind environment approval
* docs-infra: apex-deploy concurrency guard (per-ref, cancel-in-progress)
๐ค Generated by [robots](https://vyos.io)
* docs-infra: fork-safe PR preview pipeline โ approval record, R2 prefixes, label consumption, cleanup
* docs-infra: nightly preview sweep โ pipefail + per-prefix failure isolation
๐ค Generated by [robots](https://vyos.io)
* docs-infra: nightly canary QA โ per-entry sweep + URL-parity corpus vs RTD
๐ค Generated by [robots](https://vyos.io)
* docs-infra: parity sweep scoped to CF-built versions; transport-error resilience
๐ค Generated by [robots](https://vyos.io)
* docs-infra: one-off bootstrap workflow (binding targets โ runs once on this push)
๐ค Generated by [robots](https://vyos.io)
* docs-infra: remove one-off bootstrap workflow (bootstrap complete)
๐ค Generated by [robots](https://vyos.io)
* docs-infra: one-off canary apex + preview deploy (route targets for Task 3.6 step 2c)
๐ค Generated by [robots](https://vyos.io)
* docs-infra: remove one-off canary deploy workflow (targets live)
๐ค Generated by [robots](https://vyos.io)
* docs-infra: address Phase-0 CodeRabbit findings (canonical gate, error caching, registry pointer, validation)
๐ค Generated by [robots](https://vyos.io)
* docs-infra: strengthen manifest tests (full dispatch iteration, mutation-free validate)
๐ค Generated by [robots](https://vyos.io)
* docs-infra: address GitHub CodeRabbit review (pointer-after-probe, fail-closed sweeps, block-precedence UA gate, preview hardening)
๐ค Generated by [robots](https://vyos.io)
* docs-infra: adversarial review fixes โ error no-store, probe retry, PR-list membership, preview dotted-segment
๐ค Generated by [robots](https://vyos.io)
* docs-infra: serve oversized legacy PDF from R2 via apex (spec ยง5 fallback)
The 1.3 PDF (29.2 MiB) exceeds the 25 MiB static-asset cap and is absent
from the legacy content Worker's build, so /_/downloads/en/1.3/pdf/ (and
the picker's PDF link) 301'd into a dead-end 404 post-cutover. Add the R2
object fallback spec ยง5 already documented but never implemented: a
DOCS_PDFS R2 bucket binding on the apex Worker, a manifest pdf_r2_key
field (1.3 only), and a router step ahead of version dispatch that streams
the object with its own cache class (canary/error still force no-store).
๐ค Generated by [robots](https://vyos.io)
* docs-infra: PDF R2 fallback honors Range + If-None-Match, preserves ETag
๐ค Generated by [robots](https://vyos.io)
Diffstat (limited to 'workers/apex/src')
| -rw-r--r-- | workers/apex/src/dispatch.ts | 15 | ||||
| -rw-r--r-- | workers/apex/src/index.ts | 159 | ||||
| -rw-r--r-- | workers/apex/src/manifest.ts | 55 | ||||
| -rw-r--r-- | workers/apex/src/redirects.ts | 38 | ||||
| -rw-r--r-- | workers/apex/src/special.ts | 55 | ||||
| -rw-r--r-- | workers/apex/src/uagate.ts | 18 |
6 files changed, 340 insertions, 0 deletions
diff --git a/workers/apex/src/dispatch.ts b/workers/apex/src/dispatch.ts new file mode 100644 index 00000000..9ad1f15d --- /dev/null +++ b/workers/apex/src/dispatch.ts @@ -0,0 +1,15 @@ +export function resolveVersion( + pathname: string, + dispatch: Map<string, string>, +): { slug: string; binding: string } | null { + const m = pathname.match(/^\/en\/([^/]+)\//); + if (!m) return null; + const binding = dispatch.get(m[1]); + return binding ? { slug: m[1], binding } : null; +} + +export function bindingGuard(env: Record<string, unknown>, binding: string): Fetcher | null { + const b = env[binding]; + if (b && typeof (b as Fetcher).fetch === "function") return b as Fetcher; + return null; +} diff --git a/workers/apex/src/index.ts b/workers/apex/src/index.ts new file mode 100644 index 00000000..278794e2 --- /dev/null +++ b/workers/apex/src/index.ts @@ -0,0 +1,159 @@ +import { loadManifest, buildDispatch } from "./manifest"; +import { resolveVersion, bindingGuard } from "./dispatch"; +import { redirectFor } from "./redirects"; +import { specialPathFor } from "./special"; +import { uaVerdict } from "./uagate"; +import policy from "../ua-policy.json"; + +export interface ApexEnv extends Record<string, unknown> { + ASSETS: Fetcher; + APEX_BUILD_SHA: string; + DOCS_ENV: "production" | "canary"; + DOCS_KB?: Fetcher; + // ยง5 apex PDF fallback โ R2 bucket holding oversized legacy PDFs excluded from the + // content Worker's own asset tree (currently just the 1.3 PDF). Optional so the + // binding-guard path (503, not a crash) exercises on envs that omit it. + DOCS_PDFS?: R2Bucket; +} + +const manifest = loadManifest(); +const dispatch = buildDispatch(manifest); + +// Security headers only โ safe on content pass-through (never touches +// Cache-Control or X-Docs-Build, which the content Worker owns). +function securityHeaders(resp: Response): Response { + const out = new Response(resp.body, resp); + out.headers.set("X-Content-Type-Options", "nosniff"); + out.headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); + out.headers.set("Content-Security-Policy-Report-Only", "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'"); + return out; +} + +const DEFAULT_CACHE_CLASS = "public, max-age=0, s-maxage=300, must-revalidate"; + +function apexHeaders(resp: Response, env: ApexEnv, cacheClass: string = DEFAULT_CACHE_CLASS): Response { + const out = securityHeaders(resp); + out.headers.set("X-Apex-Build", env.APEX_BUILD_SHA); + // ยง3.3 cache contract applies to apex-owned responses too. Error responses (4xx/5xx) + // must never carry the s-maxage cache class โ the cache key excludes User-Agent, so a + // cached UA-gate 403 or themed 404/503 would poison the edge for every visitor for the + // full s-maxage window. Mirrors the branch worker's withDocsHeaders() precedence. + // `cacheClass` lets a specific caller (e.g. the ยง5 PDF R2 fallback) apply a + // differently-classed success cache-control; canary/error still always win. + out.headers.set( + "Cache-Control", + env.DOCS_ENV === "canary" || out.status >= 400 ? "no-store" : cacheClass, + ); + return out; +} + +async function themed(env: ApexEnv, status: 404 | 503): Promise<Response> { + const page = await env.ASSETS.fetch(new Request(`https://apex.internal/${status}.html`)); + return apexHeaders(new Response(page.body, { status, headers: { "content-type": "text/html; charset=utf-8" } }), env); +} + +export default { + async fetch(request: Request, env: ApexEnv): Promise<Response> { + const url = new URL(request.url); + + // 1. UA gate โ production only (ยง3.2.1) + if (env.DOCS_ENV === "production") { + const v = uaVerdict(request.headers.get("user-agent") ?? "", policy); + if (v === "block") return apexHeaders(new Response("Forbidden", { status: 403 }), env); + if (v === "log") console.log(JSON.stringify({ event: "ua-log", ua: request.headers.get("user-agent"), path: url.pathname })); + } + + // 2. Special paths (ยง3.2.2) + const special = await specialPathFor(request, manifest, env as never); + if (special) return apexHeaders(special, env); + + // 2b. Legacy PDF R2 fallback (spec ยง5) โ the 1.3 PDF (29.2 MiB) exceeds the 25 MiB + // static-asset cap and is excluded from the content Worker's own build. MUST run + // before version dispatch (step 6): the legacy Worker's asset tree lacks this file, + // so unconditional dispatch would 404 on the exact path the PDF 301 (ยง3.2.4) targets. + const pdfVersion = manifest.versions.find((v) => v.pdf_r2_key && v.pdf === url.pathname); + if (pdfVersion) { + const bucket = env.DOCS_PDFS; + if (!bucket) { + console.log(JSON.stringify({ event: "binding-missing", binding: "DOCS_PDFS" })); + return themed(env, 503); + } + let raw: R2ObjectBody | R2Object | null; + try { + // Forward Range + conditional (If-None-Match/If-Match/If-Modified-Since) headers + // straight through to R2 so a resumed download or a client with a fresh cached + // copy doesn't have to re-pull the full 29.2 MiB object. + raw = await bucket.get(pdfVersion.pdf_r2_key!, { + range: request.headers, + onlyIf: request.headers, + }); + } catch (e) { + console.log(JSON.stringify({ event: "binding-error", binding: "DOCS_PDFS", error: String(e) })); + return themed(env, 503); + } + if (!raw) return themed(env, 404); + + // Distinct (longer) cache class from apex's default control-response class โ this + // is effectively content, just not content the legacy content Worker can serve. + // 304/206 are both <400 so apexHeaders() still applies this class, not "no-store". + const pdfCacheClass = "public, max-age=300, s-maxage=600, must-revalidate"; + const pdfHeaders: Record<string, string> = { + etag: raw.httpEtag, + "accept-ranges": "bytes", + }; + + // A satisfied onlyIf precondition (e.g. If-None-Match matched the R2 object's current + // ETag) makes R2 hand back a body-less R2Object โ just the validators, no content. + if (!("body" in raw) || !raw.body) { + return apexHeaders(new Response(null, { status: 304, headers: pdfHeaders }), env, pdfCacheClass); + } + const obj = raw as R2ObjectBody; + pdfHeaders["content-type"] = "application/pdf"; + + // A satisfied Range request โ R2 echoes the actually-served byte range on `obj.range`; + // its absence means either no Range header was sent or R2 served the full object. + const range = obj.range; + if (range && "offset" in range) { + const start = range.offset ?? 0; + const length = range.length ?? obj.size - start; + pdfHeaders["content-range"] = `bytes ${start}-${start + length - 1}/${obj.size}`; + pdfHeaders["content-length"] = String(length); + return apexHeaders(new Response(obj.body, { status: 206, headers: pdfHeaders }), env, pdfCacheClass); + } + + pdfHeaders["content-length"] = String(obj.size); + return apexHeaders(new Response(obj.body, { status: 200, headers: pdfHeaders }), env, pdfCacheClass); + } + + // 3+4. Trailing-slash + alias/codename/PDF 301s (ยง3.2.3-4) + const redir = redirectFor(url, manifest); + if (redir) return apexHeaders(redir, env); + + // 5. /kb seam (ยง3.2.5) + if (url.pathname.startsWith("/kb/") || url.pathname === "/kb") { + if (env.DOCS_KB) return securityHeaders(await env.DOCS_KB.fetch(request)); + return themed(env, 404); + } + + // 6. Version dispatch (ยง3.2.6) + const hit = resolveVersion(url.pathname, dispatch); + if (hit) { + const fetcher = bindingGuard(env, hit.binding); + if (!fetcher) { // 7. runtime binding guard (ยง3.2.7) + console.log(JSON.stringify({ event: "binding-missing", binding: hit.binding })); + return themed(env, 503); + } + try { + const resp = await fetcher.fetch(request); + if (resp.status === 404) return themed(env, 404); + return securityHeaders(resp); // ยง3.3: security headers at apex; cache + X-Docs-Build stay content-owned + } catch (e) { + console.log(JSON.stringify({ event: "binding-error", binding: hit.binding, error: String(e) })); + return themed(env, 503); + } + } + + // 7. Fallback + return themed(env, 404); + }, +} satisfies ExportedHandler<ApexEnv>; diff --git a/workers/apex/src/manifest.ts b/workers/apex/src/manifest.ts new file mode 100644 index 00000000..396f29ed --- /dev/null +++ b/workers/apex/src/manifest.ts @@ -0,0 +1,55 @@ +import raw from "../../versions.json"; + +export interface VersionEntry { + slug: string; label: string; + status: "dev" | "lts" | "eol"; + binding: string; aliases: string[]; + pdf: string | null; + // R2 object key for the apex PDF fallback (spec ยง5) โ set only on versions whose PDF + // exceeds the 25 MiB static-asset cap and is therefore absent from the content + // Worker's own asset tree (currently just 1.3). Optional; most versions omit it. + pdf_r2_key?: string; +} +export interface Manifest { + schema_version: number; + default_lang: string; default_version: string; + languages: { code: string; label: string }[]; + versions: VersionEntry[]; +} + +// Split out from loadManifest() so tests can validate synthetic manifests without +// touching the real ../../versions.json import (which loadManifest() is hardwired to). +export function validateManifest(m: Manifest): Manifest { + if (m.schema_version !== 2) throw new Error(`versions.json schema_version ${m.schema_version} != 2`); + const slugs = new Set<string>(); + for (const v of m.versions) { + if (!/^DOCS_[A-Z0-9_]+$/.test(v.binding)) throw new Error(`bad binding for ${v.slug}`); + if (!["dev", "lts", "eol"].includes(v.status)) throw new Error(`bad status for ${v.slug}`); + if (slugs.has(v.slug)) throw new Error(`duplicate slug: ${v.slug}`); + // pdf_r2_key names an R2 fallback for the exact `pdf` URL โ a null pdf has no URL + // for the fallback to ever be reached at, so the pairing is nonsensical. + if (v.pdf_r2_key && v.pdf === null) throw new Error(`pdf_r2_key set but pdf is null for ${v.slug}`); + slugs.add(v.slug); + } + // Separate pass: an alias must not collide with ANY canonical slug (not just an + // earlier one), so `slugs` must be fully populated before this check runs. + const aliases = new Set<string>(); + for (const v of m.versions) { + for (const alias of v.aliases) { + if (slugs.has(alias)) throw new Error(`alias ${alias} (on ${v.slug}) collides with a canonical slug`); + if (aliases.has(alias)) throw new Error(`duplicate alias: ${alias}`); + aliases.add(alias); + } + } + if (!m.versions.some((v) => v.slug === m.default_version)) + throw new Error(`default_version ${m.default_version} not in versions[]`); + return m; +} + +export function loadManifest(): Manifest { + return validateManifest(raw as Manifest); +} + +export function buildDispatch(m: Manifest): Map<string, string> { + return new Map(m.versions.map((v) => [v.slug, v.binding])); +} diff --git a/workers/apex/src/redirects.ts b/workers/apex/src/redirects.ts new file mode 100644 index 00000000..835b50f7 --- /dev/null +++ b/workers/apex/src/redirects.ts @@ -0,0 +1,38 @@ +import type { Manifest } from "./manifest"; + +function aliasMap(m: Manifest): Map<string, string> { + const map = new Map<string, string>(); + for (const v of m.versions) for (const a of v.aliases) map.set(a, v.slug); + return map; +} + +function r301(pathAndQuery: string): Response { + return new Response(null, { status: 301, headers: { Location: pathAndQuery } }); +} + +export function redirectFor(url: URL, m: Manifest): Response | null { + const { pathname, search } = url; // never touch url.hash โ fragments don't reach the server + + // RTD PDF URLs: /_/downloads/en/<ver>/pdf/* โ /en/<slug>/vyos-documentation.pdf + const pdf = pathname.match(/^\/_\/downloads\/en\/([^/]+)\/pdf(?:\/|$)/); + if (pdf) { + const slug = aliasMap(m).get(pdf[1]) ?? pdf[1]; + const entry = m.versions.find((v) => v.slug === slug); + // pdf: null means no PDF artifact exists for this version โ don't 301 into a dead-end 404. + // The manifest's pdf value is the source of truth (not a hardcoded filename) so a future + // R2-fallback path for 1.3 (or any other version) can move the target without a code change. + if (entry && entry.pdf !== null) return r301(`${entry.pdf}${search}`); + } + + // Alias / codename prefixes: /en/<alias>/* โ /en/<slug>/* + const seg = pathname.match(/^\/en\/([^/]+)(\/.*)?$/); + if (seg) { + const [, first, rest = ""] = seg; + const target = aliasMap(m).get(first); + if (target) return r301(`/en/${target}${rest || "/"}${search}`); + // Trailing-slash normalization on bare version roots: /en/<slug> โ /en/<slug>/ + if (rest === "" && m.versions.some((v) => v.slug === first)) + return r301(`/en/${first}/${search}`); + } + return null; +} diff --git a/workers/apex/src/special.ts b/workers/apex/src/special.ts new file mode 100644 index 00000000..f1a843f0 --- /dev/null +++ b/workers/apex/src/special.ts @@ -0,0 +1,55 @@ +import type { Manifest } from "./manifest"; +import { bindingGuard } from "./dispatch"; + +// Static assets (404.html, 503.html, robots.txt, favicons, root) live in the apex +// ASSETS binding; versions.json is served from the imported manifest so the body +// always matches the dispatch map. +export async function specialPathFor( + request: Request, + m: Manifest, + env: Record<string, unknown> & { ASSETS: Fetcher }, +): Promise<Response | null> { + const url = new URL(request.url); + const p = url.pathname; + + if (p === "/") // default-version redirect (ยง3.2.2) โ query preserved, like alias redirects + return new Response(null, { status: 301, headers: { Location: `/en/${m.default_version}/${url.search}` } }); + + if (p === "/versions.json") + return new Response(JSON.stringify(m), { + headers: { "content-type": "application/json; charset=utf-8" }, + }); + + if (p === "/healthz") + return new Response(JSON.stringify({ status: "ok", versions: m.versions.length }), { + headers: { "content-type": "application/json" }, + }); + + if (p === "/sitemap.xml") { + const entries = m.versions + .map((v) => `<sitemap><loc>https://docs.vyos.io/en/${v.slug}/sitemap.xml</loc></sitemap>`) + .join(""); + return new Response( + `<?xml version="1.0" encoding="UTF-8"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${entries}</sitemapindex>`, + { headers: { "content-type": "application/xml" } }, + ); + } + + if (p === "/llms.txt") { // direct body, not a redirect (ยง3.2.2) + const def = m.versions.find((v) => v.slug === m.default_version)!; + const b = bindingGuard(env, def.binding); + if (!b) // do NOT fall through โ /llms.txt with a missing binding is a 503, not a 404 + return new Response("service unavailable", { status: 503, headers: { "content-type": "text/plain" } }); + // Forward the ORIGINAL request (method + conditional-GET headers), just retargeted + // to the versioned path โ mirrors the robots.txt/favicon pass-through below. + const target = new URL(`/en/${def.slug}/llms.txt`, url); + return b.fetch(new Request(target, request)); + } + + if (p === "/robots.txt" || p === "/favicon.ico" || /^\/apple-touch-icon.*\.png$/.test(p)) + // Pass the ORIGINAL request through (not a re-synthesized `new Request(url)`) so + // conditional-GET headers (If-None-Match / If-Modified-Since) reach ASSETS intact. + return env.ASSETS.fetch(request); + + return null; +} diff --git a/workers/apex/src/uagate.ts b/workers/apex/src/uagate.ts new file mode 100644 index 00000000..822a7277 --- /dev/null +++ b/workers/apex/src/uagate.ts @@ -0,0 +1,18 @@ +export interface UaPolicy { + allow: string[]; + log: string[]; + block: string[]; +} + +export type UaVerdict = "allow" | "block" | "log"; + +export function uaVerdict(ua: string, policy: UaPolicy): UaVerdict { + const hit = (list: string[]) => list.some((n) => ua.toLowerCase().includes(n.toLowerCase())); + // Explicit blocks take precedence โ a request-controlled UA string that spoofs an + // allow-listed substring (e.g. "Googlebot EvilScraper") must not be able to bypass a + // block entry just by also matching the allow list. + if (hit(policy.block)) return "block"; + if (hit(policy.allow)) return "allow"; + if (hit(policy.log)) return "log"; + return "allow"; // fail-open default +} |
