Production hardening: CSP, rate limit, env validation, health gating, Render blueprint, DEPLOYMENT.md
This commit is contained in:
@@ -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");
|
||||
|
||||
|
||||
@@ -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
13
app/healthz/route.ts
Normal 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 });
|
||||
}
|
||||
Reference in New Issue
Block a user