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
17 changes: 13 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,17 +53,26 @@ pnpm seed:demo
pnpm dev
```

Open <http://localhost:3000>. The admin area is available at <http://localhost:3000/admin/login>.
Open <http://localhost:3000> for the participant landing page. Open <http://localhost:3000/admin> for the admin dashboard; the browser will prompt for HTTP Basic Auth using `ADMIN_EMAIL` and `ADMIN_PASSWORD` from `.env`.

## Docker start

For local Docker development:
For local Docker development, the default Compose path now builds the app, waits for PostgreSQL, runs Prisma migrations, seeds demo data, and starts the web container:

```bash
cp .env.example .env
docker compose up --build
```

If you only start PostgreSQL through Docker and run the app on your host, use the explicit local test path:

```bash
docker compose up -d postgres
pnpm db:migrate
pnpm seed:demo
pnpm dev
```

For the production-oriented Compose file:

```bash
Expand Down Expand Up @@ -114,7 +123,7 @@ Runtime flow:

## Research design

The current study design uses seven-round seasons, three action points per participant per round, and 10x10 parcel maps. Initial parcel quality operationalizes inequality, while stable versus uncertain institutional conditions affect the reliability and predictability of rules or shocks.
The current study design uses seven-round seasons, three action points per participant per round, 10x10 parcel maps, and fixed formal/informal contract fees. Initial parcel quality operationalizes inequality, while stable versus uncertain institutional conditions affect the reliability and predictability of rules or shocks.

Confirmatory analysis should be preregistered before real data collection. The included analysis helper is descriptive and intended for pilots, diagnostics, and transparent release artifacts. See:

Expand All @@ -125,7 +134,7 @@ Confirmatory analysis should be preregistered before real data collection. The i

## Data exports

Administrators can export research-safe ZIP files:
Administrators can export research-safe ZIP files. Admin endpoints are protected by Basic Auth and exports omit emails, passwords, IP addresses, tokens, and authentication credentials:

- `GET /api/admin/servers/:serverId/export.zip` for one server.
- `GET /api/admin/export/all.zip` for all servers.
Expand Down
11 changes: 2 additions & 9 deletions apps/web/app/admin/_components/AdminActions.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { useState } from "react";

type ActionKind = "patch" | "post" | "export";

Expand All @@ -15,12 +15,6 @@ type Action = {
preview?: { submitted: number; missing: number; currentRound: number; nextRound: number };
};

const authHeader = () => {
if (typeof window === "undefined") return {};
const token = window.localStorage.getItem("parcel_admin_basic");
return token ? { Authorization: `Basic ${token}` } : {};
};

export function ConfirmDialog({ action, onClose, onConfirm, busy }: { action: Action | null; onClose: () => void; onConfirm: () => void; busy: boolean }) {
if (!action) return null;
return (
Expand Down Expand Up @@ -50,15 +44,14 @@ export function AdminActions({ actions }: { actions: Action[] }) {
const [pending, setPending] = useState<Action | null>(null);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const headers = useMemo(() => ({ "Content-Type": "application/json", ...authHeader() }), []);

const run = async (action: Action) => {
setBusy(true);
setMessage(null);
try {
const response = await fetch(action.url, {
method: action.kind === "patch" ? "PATCH" : "POST",
headers,
headers: { "Content-Type": "application/json" },
body: action.kind === "patch" || action.body ? JSON.stringify(action.body ?? {}) : undefined,
});
const data = await response.json().catch(() => ({}));
Expand Down
62 changes: 16 additions & 46 deletions apps/web/app/admin/login/page.tsx
Original file line number Diff line number Diff line change
@@ -1,58 +1,28 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { AdminPageHeader, Card } from "../_components/ui";

export default function AdminLoginPage() {
const [email, setEmail] = useState("admin@example.com");
const [password, setPassword] = useState("changeme");
const [saved, setSaved] = useState(false);
return (
<>
<AdminPageHeader
title="Admin API login"
description="Stores Basic Auth credentials locally for admin action buttons and API downloads. Demo defaults are for local development only."
title="Admin access"
description="Admin pages and APIs are protected by HTTP Basic Auth using ADMIN_EMAIL and ADMIN_PASSWORD. Your browser prompts for credentials before this page loads."
/>
<Card>
<form
onSubmit={(event) => {
event.preventDefault();
window.localStorage.setItem(
"parcel_admin_basic",
btoa(`${email}:${password}`),
);
setSaved(true);
}}
className="max-w-md space-y-4"
>
<label className="block text-sm font-medium">
Email
<input
value={email}
onChange={(event) => setEmail(event.target.value)}
className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2"
/>
</label>
<label className="block text-sm font-medium">
Password
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
className="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2"
/>
</label>
<p className="text-xs text-amber-700">
Demo credentials are only for local development.
<div className="max-w-2xl space-y-4 text-sm text-slate-700">
<p>
There is no separate in-app admin session for the MVP. To switch
users, clear this site&apos;s saved Basic Auth credentials in your
browser or open a private browsing window.
</p>
<button className="rounded-lg bg-slate-950 px-4 py-2 text-sm font-semibold text-white">
Save credentials
</button>
{saved ? (
<p className="text-sm text-emerald-700">
Credentials saved in this browser.
</p>
) : null}
</form>
<p>
API downloads and admin actions use the same browser-authenticated
request context; credentials are not stored in localStorage.
</p>
<Link className="inline-flex rounded-lg bg-slate-950 px-4 py-2 font-semibold text-white" href="/admin">
Back to admin dashboard
</Link>
</div>
</Card>
</>
);
Expand Down
4 changes: 2 additions & 2 deletions apps/web/app/admin/servers/new/ConfigJsonForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ const exampleConfig = `{
"productiveInvestmentDepreciation": 0.0
},
"contracts": {
"formalFeeRate": 0.08,
"informalFeeRate": 0.02,
"formalFixedFee": 2,
"informalFixedFee": 0.5,
"formalDefaultRisk": 0.02,
"informalDefaultRisk": 0.15
},
Expand Down
5 changes: 4 additions & 1 deletion apps/web/app/api/health/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,20 @@ export const dynamic = "force-dynamic";
type HealthResponse = {
ok: boolean;
database: "connected" | "disconnected";
applicationTable: "Server";
timestamp: string;
};

export async function GET() {
const timestamp = new Date().toISOString();

try {
await prisma.$queryRaw`SELECT 1`;
await prisma.server.count();

return Response.json({
ok: true,
database: "connected",
applicationTable: "Server",
timestamp,
} satisfies HealthResponse);
} catch (error) {
Expand All @@ -26,6 +28,7 @@ export async function GET() {
{
ok: false,
database: "disconnected",
applicationTable: "Server",
timestamp,
} satisfies HealthResponse,
{ status: 503 },
Expand Down
5 changes: 4 additions & 1 deletion apps/web/lib/api/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,13 @@ describe("admin authorization", () => {
prismaMock.adminUser.upsert.mockResolvedValue({ id: "admin-profile" });
});

it("rejects missing admin credentials", async () => {
it("rejects missing admin credentials with a Basic Auth challenge", async () => {
await expect(requireAdminAuth(new Request("https://example.test/api/admin"))).rejects.toMatchObject({
status: 401,
code: "UNAUTHORIZED",
headers: {
"WWW-Authenticate": 'Basic realm="Parcel Society Admin", charset="UTF-8"',
},
});
});

Expand Down
12 changes: 8 additions & 4 deletions apps/web/lib/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import { ApiException } from "./responses";
import { rateLimit } from "./rateLimit";

const PARTICIPANT_COOKIE = "parcel_society_user_id";
const ADMIN_AUTH_CHALLENGE = {
"WWW-Authenticate": 'Basic realm="Parcel Society Admin", charset="UTF-8"',
"Cache-Control": "no-store",
};

const appSecret = (): string => {
const secret = process.env.APP_SECRET;
Expand Down Expand Up @@ -91,11 +95,9 @@ export const requireAdminAuth = async (
rateLimit({ request, key: "admin-login", limit: 20, windowMs: 60_000 });
const credentials = parseBasicAuth(request);
const adminEmail =
process.env.ADMIN_EMAIL ??
(process.env.NODE_ENV === "production" ? undefined : "admin@example.com");
process.env.ADMIN_EMAIL;
const adminPassword =
process.env.ADMIN_PASSWORD ??
(process.env.NODE_ENV === "production" ? undefined : "changeme");
process.env.ADMIN_PASSWORD;

if (!adminEmail || !adminPassword) {
throw new ApiException(
Expand All @@ -114,6 +116,8 @@ export const requireAdminAuth = async (
401,
"UNAUTHORIZED",
"Admin credentials are required.",
undefined,
ADMIN_AUTH_CHALLENGE,
);
}

Expand Down
9 changes: 6 additions & 3 deletions apps/web/lib/api/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@ export class ApiException extends Error {
readonly status: number;
readonly code: string;
readonly details?: unknown;
readonly headers?: HeadersInit;

constructor(status: number, code: string, message: string, details?: unknown) {
constructor(status: number, code: string, message: string, details?: unknown, headers?: HeadersInit) {
super(message);
this.status = status;
this.code = code;
this.details = details;
this.headers = headers;
}
}

Expand All @@ -32,15 +34,16 @@ export const apiError = (
code: string,
message: string,
details?: unknown,
headers?: HeadersInit,
): NextResponse<ApiResponse<never>> =>
NextResponse.json({ ok: false, error: { code, message, details } }, { status });
NextResponse.json({ ok: false, error: { code, message, details } }, { status, headers });

export const handleApiError = (error: unknown, context?: Record<string, unknown>): NextResponse<ApiResponse<never>> => {
if (error instanceof ApiException) {
if (error.status >= 500) {
console.error("API exception", { code: error.code, message: error.message, context, details: error.details });
}
return apiError(error.status, error.code, error.message, error.details);
return apiError(error.status, error.code, error.message, error.details, error.headers);
}

if (error instanceof ZodError) {
Expand Down
1 change: 0 additions & 1 deletion apps/web/lib/services/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,6 @@ export const defaultEngineConfig = (server: {
startingWealth: Number(overrides.startingWealth ?? 100),
investmentUnitCost: Number(overrides.investmentUnitCost ?? 10),
safeAssetReturn: Number(overrides.safeAssetReturn ?? 0.03),
publicGoodMultiplier: Number(overrides.publicGoodMultiplier ?? 1.5),
lobbyingCost: Number(overrides.lobbyingCost ?? 5),
uncertaintyRuleChangeRounds: Array.isArray(overrides.uncertaintyRuleChangeRounds)
? overrides.uncertaintyRuleChangeRounds.map(Number).filter(Number.isFinite)
Expand Down
Loading
Loading