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.
85 lines
2.7 KiB
TypeScript
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;
|
|
}
|