diff --git a/app/api/admin/stats/route.ts b/app/api/admin/stats/route.ts index 1b08faa..f9e0f28 100644 --- a/app/api/admin/stats/route.ts +++ b/app/api/admin/stats/route.ts @@ -1,6 +1,5 @@ import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; -import { validateAdminRequest } from "@/lib/adminAuth.server"; +import { adminJsonResponse, validateAdminRequest } from "@/lib/adminAuth.server"; import { AdminSubmissionError, adminStatsJson, getAdminStats } from "@/lib/adminSubmissions"; export const runtime = "nodejs"; @@ -13,12 +12,12 @@ export async function GET(request: NextRequest) { try { const stats = await getAdminStats(); - return NextResponse.json(adminStatsJson(stats)); + return adminJsonResponse(adminStatsJson(stats)); } catch (error) { if (error instanceof AdminSubmissionError) { - return NextResponse.json({ ok: false, error: error.message }, { status: error.status }); + return adminJsonResponse({ ok: false, error: error.message }, { status: error.status }); } - return NextResponse.json({ ok: false, error: "Unable to retrieve admin stats." }, { status: 500 }); + return adminJsonResponse({ ok: false, error: "Unable to retrieve admin stats." }, { status: 500 }); } } diff --git a/app/api/admin/submissions.csv/route.ts b/app/api/admin/submissions.csv/route.ts index 1e9fd7a..76bc038 100644 --- a/app/api/admin/submissions.csv/route.ts +++ b/app/api/admin/submissions.csv/route.ts @@ -1,6 +1,6 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; -import { validateAdminRequest } from "@/lib/adminAuth.server"; +import { adminJsonResponse, validateAdminRequest, withAdminNoStore } from "@/lib/adminAuth.server"; import { AdminSubmissionError, listAllAdminSubmissions, submissionsToCsv } from "@/lib/adminSubmissions"; export const runtime = "nodejs"; @@ -13,17 +13,17 @@ export async function GET(request: NextRequest) { try { const items = await listAllAdminSubmissions(); - return new NextResponse(submissionsToCsv(items), { + return new NextResponse(submissionsToCsv(items), withAdminNoStore({ headers: { "content-disposition": "attachment; filename=\"submissions.csv\"", "content-type": "text/csv; charset=utf-8", }, - }); + })); } catch (error) { if (error instanceof AdminSubmissionError) { - return NextResponse.json({ ok: false, error: error.message }, { status: error.status }); + return adminJsonResponse({ ok: false, error: error.message }, { status: error.status }); } - return NextResponse.json({ ok: false, error: "Unable to retrieve submissions." }, { status: 500 }); + return adminJsonResponse({ ok: false, error: "Unable to retrieve submissions." }, { status: 500 }); } } diff --git a/app/api/admin/submissions/route.ts b/app/api/admin/submissions/route.ts index 6b3070d..fa3b835 100644 --- a/app/api/admin/submissions/route.ts +++ b/app/api/admin/submissions/route.ts @@ -1,6 +1,5 @@ import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; -import { validateAdminRequest } from "@/lib/adminAuth.server"; +import { adminJsonResponse, validateAdminRequest } from "@/lib/adminAuth.server"; import { AdminSubmissionError, adminSubmissionPageJson, listAdminSubmissions } from "@/lib/adminSubmissions"; export const runtime = "nodejs"; @@ -13,12 +12,12 @@ export async function GET(request: NextRequest) { try { const page = await listAdminSubmissions(request.nextUrl.searchParams); - return NextResponse.json(adminSubmissionPageJson(page)); + return adminJsonResponse(adminSubmissionPageJson(page)); } catch (error) { if (error instanceof AdminSubmissionError) { - return NextResponse.json({ ok: false, error: error.message }, { status: error.status }); + return adminJsonResponse({ ok: false, error: error.message }, { status: error.status }); } - return NextResponse.json({ ok: false, error: "Unable to retrieve submissions." }, { status: 500 }); + return adminJsonResponse({ ok: false, error: "Unable to retrieve submissions." }, { status: 500 }); } } diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 5d2cc55..bc08ec2 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -2,13 +2,17 @@ import { NextResponse } from "next/server"; import { isDatabaseConfigured, isServerSubmissionEnabled } from "@/lib/serverConfig"; export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; export function GET() { - return NextResponse.json({ - ok: true, - service: "hidden-cost-game", - timestamp: new Date().toISOString(), - serverSubmissionEnabled: isServerSubmissionEnabled(), - databaseConfigured: isDatabaseConfigured(), - }); + return NextResponse.json( + { + ok: true, + service: "hidden-cost-game", + timestamp: new Date().toISOString(), + serverSubmissionEnabled: isServerSubmissionEnabled(), + databaseConfigured: isDatabaseConfigured(), + }, + { headers: { "Cache-Control": "no-store" } }, + ); } diff --git a/app/api/submissions/route.ts b/app/api/submissions/route.ts index 53a000b..fe262f9 100644 --- a/app/api/submissions/route.ts +++ b/app/api/submissions/route.ts @@ -63,7 +63,7 @@ export async function POST(request: NextRequest) { const validation = researchExportSchema.safeParse(parsedJson); if (!validation.success) { - return NextResponse.json( + return submissionJsonResponse( { ok: false, error: "Submission must be a complete research export JSON object.", @@ -108,7 +108,7 @@ export async function POST(request: NextRequest) { submittedAt, }); - return NextResponse.json( + return submissionJsonResponse( { ok: true, serverSubmissionId, @@ -122,7 +122,17 @@ export async function POST(request: NextRequest) { } function jsonError(error: string, status: number): NextResponse { - return NextResponse.json({ ok: false, error }, { status }); + return submissionJsonResponse({ ok: false, error }, { status }); +} + +function submissionJsonResponse(body: unknown, init: ResponseInit = {}): NextResponse { + const headers = new Headers(init.headers); + headers.set("Cache-Control", "no-store"); + + return NextResponse.json(body, { + ...init, + headers, + }); } function getClientKey(request: NextRequest): string { diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 4eda54c..bfd32f6 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -25,8 +25,7 @@ At minimum review: - `APP_BASE_URL` — set this to `https://your-domain.com` in production. - `ENABLE_SERVER_SUBMISSION` and `NEXT_PUBLIC_ENABLE_SERVER_SUBMISSION` — set both to `true` when you want participant submissions stored on the server. -- `ADMIN_EXPORT_TOKEN` — use a long random value for API exports. -- `ADMIN_DASHBOARD_PASSWORD` — use a long random password for `/admin`. +- `ADMIN_EXPORT_TOKEN` — use a long random value for API exports; never leave the production value empty or set to `change-me-before-production`. - `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. @@ -136,6 +135,30 @@ your-domain.com { } ``` +Optional extra protection for researcher-only admin routes: + +```caddyfile +your-domain.com { + route /admin* { + basic_auth { + researcher + } + reverse_proxy 127.0.0.1:3000 + } + + route /api/admin* { + basic_auth { + researcher + } + reverse_proxy 127.0.0.1:3000 + } + + reverse_proxy 127.0.0.1:3000 +} +``` + +Use HTTPS, keep `ADMIN_EXPORT_TOKEN` strong and private, send it only in `Authorization: Bearer `, and consider Caddy `basic_auth`, an IP allowlist, VPN, or institutional SSO for `/admin` and `/api/admin/*`. + ## E. Firewall Allow only SSH and web traffic from the public internet: diff --git a/docs/VPS_DEPLOYMENT.md b/docs/VPS_DEPLOYMENT.md index ef1eeca..cc37da3 100644 --- a/docs/VPS_DEPLOYMENT.md +++ b/docs/VPS_DEPLOYMENT.md @@ -114,7 +114,7 @@ Required production values: - `APP_BASE_URL="https://your-domain.com"`: the public URL for the deployed site. - `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. +- `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. Optional Google Sheets values: @@ -200,6 +200,30 @@ your-domain.com { } ``` +For pilot data collection, also consider adding a second layer around the researcher-only admin routes. The app still requires `ADMIN_EXPORT_TOKEN` at `/api/admin/*`, but Caddy `basic_auth` or an IP allowlist reduces exposure if the token is mishandled. Generate a real hashed password with `caddy hash-password` and replace the placeholder before use: + +```caddyfile +your-domain.com { + route /admin* { + basic_auth { + researcher + } + reverse_proxy 127.0.0.1:3000 + } + + route /api/admin* { + basic_auth { + researcher + } + reverse_proxy 127.0.0.1:3000 + } + + reverse_proxy 127.0.0.1:3000 +} +``` + +Use HTTPS for all participant and admin traffic. Do not put `ADMIN_EXPORT_TOKEN` in URLs or query strings, because URLs are more likely to appear in logs, browser history, and referrer data. + Reload Caddy: ```bash @@ -208,7 +232,17 @@ sudo systemctl reload caddy Then open `https://your-domain.com` in your browser. -## 10. Firewall +## 10. Admin route security checklist + +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. +- 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. + +## 11. Firewall Enable UFW and allow only SSH plus web traffic: @@ -224,7 +258,7 @@ Do **not** open port `3000` publicly for normal production use. The Docker Compo Only expose `3000` temporarily when debugging, and close it again immediately afterward. -## 11. Update deployment +## 12. Update deployment From the project directory, pull the latest branch changes: @@ -250,7 +284,7 @@ Check logs after every update: docker compose logs -f app ``` -## 12. Backup notes +## 13. Backup notes The bundled PostgreSQL database stores its files in the Docker volume named `postgres_data`. Docker volumes persist when containers are recreated, but they are still on the VPS disk. Back up the database before risky changes, major updates, or server migrations. @@ -281,7 +315,7 @@ A restore command usually looks like this, but do not run it on production unles cat backups/hidden_cost_game_BACKUP_FILE.sql | docker compose exec -T postgres psql -U hcg -d hidden_cost_game ``` -## 13. Final smoke test +## 14. Final smoke test After deployment and HTTPS setup, test the full production path: diff --git a/lib/adminAuth.server.ts b/lib/adminAuth.server.ts index 34094f4..4090d92 100644 --- a/lib/adminAuth.server.ts +++ b/lib/adminAuth.server.ts @@ -10,9 +10,10 @@ type AdminAuthResult = }; const BEARER_PREFIX = "Bearer "; +const PLACEHOLDER_ADMIN_TOKEN = "change-me-before-production"; export function validateAdminRequest(request: NextRequest): AdminAuthResult { - const configuredToken = process.env.ADMIN_EXPORT_TOKEN; + const configuredToken = process.env.ADMIN_EXPORT_TOKEN?.trim(); if (!configuredToken) { return { @@ -21,6 +22,13 @@ export function validateAdminRequest(request: NextRequest): AdminAuthResult { }; } + if (isUnsafeProductionAdminToken(configuredToken)) { + return { + ok: false, + response: adminJsonError("Admin export token must be changed before production use.", 500), + }; + } + const authorization = request.headers.get("authorization"); if (!authorization?.startsWith(BEARER_PREFIX)) { return { @@ -40,6 +48,24 @@ export function validateAdminRequest(request: NextRequest): AdminAuthResult { return { ok: true }; } +export function adminJsonResponse(body: unknown, init: ResponseInit = {}): NextResponse { + return NextResponse.json(body, withAdminNoStore(init)); +} + +export function withAdminNoStore(init: ResponseInit = {}): ResponseInit { + const headers = new Headers(init.headers); + headers.set("Cache-Control", "no-store"); + + return { + ...init, + headers, + }; +} + +function isUnsafeProductionAdminToken(token: string): boolean { + return process.env.NODE_ENV === "production" && token === PLACEHOLDER_ADMIN_TOKEN; +} + function constantTimeTokenEquals(a: string, b: string): boolean { const aHash = createHash("sha256").update(a).digest(); const bHash = createHash("sha256").update(b).digest(); @@ -48,5 +74,5 @@ function constantTimeTokenEquals(a: string, b: string): boolean { } function adminJsonError(error: string, status: number): NextResponse { - return NextResponse.json({ ok: false, error }, { status }); + return adminJsonResponse({ ok: false, error }, { status }); }