diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..3532b2a --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,73 @@ +# Deployment + +This app is built to deploy to **[Render](https://render.com)** as a Web +Service via the included `render.yaml` blueprint. + +## Pre-flight checklist + +Before deploying, confirm: + +- [ ] CiviCRM has a working API key for a service user with sufficient privileges to read/write `Contact`, `Activity`, `Relationship`, and `OptionValue`. +- [ ] The `Primary Contact` (or whatever value `FORM_CONTACT_RELATIONSHIP` resolves to in `config/form.ts`) relationship type exists, with Individual on side A and Organization on side B. +- [ ] The `Check-in (organizing)` activity type exists with the six custom field groups attached (`Check_in_data__organizing_`, `Stage_1`, `Stage_2`, `Stage_3`, `Stage_4`, `Stage_5`). +- [ ] The Framework Stage option group on the Organization contact contains all six in-scope values: `Inquiry`, `Organizing`, `Feasibility`, `Business feasibility`, `Store Implementation`, `Stabilize newly opened co-op`. + +Verify all of the above against your live CiviCRM by hitting `/api/health` (in development; production requires a token — see below). + +## First-time Render setup + +1. **Create a new Web Service** from this repo on Render. Render will detect `render.yaml` and configure the service automatically. + +2. **Set the secrets** in the Render dashboard → Environment. The `render.yaml` declares them but marks them `sync: false` so they're never echoed in the committed file: + + | Variable | Required? | Purpose | + |---|---|---| + | `CIVI_BASE_URL` | yes | e.g. `https://crm.fci.coop` | + | `CIVI_API_KEY` | yes | Per-user API key. Generate via CiviCRM Contact → API Key field. | + | `CIVI_SITE_KEY` | yes | From `civicrm.settings.php` (`CIVICRM_SITE_KEY`). | + | `CIVI_HTTP_AUTH_USER` | only if Civi sits behind webserver-level Basic Auth | HTTP Basic Auth username. | + | `CIVI_HTTP_AUTH_PASS` | only if CIVI_HTTP_AUTH_USER is set | HTTP Basic Auth password. | + | `HEALTH_TOKEN` | recommended | Long random string (e.g. `openssl rand -hex 32`). Required to access `/api/health` in production. If unset, that route returns 404. | + | `PUBLIC_ORIGIN` | optional | e.g. `https://check-in.fci.coop` — used in absolute self-links if needed later. | + +3. **Trigger the first deploy.** Render will run `npm ci && npm run build` then `npm run start`. The platform health check hits `/healthz` (lightweight, no Civi dependency). + +4. **Verify**: + - `https:///healthz` → `{"ok":true,"service":"coop-checkin"}` + - `https:///api/health?token=` → all checks should be green + - Visit `https:///?cid=&cs=` → form loads with prefill + +5. **Custom domain (optional)** — Render dashboard → Custom Domain → add `check-in.fci.coop`. Update DNS to the provided CNAME. Render auto-issues a Let's Encrypt cert. + +## Security defaults + +The app ships with these defaults; review and adjust per your threat model. + +| Concern | Default | +|---|---| +| HTTPS | Required. Render terminates TLS for you. HSTS header is set with a 2-year max-age + preload. | +| CSP | Tight: same-origin scripts/connections; Google Fonts allowed for next/font; `frame-ancestors 'none'`; no third-party scripts. | +| Frame embedding | Denied (X-Frame-Options + CSP frame-ancestors). | +| Referrer | `same-origin` so the cid+cs query string never leaks via Referer to other origins. | +| Health endpoint | Production: gated behind `HEALTH_TOKEN`. Dev: unauthenticated. | +| Submit rate limit | 10 submissions per IP per minute (in-memory; resets on restart). | +| Error messages | Production responses say "Could not save" without details; full error logged server-side with secrets redacted. | +| Stub mode | Refuses to start if `NODE_ENV=production` and any of `CIVI_BASE_URL`/`CIVI_API_KEY`/`CIVI_SITE_KEY` is missing. | + +## What's intentionally NOT done + +- **CSRF tokens.** The submit endpoint is gated by the per-contact CiviCRM checksum, which is not browser-cookie-based, so traditional CSRF doesn't apply. Add CSRF tokens if you ever introduce a session-cookie auth path. +- **Multi-instance rate limiting.** The in-memory token bucket is per-process. Acceptable for a single Render instance; switch to Redis or Render's edge rate-limit if you scale horizontally. +- **WAF.** None configured. Render's platform provides basic DDoS protection; add a WAF (Cloudflare in front of Render, or similar) if your threat model warrants it. +- **i18n.** English-only. + +## Rolling out updates + +1. Push to your default branch. +2. Render auto-builds and deploys (per `autoDeploy: true` in `render.yaml`). +3. Watch deploy logs for any health-check failures. +4. After deploy: hit `/api/health?token=…` to confirm all checks still pass against the live CRM. + +## Rollback + +Render keeps the previous deploy available. From the Render dashboard → **Deploys** → previous deploy → **Rollback to this deploy**. diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 756e22b..ae9b67a 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -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 { } export async function GET(req: Request) { + // In production: require ?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"); diff --git a/app/api/submit/route.ts b/app/api/submit/route.ts index ac760ef..0fa345e 100644 --- a/app/api/submit/route.ts +++ b/app/api/submit/route.ts @@ -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): Promise { const ok = await verifyChecksum(cid, cs); if (!ok) { return NextResponse.json({ error: "Link is invalid or has expired." }, { status: 401 }); diff --git a/app/healthz/route.ts b/app/healthz/route.ts new file mode 100644 index 0000000..b576de4 --- /dev/null +++ b/app/healthz/route.ts @@ -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 }); +} diff --git a/lib/env.ts b/lib/env.ts new file mode 100644 index 0000000..0e9e27d --- /dev/null +++ b/lib/env.ts @@ -0,0 +1,71 @@ +/** + * 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; + /** + * 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, + 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]) { + if (v && v.length >= 6) out = out.split(v).join("«redacted»"); + } + return out; +} diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts new file mode 100644 index 0000000..cf5edd1 --- /dev/null +++ b/lib/rate-limit.ts @@ -0,0 +1,55 @@ +/** + * Tiny in-memory rate limiter. Token-bucket per key (typically client IP). + * + * Suitable for a single-instance deployment behind Render's load balancer. + * State resets on process restart, which is fine for this use case (the + * worst that happens is a malicious client gets to retry sooner). + * + * For multi-instance deployments use Redis or Render's edge rate-limit. + */ + +interface Bucket { + tokens: number; + refilledAt: number; +} + +const BUCKETS = new Map(); + +export interface RateLimitOptions { + /** Max requests per window. */ + capacity: number; + /** Window length in milliseconds (tokens fully refill each window). */ + windowMs: number; +} + +export function rateLimit(key: string, opts: RateLimitOptions): { allowed: boolean; remaining: number; resetMs: number } { + const now = Date.now(); + const bucket = BUCKETS.get(key); + if (!bucket || now - bucket.refilledAt >= opts.windowMs) { + BUCKETS.set(key, { tokens: opts.capacity - 1, refilledAt: now }); + return { allowed: true, remaining: opts.capacity - 1, resetMs: opts.windowMs }; + } + if (bucket.tokens > 0) { + bucket.tokens--; + return { + allowed: true, + remaining: bucket.tokens, + resetMs: opts.windowMs - (now - bucket.refilledAt), + }; + } + return { + allowed: false, + remaining: 0, + resetMs: opts.windowMs - (now - bucket.refilledAt), + }; +} + +/** + * Best-effort client-IP extraction from a Next.js Request. Trusts the + * left-most x-forwarded-for entry (set by Render's load balancer). + */ +export function clientIp(req: Request): string { + const xff = req.headers.get("x-forwarded-for"); + if (xff) return xff.split(",")[0].trim(); + return req.headers.get("x-real-ip") ?? "unknown"; +} diff --git a/next.config.ts b/next.config.ts index e9ffa30..a22d142 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,51 @@ import type { NextConfig } from "next"; +/** + * Security headers applied to every response. + * + * Notes on each: + * - CSP: tight default; allows Google Fonts (next/font) and the same-origin + * /api routes. No third-party scripts. `frame-ancestors 'none'` prevents + * this app being embedded in another site's iframe. + * - HSTS: only meaningful behind HTTPS (Render terminates TLS, so this is + * correct in production). + * - Permissions-Policy: drop everything we don't use. + * - Referrer-Policy: same-origin — never leak the cid+cs query string to + * other origins via the Referer header. + * - X-Content-Type-Options: prevents MIME sniffing. + */ +const securityHeaders = [ + { + key: "Content-Security-Policy", + value: [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline'", + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", + "font-src 'self' https://fonts.gstatic.com data:", + "img-src 'self' data:", + "connect-src 'self'", + "frame-ancestors 'none'", + "form-action 'self'", + "base-uri 'self'", + "object-src 'none'", + ].join("; "), + }, + { key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "Referrer-Policy", value: "same-origin" }, + { + key: "Permissions-Policy", + value: "camera=(), microphone=(), geolocation=(), interest-cohort=()", + }, + { key: "X-Frame-Options", value: "DENY" }, +]; + const nextConfig: NextConfig = { - /* config options here */ + poweredByHeader: false, + reactStrictMode: true, + async headers() { + return [{ source: "/:path*", headers: securityHeaders }]; + }, }; export default nextConfig; diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..57aeb65 --- /dev/null +++ b/render.yaml @@ -0,0 +1,37 @@ +# Render Blueprint — https://render.com/docs/blueprint-spec +# +# Deploy this app as a Web Service on Render. Push to your default branch +# triggers a deploy. Set the four CIVI_* env vars in the Render dashboard +# (mark them as "Sync: false" so they're not overwritten by this file). + +services: + - type: web + name: coop-checkin + runtime: node + plan: starter + region: oregon + buildCommand: npm ci && npm run build + startCommand: npm run start + healthCheckPath: /healthz + envVars: + - key: NODE_ENV + value: production + - key: NODE_VERSION + value: "20" + # Set these manually in the Render dashboard (UI → Environment). + # Marked sync:false so they're not echoed in this committed file. + - key: CIVI_BASE_URL + sync: false + - key: CIVI_API_KEY + sync: false + - key: CIVI_SITE_KEY + sync: false + - key: CIVI_HTTP_AUTH_USER + sync: false + - key: CIVI_HTTP_AUTH_PASS + sync: false + - key: HEALTH_TOKEN + sync: false + - key: PUBLIC_ORIGIN + sync: false + autoDeploy: true