summaryrefslogtreecommitdiff
path: root/workers/apex/src
diff options
context:
space:
mode:
authorYuriy Andamasov <yuriy@vyos.io>2026-07-10 17:14:13 +0300
committerGitHub <noreply@github.com>2026-07-10 16:14:13 +0200
commit21689ef59b0eb34b1a29eda739dc10d33d25b44a (patch)
tree95883c2ee70d4c09562ae3e0289e18f34999240c /workers/apex/src
parent6d2d28d4411ff52ad0f9314cf3696336748ab89d (diff)
downloadvyos-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.ts15
-rw-r--r--workers/apex/src/index.ts159
-rw-r--r--workers/apex/src/manifest.ts55
-rw-r--r--workers/apex/src/redirects.ts38
-rw-r--r--workers/apex/src/special.ts55
-rw-r--r--workers/apex/src/uagate.ts18
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
+}