/* RFC-0899: Runtime access protection middleware for dev/alt subdomains. Checks Host header and requires Basic Auth with a 4-digit PIN for dev.* and alt.* hosts. Sets X-Robots-Tag headers to prevent indexing. Do not modify the HTML response body — only headers and access gating. Do not activate for the main/production domain, even if the PIN secret is set on the main Worker. Do not use Node.js-specific APIs (Buffer, crypto.timingSafeEqual) — runs in Cloudflare Workers runtime. PIN check runs in the Workers runtime — Web Crypto only, no Node APIs. RFC-1097: step 6 — compass.migrate codemod run Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order. warpgogol-m000193: prerendered-context guard — skip Host/Authorization reads when context.isPrerendered (static render carries a stub request whose headers getter logs a per-route Astro warning; real static-asset protection lives in worker.ts). RFC-1097: sweep — werkstatt-engine clean Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics. RFC-0899: protect workers.dev dev and alt channels with the PIN and noindex RFC-1325: provisional workers.dev as first-class site host Single host predicate module in werkstatt-shared; provisional registration derives dev/alt/main workers.dev channels from the account subdomain; first dev deploy auto-protects provisional hosts; provisional main responses carry noindex; dns-aid skips provisional sites; site.domain.adopt cuts over to a custom domain. RFC-0899 */ import { defineMiddleware } from "astro:middleware"; import { isDevOrAltHost, isProvisionalHost } from "./site-host.ts"; // RFC-1325: compat re-export — consumers keep importing isDevOrAltHost from // this module while the predicate's canonical home is site-host.ts. export { isDevOrAltHost }; const NOINDEX_HEADER = "noindex, nofollow, noai, noimageai"; /** * Constant-time string comparison to prevent timing attacks. * Returns true if both strings are equal, false otherwise. * Always processes the full length of both strings regardless of match status. */ function constantTimeEqual(a: string, b: string): boolean { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) { diff |= a.charCodeAt(i) ^ b.charCodeAt(i); } return diff === 0; } /** * Paths exempt from access protection on dev/alt subdomains. * Browsers fetch these without sending Basic Auth credentials, causing 401 console errors. */ const PUBLIC_STATIC_EXEMPTIONS = ["/manifest.webmanifest"]; function isExemptPath(pathname: string): boolean { return PUBLIC_STATIC_EXEMPTIONS.includes(pathname); } /** * RFC-0899: Check access protection for a request. Called from the Worker entry point * (worker.ts) before passing to the Astro handler. This is necessary because Astro * middleware does not run for prerendered static pages (output: "static") — the * Cloudflare adapter serves them from the ASSETS binding directly. * * Returns a 401 Response if access is denied, or null if the request should pass through. * For dev/alt hosts with no PIN set, returns null (pass through) — caller should add * X-Robots-Tag header to the response. * * @param request - The incoming Request * @param env - The Worker env object (contains ACCESS_PIN) * @returns 401 Response if denied, null if pass through */ export function checkAccessProtection( request: Request, env: Record, ): Response | null { const host = request.headers.get("host") ?? ""; if (!isDevOrAltHost(host)) return null; const url = new URL(request.url); if (isExemptPath(url.pathname)) return null; const pin = (env.ACCESS_PIN as string | undefined) ?? undefined; // No PIN set — allow access (caller should add X-Robots-Tag) if (!pin) return null; // Check Basic Auth const auth = request.headers.get("authorization") ?? ""; const expected = `Basic ${btoa(`warp:${pin}`)}`; if (auth && constantTimeEqual(auth, expected)) { return null; // Authenticated — pass through } // Not authenticated — challenge return new Response("Authentication required", { status: 401, headers: { "WWW-Authenticate": 'Basic realm="Staging Access"', "X-Robots-Tag": NOINDEX_HEADER, }, }); } /** * RFC-0899: Add X-Robots-Tag header to a response for dev/alt subdomains. * Called after the Astro handler returns a response, if the host is dev/alt. * * @param response - The response to modify * @returns A new response with X-Robots-Tag added (if dev/alt host) */ export function addNoIndexHeaderIfNeeded(request: Request, response: Response): Response { const host = request.headers.get("host") ?? ""; // RFC-1325: noindex on every provisional-host response — the unprotected // provisional main channel is the public preview but must not index. if (!isDevOrAltHost(host) && !isProvisionalHost(host)) return response; const headers = new Headers(response.headers); headers.set("X-Robots-Tag", NOINDEX_HEADER); return new Response(response.body, { status: response.status, statusText: response.statusText, headers, }); } // cloudflare:workers is only available in the Cloudflare Workers runtime. // In Astro build (Node.js) the static import fails — resolve lazily so the // middleware still loads; ACCESS_PIN is undefined in build (no runtime binding). async function resolveAccessPin(): Promise { try { const { env } = await import("cloudflare:workers"); return (env.ACCESS_PIN as string | undefined) ?? undefined; } catch { return undefined; } } /** * RFC-0899: Access protection middleware for dev/alt subdomains. * * At runtime, checks the Host header: * - dev.* or alt.* → require Basic Auth with ACCESS_PIN env var * - main.* or production domain → no protection, pass through * - No PIN configured → pass through but still set X-Robots-Tag * * The auth check happens BEFORE next() to short-circuit unauthorized access. * X-Robots-Tag is set on ALL dev/alt responses including 401 challenges. * * Uses `cloudflare:workers` env import (Astro v6 removed `context.locals.runtime.env`). * Uses `btoa()` (Workers runtime), not Node.js Buffer. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export const accessProtectionMiddleware = defineMiddleware(async (context: any, next: any) => { // Prerendered routes carry a stub request — no real Host header exists, and // reading `request.headers` there makes Astro log a per-route warning. Real // protection for static assets runs in worker.ts (checkAccessProtection) // before Astro, so skipping here changes nothing at runtime. if (context.isPrerendered) { return next(); } const host = context.request.headers.get("host") ?? ""; const isDevOrAlt = isDevOrAltHost(host); if (!isDevOrAlt && !isProvisionalHost(host)) { return next(); } // RFC-1325: the provisional main host (.workers.dev) carries no PIN gate — // it is the public preview — but every provisional response is noindexed. if (!isDevOrAlt) { const response = await next(); response.headers.set("X-Robots-Tag", NOINDEX_HEADER); return response; } const url = new URL(context.request.url); if (isExemptPath(url.pathname)) { const response = await next(); response.headers.set("X-Robots-Tag", NOINDEX_HEADER); return response; } const pin = await resolveAccessPin(); // No PIN set — allow access but still set noindex headers if (!pin) { const response = await next(); response.headers.set("X-Robots-Tag", NOINDEX_HEADER); return response; } // Check Basic Auth BEFORE calling next() const auth = context.request.headers.get("authorization") ?? ""; const expected = `Basic ${btoa(`warp:${pin}`)}`; if (auth && constantTimeEqual(auth, expected)) { const response = await next(); response.headers.set("X-Robots-Tag", NOINDEX_HEADER); return response; } // Not authenticated — challenge with noindex headers return new Response("Authentication required", { status: 401, headers: { "WWW-Authenticate": 'Basic realm="Staging Access"', "X-Robots-Tag": NOINDEX_HEADER, }, }); });