From ddfb96c88e34a91ccaa67b6e984e55f3f3e9cd81 Mon Sep 17 00:00:00 2001 From: Spbd1 <148923621+Spbd1@users.noreply.github.com> Date: Mon, 11 May 2026 16:35:20 +0000 Subject: [PATCH] Harden production launch checks --- .env.example | 1 + README.md | 6 +++- app/api/health/route.ts | 3 -- docs/DEPLOYMENT.md | 2 ++ docs/VPS_DEPLOYMENT.md | 7 ++-- package.json | 2 +- scripts/validate-production-env.mjs | 51 +++++++++++++++++++++++++++++ 7 files changed, 64 insertions(+), 8 deletions(-) create mode 100644 scripts/validate-production-env.mjs diff --git a/.env.example b/.env.example index 694568f..0d5b44c 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,7 @@ ADMIN_EXPORT_TOKEN="change-me-before-production" SUBMISSION_RATE_LIMIT_WINDOW_MS="60000" SUBMISSION_RATE_LIMIT_MAX="20" MAX_SUBMISSION_BODY_BYTES="250000" +EXPORT_SUBMISSIONS_LIMIT="" GOOGLE_SHEETS_WEBHOOK_URL="" GOOGLE_SHEETS_WEBHOOK_SECRET="" diff --git a/README.md b/README.md index 927cee1..4557bff 100644 --- a/README.md +++ b/README.md @@ -162,12 +162,13 @@ Start from `.env.example` for local or server configuration. | `SUBMISSION_RATE_LIMIT_WINDOW_MS` | Optional | `60000` | Submission API | In-memory rate-limit window for submission attempts. Defaults to 60000 ms. | | `SUBMISSION_RATE_LIMIT_MAX` | Optional | `20` | Submission API | Maximum submissions per client key per window. Defaults to 20. | | `MAX_SUBMISSION_BODY_BYTES` | Optional | `250000` | Submission API | Maximum accepted submission body size. Defaults to 250000 bytes. | +| `EXPORT_SUBMISSIONS_LIMIT` | Optional | `500` | Export script | Optional default page size for `npm run export:submissions`; command-line `--limit` takes precedence. | | `GOOGLE_SHEETS_WEBHOOK_URL` | Optional | `https://script.google.com/macros/s/.../exec` | Submission API | Optional Google Apps Script webhook URL. When set, successfully stored submissions are mirrored to Google Sheets after PostgreSQL save. | | `GOOGLE_SHEETS_WEBHOOK_SECRET` | Recommended with Sheets mirror | `replace-with-a-long-random-secret` | Submission API, Apps Script | Shared secret sent to the webhook for verification. Keep it private and do not append it to sheet rows. | | `CONSENT_VERSION` | Optional server metadata fallback | `pilot-consent-v1` | Submission API | Stored as a fallback if a submitted payload lacks `consentVersion`. The client export currently uses the version constant in `utils/researchMetrics.ts`. | | `SCHEMA_VERSION` | Present in `.env.example`; not currently read by app code | `research-export-v1` | Deployment convention only | Reserved/configuration note for schema versioning. The client export currently uses the schema version constant in `utils/researchMetrics.ts`. | -For Docker Compose, `.env.example` also includes `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DB` for the bundled PostgreSQL service. +For Docker Compose, `.env.example` also includes `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DB` for the bundled PostgreSQL service. In `NODE_ENV=production`, `npm run start` refuses to run if `ADMIN_EXPORT_TOKEN`, `POSTGRES_PASSWORD`, or `DATABASE_URL` still contain the example secret values. ## Optional Google Sheets mirror @@ -472,6 +473,8 @@ Required deployment environment variables for server collection are: - `ADMIN_EXPORT_TOKEN` for `/admin` and `/api/admin/*` exports. - `APP_BASE_URL` for export helpers and deployment-specific examples. +Production startup validation refuses the example `ADMIN_EXPORT_TOKEN` and PostgreSQL password values, and also refuses `ENABLE_SERVER_SUBMISSION=true` without `DATABASE_URL`. This makes placeholder secrets fail closed instead of being silently accepted. + Optional operational controls are `SUBMISSION_RATE_LIMIT_WINDOW_MS`, `SUBMISSION_RATE_LIMIT_MAX`, and `MAX_SUBMISSION_BODY_BYTES`. They default to safe prototype values when unset, but production deployments should review them for the expected participant volume and hosting topology. ## Deployment checklist @@ -483,6 +486,7 @@ Before collecting pilot data: - [ ] Set `ENABLE_SERVER_SUBMISSION=true` on the server. - [ ] Set `NEXT_PUBLIC_ENABLE_SERVER_SUBMISSION=true` before build so participants see the submission UI. - [ ] Use a strong `ADMIN_EXPORT_TOKEN`. +- [ ] Replace the example `POSTGRES_PASSWORD` before starting production containers. - [ ] Install exactly from the lockfile: `npm ci`. - [ ] Generate Prisma client: `npm run db:generate`. - [ ] Run database migration: `npm run db:migrate` (`npx prisma migrate deploy`). diff --git a/app/api/health/route.ts b/app/api/health/route.ts index bc08ec2..335da0b 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -1,5 +1,4 @@ import { NextResponse } from "next/server"; -import { isDatabaseConfigured, isServerSubmissionEnabled } from "@/lib/serverConfig"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -10,8 +9,6 @@ export function GET() { ok: true, service: "hidden-cost-game", timestamp: new Date().toISOString(), - serverSubmissionEnabled: isServerSubmissionEnabled(), - databaseConfigured: isDatabaseConfigured(), }, { headers: { "Cache-Control": "no-store" } }, ); diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index bfd32f6..ecb614b 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -29,6 +29,8 @@ At minimum review: - `POSTGRES_PASSWORD` — change the local Docker default before real data collection. - `DATABASE_URL` — for native Node deployments, point this at the local/native Postgres database. +Production startup validation runs before `next start`. It refuses the example `ADMIN_EXPORT_TOKEN`, refuses the example PostgreSQL password in `POSTGRES_PASSWORD` or `DATABASE_URL`, and refuses `ENABLE_SERVER_SUBMISSION=true` without `DATABASE_URL`. + Generate secrets with a command such as: ```bash diff --git a/docs/VPS_DEPLOYMENT.md b/docs/VPS_DEPLOYMENT.md index b386a39..2a1b59c 100644 --- a/docs/VPS_DEPLOYMENT.md +++ b/docs/VPS_DEPLOYMENT.md @@ -115,7 +115,7 @@ Required production values: - `ENABLE_SERVER_SUBMISSION="true"`: enables the server API that accepts participant submissions. - `NEXT_PUBLIC_ENABLE_SERVER_SUBMISSION="true"`: shows the submission UI in the browser; because this is a public build-time value, rebuild the app after changing it. - `ADMIN_EXPORT_TOKEN="long random secret"`: secret token used to open `/admin` and export CSV/JSON. Keep it private, never leave it as `change-me-before-production`, and send it only in the `Authorization: Bearer ` header. -- `POSTGRES_PASSWORD="long random password"`: password for the Docker PostgreSQL database. Keep it private and back up your data before changing it later. +- `POSTGRES_PASSWORD="long random password"`: password for the Docker PostgreSQL database. Keep it private and back up your data before changing it later. The production app refuses to start if this is still the example `hcg_password_change_me` value. Optional Google Sheets values: @@ -136,7 +136,7 @@ Run the command once for `ADMIN_EXPORT_TOKEN`, once for `POSTGRES_PASSWORD`, and ## 7. Start the app -Build and start the app and database in the background: +Build and start the app and database in the background. If `.env` still contains placeholder production secrets, the app container will fail closed before starting Next.js; replace `ADMIN_EXPORT_TOKEN` and `POSTGRES_PASSWORD` first. ```bash docker compose up --build -d @@ -249,7 +249,8 @@ Then open `https://your-domain.com` in your browser. Before collecting real pilot data: - Use HTTPS, with HTTP redirected to HTTPS by Caddy. -- Use a long random `ADMIN_EXPORT_TOKEN`; the production admin API refuses a missing token and refuses the example `change-me-before-production` token. +- Use a long random `ADMIN_EXPORT_TOKEN`; the production admin API and startup validation refuse a missing token and refuse the example `change-me-before-production` token. +- Use a long random `POSTGRES_PASSWORD`; startup validation refuses the example `hcg_password_change_me` password in production. - Send the admin token only as `Authorization: Bearer `, not as a query string. - Optionally protect `/admin` and `/api/admin/*` behind Caddy `basic_auth`, an IP allowlist, VPN, or institutional access control. - Keep `.env`, database backups, and exported CSV/JSON files private. diff --git a/package.json b/package.json index bc2b75e..599ac2a 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "scripts": { "dev": "next dev", "build": "next build", - "start": "next start", + "start": "node scripts/validate-production-env.mjs && next start", "lint": "next lint", "typecheck": "tsc --noEmit", "db:generate": "prisma generate", diff --git a/scripts/validate-production-env.mjs b/scripts/validate-production-env.mjs new file mode 100644 index 0000000..9659624 --- /dev/null +++ b/scripts/validate-production-env.mjs @@ -0,0 +1,51 @@ +const DEFAULT_ADMIN_EXPORT_TOKEN = "change-me-before-production"; +const DEFAULT_POSTGRES_PASSWORD = "hcg_password_change_me"; + +const isProduction = process.env.NODE_ENV === "production"; + +if (!isProduction) { + process.exit(0); +} + +const errors = []; +const warnings = []; +const enableServerSubmission = process.env.ENABLE_SERVER_SUBMISSION === "true"; +const adminExportToken = process.env.ADMIN_EXPORT_TOKEN?.trim(); +const databaseUrl = process.env.DATABASE_URL?.trim(); +const postgresPassword = process.env.POSTGRES_PASSWORD?.trim(); +const googleSheetsWebhookUrl = process.env.GOOGLE_SHEETS_WEBHOOK_URL?.trim(); +const googleSheetsWebhookSecret = process.env.GOOGLE_SHEETS_WEBHOOK_SECRET?.trim(); + +if (!adminExportToken) { + errors.push("ADMIN_EXPORT_TOKEN is required in production."); +} else if (adminExportToken === DEFAULT_ADMIN_EXPORT_TOKEN) { + errors.push(`ADMIN_EXPORT_TOKEN must not use the example value (${DEFAULT_ADMIN_EXPORT_TOKEN}).`); +} + +if (enableServerSubmission && !databaseUrl) { + errors.push("DATABASE_URL is required when ENABLE_SERVER_SUBMISSION=true in production."); +} + +if (databaseUrl?.includes(DEFAULT_POSTGRES_PASSWORD)) { + errors.push(`DATABASE_URL must not contain the example PostgreSQL password (${DEFAULT_POSTGRES_PASSWORD}).`); +} + +if (postgresPassword === DEFAULT_POSTGRES_PASSWORD) { + errors.push(`POSTGRES_PASSWORD must not use the example value (${DEFAULT_POSTGRES_PASSWORD}).`); +} + +if (googleSheetsWebhookUrl && !googleSheetsWebhookSecret) { + warnings.push("GOOGLE_SHEETS_WEBHOOK_URL is set without GOOGLE_SHEETS_WEBHOOK_SECRET; the mirror remains optional, but a shared secret is recommended."); +} + +for (const warning of warnings) { + console.warn(`[production-env] Warning: ${warning}`); +} + +if (errors.length > 0) { + console.error("[production-env] Refusing to start with unsafe production environment:"); + for (const error of errors) { + console.error(`- ${error}`); + } + process.exit(1); +}