Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=""
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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`).
Expand Down
3 changes: 0 additions & 3 deletions app/api/health/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { NextResponse } from "next/server";
import { isDatabaseConfigured, isServerSubmissionEnabled } from "@/lib/serverConfig";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";
Expand All @@ -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" } },
);
Expand Down
2 changes: 2 additions & 0 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions docs/VPS_DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>` 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:

Expand All @@ -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
Expand Down Expand Up @@ -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 <token>`, 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.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
51 changes: 51 additions & 0 deletions scripts/validate-production-env.mjs
Original file line number Diff line number Diff line change
@@ -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);
}