/*
RFC-1091: shared health-check helpers extracted from cloudflare-workers adapter for reuse across deployment adapters (cloudflare-workers, github-pages). Provides behavior snapshot reading, probe route selection, HTTP fetch with retry, redirect verification, env filtering, and dotenv sourcing.
- Do not implement adapter-specific logic — each adapter lives in its own file.
- Do not log, echo, or serialize secret values or resolved secrets-file contents.
- RFC-1091: extract readBehaviorSnapshot, verifyRedirectRoute, selectProbeRoutes, fetchWithRetry, filterEnv, sourceDotenv from cloudflare-workers.ts into shared module.
- RFC-1188: Web Bot Auth outbound identity: wba-sign primitives, directory emit+sweep, AGS-WBA-01, leitstand signed fetches
*/
import path from "node:path";
import type { RouteFact } from "@warpgogol/werkstatt-engine/schemas";
import type { CommandRunner } from "../adapter.ts";
import { createDefaultIO, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io";
export function filterEnv(env: Record): Record {
const result: Record = {};
for (const [key, value] of Object.entries(env)) {
if (value !== undefined) {
result[key] = value;
}
}
return result;
}
export function createDefaultCommandRunner(io: WorkspaceIO = createDefaultIO().io): CommandRunner {
return async (cmd, args, opts) => {
const result = await io.exec(cmd, args, { cwd: opts?.cwd, env: opts?.env });
return { exitCode: result.exitCode ?? 1, stdout: result.stdout, stderr: result.stderr };
};
}
export async function sourceDotenv(
filePath: string,
io: WorkspaceIO = createDefaultIO().io,
): Promise> {
const env: Record = {};
if (!(await io.exists(filePath))) return env;
const content = await io.readFile(filePath);
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const eqIdx = trimmed.indexOf("=");
if (eqIdx === -1) continue;
const key = trimmed.slice(0, eqIdx).trim();
const value = trimmed
.slice(eqIdx + 1)
.trim()
.replace(/^["']|["']$/g, "");
if (value === "") continue;
env[key] = value;
}
return env;
}
export interface BehaviorSnapshot {
routes: RouteFact[];
}
export async function readBehaviorSnapshot(
workspaceRoot: string,
releaseId: string,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
const snapshotPath = path.join(workspaceRoot, "releases", releaseId, "behavior-snapshot.json");
if (!(await io.exists(snapshotPath))) return null;
const content = await io.readFile(snapshotPath);
const parsed = JSON.parse(content) as { behaviorSnapshot?: BehaviorSnapshot } & BehaviorSnapshot;
return parsed.behaviorSnapshot ?? parsed;
}
/**
* Verify a redirect route response: HTTP status must be 307 or 308,
* and if redirectTarget is known (not "unknown"), the Location header
* must match it exactly.
*
* Only HTTP-level redirects are supported. Static hosting that serves
* meta-refresh HTML pages with HTTP 200 will fail this check — the
* cloudflare-workers adapter always issues HTTP-level redirects.
*/
export function verifyRedirectRoute(
status: number,
location: string,
route: RouteFact,
): { passed: boolean; detail: string } {
const isRedirectStatus = status === 307 || status === 308;
const targetKnown = route.redirectTarget && route.redirectTarget !== "unknown";
const locationMatches = targetKnown ? location === route.redirectTarget : true;
const passed = isRedirectStatus && locationMatches;
return {
passed,
detail: isRedirectStatus
? locationMatches
? `Redirect ${status} → ${location}`
: `Redirect ${status} but Location mismatch: got ${location}, expected ${route.redirectTarget}`
: `Expected redirect status (307/308), got ${status}`,
};
}
export function selectProbeRoutes(routes: RouteFact[], maxProbes: number): RouteFact[] {
const homeRoutes = routes.filter((r) => r.path === "/" || /^\/[a-z]{2}\/?$/.test(r.path));
const legalRoutes = routes.filter((r) =>
/legal|imprint|privacy|datenschutz|impressum|agb|terms/i.test(r.path),
);
const sitemapRoutes = routes.filter((r) => /sitemap|llms|robots/i.test(r.path));
const remaining = routes.filter(
(r) => !homeRoutes.includes(r) && !legalRoutes.includes(r) && !sitemapRoutes.includes(r),
);
remaining.sort((a, b) => a.path.localeCompare(b.path));
const prioritized = [...homeRoutes, ...legalRoutes, ...sitemapRoutes, ...remaining];
return prioritized.slice(0, maxProbes);
}
export async function fetchWithRetry(
url: string,
maxAttempts: number,
redirect: "follow" | "manual" = "follow",
authHeaders: Record = {},
fetchImpl: typeof fetch = fetch,
): Promise<{ ok: boolean; status: number; body: string; headers: Headers } | null> {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
const response = await fetchImpl(url, { redirect, headers: authHeaders });
const body = await response.text();
return { ok: response.ok, status: response.status, body, headers: response.headers };
} catch {
if (attempt < maxAttempts - 1) {
const delayMs = Math.min(1000 * 2 ** attempt, 30000);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
}
return null;
}