Files
ThreadCount 0910bc32c1 ThreadCount Community edition
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 38e16eb on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-16 07:57:54 +10:00

59 lines
3.3 KiB
TypeScript

// Cloudflare Turnstile server-side verification for sign-in / sign-up.
//
// Configured by two variables that have to agree: TURNSTILE_SECRET here, and
// NEXT_PUBLIC_TURNSTILE_SITEKEY in the browser, which is baked in at build time. Local dev and the
// e2e suites run with neither, and every check is skipped.
//
// In production it fails *closed*. A rebuilt secrets file that drops TURNSTILE_SECRET used to take
// bot protection off sign-in, sign-up, password reset, the contact form and the newsletter with no
// error, no log line and nothing visible — which is the worst shape a security control can fail in.
// Now the checks refuse instead, and instrumentation.ts stops the server from starting at all, so
// the missing variable is found on deploy rather than after it has been exploited.
export const turnstileEnabled = () => !!process.env.TURNSTILE_SECRET;
/* TURNSTILE_OPTIONAL=1 is the one way out, and it exists for a real case: a local `next start`
* smoke test runs with NODE_ENV=production against a machine that has no Cloudflare keys and no
* business having them. It is never set in a production secrets file, so it cannot quietly
* disarm the live site the way a *missing* variable used to. */
export const turnstileRequired = () =>
process.env.NODE_ENV === "production" && process.env.TURNSTILE_OPTIONAL !== "1"
// A Community instance has no Cloudflare account to lean on. Turnstile stays available to it —
// set both keys and it is enforced — but its absence is not a misconfiguration there; the
// per-address rate limits on every auth route are what stands in its place.
&& process.env.EDITION !== "community";
export async function verifyTurnstile(token: unknown, ip: string): Promise<string | null> {
const secret = process.env.TURNSTILE_SECRET;
if (!secret) {
if (!turnstileRequired()) return null;
console.error("[turnstile] TURNSTILE_SECRET is not set in production — refusing the request");
return "Security check unavailable — please try again in a moment.";
}
const t = typeof token === "string" ? token.slice(0, 2048) : "";
if (!t) return "Please complete the security check.";
try {
const r = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ secret, response: t, remoteip: ip }),
signal: AbortSignal.timeout(6000),
});
const j = (await r.json()) as { success?: boolean; "error-codes"?: string[] };
if (!j.success) return "Security check failed — please try again.";
return null;
} catch {
return "Security check unavailable — please try again in a moment.";
}
}
/**
* The pair of variables the checks need, as they are at boot.
*
* The site key is read as a plain static reference on purpose: NEXT_PUBLIC_ values are substituted
* at build time, so this reports what the browser bundle actually got. That catches the mirror
* failure — a secret present but no site key compiled in, where no widget renders, no token is
* posted, and every sign-in answers "Please complete the security check."
*/
export function turnstileConfig(): { secret: boolean; sitekey: boolean } {
return { secret: !!process.env.TURNSTILE_SECRET, sitekey: !!process.env.NEXT_PUBLIC_TURNSTILE_SITEKEY };
}