Production hardening: CSP, rate limit, env validation, health gating, Render blueprint, DEPLOYMENT.md

This commit is contained in:
Joel Brock
2026-05-09 21:43:43 -07:00
parent da3e48a874
commit dcdf315244
8 changed files with 343 additions and 2 deletions

View File

@@ -21,6 +21,7 @@
import { NextResponse } from "next/server";
import { civi, verifyChecksum } from "@/lib/civicrm";
import { ACTIVITY_TYPE_NAME, FORM_CONTACT_RELATIONSHIP } from "@/config/form";
import { appEnv } from "@/lib/env";
interface CheckResult {
check: string;
@@ -353,6 +354,20 @@ async function probeContactRelationships(cid: string): Promise<CheckResult> {
}
export async function GET(req: Request) {
// In production: require ?token=<HEALTH_TOKEN>. If HEALTH_TOKEN is unset
// in production, the endpoint is disabled entirely (404). In development
// / non-prod, anyone can read it.
const e = appEnv();
const url = new URL(req.url);
if (e.isProduction) {
if (!e.healthToken) {
return new NextResponse("Not Found", { status: 404 });
}
if (url.searchParams.get("token") !== e.healthToken) {
return new NextResponse("Not Found", { status: 404 });
}
}
const env = envSummary();
const summary = { mode: env.ok ? "live" : "stub" };
@@ -361,7 +376,6 @@ export async function GET(req: Request) {
return NextResponse.json({ ...summary, checks: [env] });
}
const url = new URL(req.url);
const cid = url.searchParams.get("cid");
const cs = url.searchParams.get("cs");

View File

@@ -16,6 +16,8 @@ import { NextResponse } from "next/server";
import { civi, verifyChecksum } from "@/lib/civicrm";
import { allFields, ACTIVITY_TYPE_NAME, ACTIVITY_STAGE_FIELD, FORM_CONTACT_RELATIONSHIP } from "@/config/form";
import type { SubmitPayload } from "@/types/form";
import { appEnv, redact } from "@/lib/env";
import { rateLimit, clientIp } from "@/lib/rate-limit";
function isStubMode(): boolean {
return !(
@@ -28,6 +30,21 @@ function isStubMode(): boolean {
const FIELD_BY_NAME = new Map(allFields.map((f) => [f.name, f]));
export async function POST(req: Request) {
// Rate limit per client IP. Generous default — 10 submissions per minute.
const ip = clientIp(req);
const rl = rateLimit(`submit:${ip}`, { capacity: 10, windowMs: 60_000 });
if (!rl.allowed) {
return NextResponse.json(
{ error: "Too many submissions. Please wait a moment and try again." },
{
status: 429,
headers: {
"Retry-After": String(Math.ceil(rl.resetMs / 1000)),
},
},
);
}
let body: SubmitPayload;
try {
body = (await req.json()) as SubmitPayload;
@@ -44,6 +61,23 @@ export async function POST(req: Request) {
return NextResponse.json({ ok: true, stub: true });
}
try {
return await runSubmit(cid, cs, values);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error("[submit] failure:", redact(msg));
return NextResponse.json(
{
error: appEnv().isProduction
? "Could not save your check-in. Please try again, or contact your engagement coordinator."
: `Save failed: ${redact(msg)}`,
},
{ status: 500 },
);
}
}
async function runSubmit(cid: string, cs: string, values: Record<string, unknown>): Promise<NextResponse> {
const ok = await verifyChecksum(cid, cs);
if (!ok) {
return NextResponse.json({ error: "Link is invalid or has expired." }, { status: 401 });

13
app/healthz/route.ts Normal file
View File

@@ -0,0 +1,13 @@
/**
* GET /healthz
*
* Lightweight health check for Render's load-balancer. Returns 200 OK
* without making any external calls. Use /api/health for the deeper
* Civi-side diagnostic.
*/
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({ ok: true, service: "coop-checkin" }, { status: 200 });
}