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
9 changes: 4 additions & 5 deletions app/api/admin/stats/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 });
}
}
10 changes: 5 additions & 5 deletions app/api/admin/submissions.csv/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 });
}
}
9 changes: 4 additions & 5 deletions app/api/admin/submissions/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 });
}
}
18 changes: 11 additions & 7 deletions app/api/health/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" } },
);
}
16 changes: 13 additions & 3 deletions app/api/submissions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -108,7 +108,7 @@ export async function POST(request: NextRequest) {
submittedAt,
});

return NextResponse.json(
return submissionJsonResponse(
{
ok: true,
serverSubmissionId,
Expand All @@ -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 {
Expand Down
27 changes: 25 additions & 2 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -136,6 +135,30 @@ your-domain.com {
}
```

Optional extra protection for researcher-only admin routes:

```caddyfile
your-domain.com {
route /admin* {
basic_auth {
researcher <hashed-password-placeholder>
}
reverse_proxy 127.0.0.1:3000
}

route /api/admin* {
basic_auth {
researcher <hashed-password-placeholder>
}
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 <token>`, 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:
Expand Down
44 changes: 39 additions & 5 deletions docs/VPS_DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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.

Optional Google Sheets values:
Expand Down Expand Up @@ -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 <hashed-password-placeholder>
}
reverse_proxy 127.0.0.1:3000
}

route /api/admin* {
basic_auth {
researcher <hashed-password-placeholder>
}
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
Expand All @@ -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 <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.

## 11. Firewall

Enable UFW and allow only SSH plus web traffic:

Expand All @@ -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:

Expand All @@ -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.

Expand Down Expand Up @@ -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:

Expand Down
30 changes: 28 additions & 2 deletions lib/adminAuth.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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();
Expand All @@ -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 });
}