/*
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,
},
});
});