diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 6192e04..6f91b42 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -36,12 +36,61 @@ function envSummary(): CheckResult { }; const ok = have.CIVI_BASE_URL && have.CIVI_API_KEY && have.CIVI_SITE_KEY; const baseUrl = process.env.CIVI_BASE_URL ?? "(unset)"; + const basicAuth = + process.env.CIVI_HTTP_AUTH_USER && process.env.CIVI_HTTP_AUTH_PASS + ? `set (${process.env.CIVI_HTTP_AUTH_USER})` + : "not set"; const detail = ok - ? `All three env vars present. CIVI_BASE_URL=${baseUrl}` + ? `Civi env vars present. CIVI_BASE_URL=${baseUrl}. HTTP Basic Auth: ${basicAuth}.` : `Missing: ${Object.entries(have).filter(([, v]) => !v).map(([k]) => k).join(", ") || "(none)"}. STUB MODE active.`; return { check: "env_vars", ok, detail }; } +async function probeWebserverReachable(): Promise { + // Detects the "Apache 401 in front of Civi" pattern specifically. We GET + // the base URL with the same auth headers we'd send to APIv4. If the + // webserver itself rejects (401 + HTML body), it's an htaccess basic-auth + // wall and we need CIVI_HTTP_AUTH_USER / CIVI_HTTP_AUTH_PASS. + try { + const headers: Record = {}; + if (process.env.CIVI_HTTP_AUTH_USER && process.env.CIVI_HTTP_AUTH_PASS) { + const creds = Buffer.from( + `${process.env.CIVI_HTTP_AUTH_USER}:${process.env.CIVI_HTTP_AUTH_PASS}`, + ).toString("base64"); + headers["Authorization"] = `Basic ${creds}`; + } + const res = await fetch(`${process.env.CIVI_BASE_URL}/civicrm`, { + method: "GET", + headers, + cache: "no-store", + redirect: "manual", + }); + if (res.status === 401) { + const auth = res.headers.get("www-authenticate") ?? ""; + return { + check: "webserver_reachable", + ok: false, + detail: + `Webserver returned 401 from ${process.env.CIVI_BASE_URL}/civicrm. ` + + `WWW-Authenticate: "${auth}". ` + + `This is an HTTP Basic Auth wall in front of CiviCRM (Apache/htaccess). ` + + `Set CIVI_HTTP_AUTH_USER and CIVI_HTTP_AUTH_PASS in .env.local.`, + }; + } + return { + check: "webserver_reachable", + ok: res.status < 500, + detail: `GET /civicrm → HTTP ${res.status}.`, + }; + } catch (e) { + return { + check: "webserver_reachable", + ok: false, + detail: e instanceof Error ? e.message : String(e), + }; + } +} + async function probeContactGet(): Promise { try { const res = await civi("Contact", "get", { @@ -223,15 +272,27 @@ export async function GET(req: Request) { const checks: CheckResult[] = [env]; - // Run independent probes in parallel. - const probes = await Promise.all([ - probeContactGet(), - probeRelationshipType(), - probeActivityType(), - probeStageOptionGroup(), - probeChecksumApi(), - ]); - checks.push(...probes); + // Webserver probe first — if this fails, the others will too. We run it + // serially so we get a clean "fix this first" signal at the top. + const webserver = await probeWebserverReachable(); + checks.push(webserver); + + if (webserver.ok) { + const probes = await Promise.all([ + probeContactGet(), + probeRelationshipType(), + probeActivityType(), + probeStageOptionGroup(), + probeChecksumApi(), + ]); + checks.push(...probes); + } else { + checks.push({ + check: "civi_probes_skipped", + ok: false, + detail: "Skipped Civi-level probes because the webserver itself rejected the request. Fix webserver_reachable above first.", + }); + } // If a sample cid+cs was provided, verify that specific checksum. if (cid && cs) { diff --git a/lib/civicrm.ts b/lib/civicrm.ts index 663a89d..b4eced0 100644 --- a/lib/civicrm.ts +++ b/lib/civicrm.ts @@ -6,14 +6,19 @@ * can be developed without a live CiviCRM instance. * * Env vars: - * CIVI_BASE_URL e.g. https://crm.fci.coop - * CIVI_API_KEY per-user API key (Civi user "API Key" property) - * CIVI_SITE_KEY site-wide key (from civicrm.settings.php) + * CIVI_BASE_URL e.g. https://crm.fci.coop + * CIVI_API_KEY per-user API key (Civi user "API Key" property) + * CIVI_SITE_KEY site-wide key (from civicrm.settings.php) + * CIVI_HTTP_AUTH_USER (optional) HTTP Basic Auth username, if the site + * itself sits behind webserver-level basic auth + * (common on staging/dev). When set together with + * CIVI_HTTP_AUTH_PASS, every request adds an + * `Authorization: Basic ` header. + * CIVI_HTTP_AUTH_PASS (optional) HTTP Basic Auth password. * - * If you are seeing STUB MODE warnings, set those three. Auth strategy may - * also need adjustment depending on your CiviCRM auth extension (AuthX vs - * stock APIv3-style site_key/api_key). The header style here matches the - * stock CiviCRM 5+ pattern; AuthX users may need Bearer tokens instead. + * Auth strategy may need adjustment depending on your CiviCRM auth extension + * (AuthX vs stock APIv3-style site_key/api_key). The header style here + * matches the AuthX pattern; classic API3 users may need different headers. */ export interface CiviApiOptions { @@ -56,13 +61,21 @@ export async function civi( const body = new URLSearchParams({ params: JSON.stringify(params), }); + const headers: Record = { + "Content-Type": "application/x-www-form-urlencoded", + "X-Civi-Auth": `Bearer ${process.env.CIVI_API_KEY}`, + "X-Civi-Key": process.env.CIVI_SITE_KEY!, + }; + // Webserver-level HTTP Basic Auth (e.g. site is gated by .htaccess on staging). + if (process.env.CIVI_HTTP_AUTH_USER && process.env.CIVI_HTTP_AUTH_PASS) { + const creds = Buffer.from( + `${process.env.CIVI_HTTP_AUTH_USER}:${process.env.CIVI_HTTP_AUTH_PASS}`, + ).toString("base64"); + headers["Authorization"] = `Basic ${creds}`; + } const res = await fetch(url, { method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - "X-Civi-Auth": `Bearer ${process.env.CIVI_API_KEY}`, - "X-Civi-Key": process.env.CIVI_SITE_KEY!, - }, + headers, body, cache: "no-store", });