Files
WebForm-mw/lib/env.ts
T
Joel Brock 526422abed Give /api/preview-link its own Bearer secret; constant-time token checks
preview-link mints working form links for any contact, but was gated by
HEALTH_TOKEN (the diagnostics secret) passed in the query string, where
it lands in access logs. README already documented PREVIEW_ADMIN_TOKEN
as a Bearer header; make the code match.

- PREVIEW_ADMIN_TOKEN, when set, is required as Authorization: Bearer
  and is never accepted from the URL.
- Until it is set, HEALTH_TOKEN still works (Bearer or legacy ?token=)
  so the support runbook in EMAIL_DELIVERY.md keeps working.
- preview-link and /api/health compare tokens in constant time.
- redact() also scrubs PREVIEW_ADMIN_TOKEN and STAFF_REPORT_KEY.
2026-10-30 11:20:30 -07:00

85 lines
2.7 KiB
TypeScript

/**
* Environment-variable validation. Called at module load time by routes
* that need real Civi credentials. In stub mode, no env is required.
*
* Centralizing here means we can:
* - Fail fast at boot in production if a required var is missing
* - Avoid leaking secrets to error responses (we redact in error paths)
* - Reuse the "is stub mode?" check in one place
*/
export interface AppEnv {
isStub: boolean;
isProduction: boolean;
civiBaseUrl?: string;
civiApiKey?: string;
civiSiteKey?: string;
basicAuthUser?: string;
basicAuthPass?: string;
/**
* Optional admin token to gate /api/health in production. If unset in prod,
* /api/health is disabled entirely (returns 404). Set this to a long random
* string and pass via `?token=<>` to the health endpoint to read diagnostics.
*/
healthToken?: string;
/**
* Dedicated secret for /api/preview-link, sent as `Authorization: Bearer`.
* When unset, that endpoint falls back to HEALTH_TOKEN.
*/
previewAdminToken?: string;
/**
* Same-origin host this app runs at. Used to build `frame-ancestors 'self'`
* and any absolute self-links. Defaults to inferring from the request.
*/
publicOrigin?: string;
}
let cached: AppEnv | undefined;
export function appEnv(): AppEnv {
if (cached) return cached;
const e: AppEnv = {
isStub: !(
process.env.CIVI_BASE_URL &&
process.env.CIVI_API_KEY &&
process.env.CIVI_SITE_KEY
),
isProduction: process.env.NODE_ENV === "production",
civiBaseUrl: process.env.CIVI_BASE_URL,
civiApiKey: process.env.CIVI_API_KEY,
civiSiteKey: process.env.CIVI_SITE_KEY,
basicAuthUser: process.env.CIVI_HTTP_AUTH_USER,
basicAuthPass: process.env.CIVI_HTTP_AUTH_PASS,
healthToken: process.env.HEALTH_TOKEN,
previewAdminToken: process.env.PREVIEW_ADMIN_TOKEN,
publicOrigin: process.env.PUBLIC_ORIGIN,
};
// In production, refuse to start in stub mode — that's almost certainly a
// misconfiguration. Better to crash loudly than silently serve stub data.
if (e.isProduction && e.isStub) {
throw new Error(
"Refusing to run in production without CIVI_BASE_URL, CIVI_API_KEY, and CIVI_SITE_KEY set. " +
"Either provide all three, or set NODE_ENV != 'production'.",
);
}
cached = e;
return e;
}
/** Strip secrets from any string before logging or echoing in responses. */
export function redact(s: string): string {
let out = s;
const e = appEnv();
for (const v of [
e.civiApiKey,
e.civiSiteKey,
e.basicAuthPass,
e.healthToken,
e.previewAdminToken,
process.env.STAFF_REPORT_KEY,
]) {
if (v && v.length >= 6) out = out.split(v).join("«redacted»");
}
return out;
}