diff --git a/README.md b/README.md index acd5984..0e968df 100644 --- a/README.md +++ b/README.md @@ -53,17 +53,26 @@ pnpm seed:demo pnpm dev ``` -Open . The admin area is available at . +Open for the participant landing page. Open 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 @@ -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: @@ -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. diff --git a/apps/web/app/admin/_components/AdminActions.tsx b/apps/web/app/admin/_components/AdminActions.tsx index 7c60513..bc7d861 100644 --- a/apps/web/app/admin/_components/AdminActions.tsx +++ b/apps/web/app/admin/_components/AdminActions.tsx @@ -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"; @@ -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 ( @@ -50,7 +44,6 @@ export function AdminActions({ actions }: { actions: Action[] }) { const [pending, setPending] = useState(null); const [busy, setBusy] = useState(false); const [message, setMessage] = useState(null); - const headers = useMemo(() => ({ "Content-Type": "application/json", ...authHeader() }), []); const run = async (action: Action) => { setBusy(true); @@ -58,7 +51,7 @@ export function AdminActions({ actions }: { actions: Action[] }) { 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(() => ({})); diff --git a/apps/web/app/admin/login/page.tsx b/apps/web/app/admin/login/page.tsx index 73f0bb1..4c23b6e 100644 --- a/apps/web/app/admin/login/page.tsx +++ b/apps/web/app/admin/login/page.tsx @@ -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 ( <> -
{ - event.preventDefault(); - window.localStorage.setItem( - "parcel_admin_basic", - btoa(`${email}:${password}`), - ); - setSaved(true); - }} - className="max-w-md space-y-4" - > - - -

- Demo credentials are only for local development. +

+

+ There is no separate in-app admin session for the MVP. To switch + users, clear this site's saved Basic Auth credentials in your + browser or open a private browsing window.

- - {saved ? ( -

- Credentials saved in this browser. -

- ) : null} - +

+ API downloads and admin actions use the same browser-authenticated + request context; credentials are not stored in localStorage. +

+ + Back to admin dashboard + +
); diff --git a/apps/web/app/admin/servers/new/ConfigJsonForm.tsx b/apps/web/app/admin/servers/new/ConfigJsonForm.tsx index fdda973..9c0fcc3 100644 --- a/apps/web/app/admin/servers/new/ConfigJsonForm.tsx +++ b/apps/web/app/admin/servers/new/ConfigJsonForm.tsx @@ -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 }, diff --git a/apps/web/app/api/health/route.ts b/apps/web/app/api/health/route.ts index 7f55042..69c5e1e 100644 --- a/apps/web/app/api/health/route.ts +++ b/apps/web/app/api/health/route.ts @@ -5,6 +5,7 @@ export const dynamic = "force-dynamic"; type HealthResponse = { ok: boolean; database: "connected" | "disconnected"; + applicationTable: "Server"; timestamp: string; }; @@ -12,11 +13,12 @@ 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) { @@ -26,6 +28,7 @@ export async function GET() { { ok: false, database: "disconnected", + applicationTable: "Server", timestamp, } satisfies HealthResponse, { status: 503 }, diff --git a/apps/web/lib/api/auth.test.ts b/apps/web/lib/api/auth.test.ts index 051661d..7dee88d 100644 --- a/apps/web/lib/api/auth.test.ts +++ b/apps/web/lib/api/auth.test.ts @@ -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"', + }, }); }); diff --git a/apps/web/lib/api/auth.ts b/apps/web/lib/api/auth.ts index 5cbdc22..22e2ae9 100644 --- a/apps/web/lib/api/auth.ts +++ b/apps/web/lib/api/auth.ts @@ -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; @@ -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( @@ -114,6 +116,8 @@ export const requireAdminAuth = async ( 401, "UNAUTHORIZED", "Admin credentials are required.", + undefined, + ADMIN_AUTH_CHALLENGE, ); } diff --git a/apps/web/lib/api/responses.ts b/apps/web/lib/api/responses.ts index a43b6e1..9f610c2 100644 --- a/apps/web/lib/api/responses.ts +++ b/apps/web/lib/api/responses.ts @@ -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; } } @@ -32,15 +34,16 @@ export const apiError = ( code: string, message: string, details?: unknown, + headers?: HeadersInit, ): NextResponse> => - 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): NextResponse> => { 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) { diff --git a/apps/web/lib/services/game.ts b/apps/web/lib/services/game.ts index de6f657..b9e3d2d 100644 --- a/apps/web/lib/services/game.ts +++ b/apps/web/lib/services/game.ts @@ -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) diff --git a/apps/web/lib/services/researchExport.test.ts b/apps/web/lib/services/researchExport.test.ts new file mode 100644 index 0000000..ca90c05 --- /dev/null +++ b/apps/web/lib/services/researchExport.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it, vi } from "vitest"; + +const prismaMock = vi.hoisted(() => ({ + server: { + findMany: vi.fn(), + }, +})); + +vi.mock("@parcel-society/db", () => ({ + ActionType: { + PRODUCE: "PRODUCE", + PRODUCTIVE_INVESTMENT: "PRODUCTIVE_INVESTMENT", + SAFE_ASSET: "SAFE_ASSET", + PUBLIC_CONTRIBUTION: "PUBLIC_CONTRIBUTION", + INFORMAL_CONTRACT: "INFORMAL_CONTRACT", + FORMAL_CONTRACT: "FORMAL_CONTRACT", + LOBBYING: "LOBBYING", + EXIT: "EXIT", + }, + ContractType: { FORMAL: "FORMAL", INFORMAL: "INFORMAL" }, + InequalityCondition: { LOW: "LOW", HIGH: "HIGH" }, + RoundStatus: { PENDING: "PENDING", ACTIVE: "ACTIVE", RESOLVED: "RESOLVED" }, + SeasonStatus: { PENDING: "PENDING", ACTIVE: "ACTIVE", COMPLETED: "COMPLETED", ARCHIVED: "ARCHIVED" }, + ServerEventType: { + TAX_CHANGE: "TAX_CHANGE", + FORMAL_CONTRACT_FEE_CHANGE: "FORMAL_CONTRACT_FEE_CHANGE", + SHOCK_PROBABILITY_CHANGE: "SHOCK_PROBABILITY_CHANGE", + RESOURCE_SHOCK: "RESOURCE_SHOCK", + INFO: "INFO", + }, + ServerStatus: { DRAFT: "DRAFT", WAITING: "WAITING", ACTIVE: "ACTIVE", COMPLETED: "COMPLETED", ARCHIVED: "ARCHIVED" }, + TreasuryTransactionType: { + CONTRIBUTION: "CONTRIBUTION", + TAX: "TAX", + FEE: "FEE", + FINE: "FINE", + PUBLIC_SPENDING: "PUBLIC_SPENDING", + PAYOUT: "PAYOUT", + ADJUSTMENT: "ADJUSTMENT", + }, + UncertaintyCondition: { STABLE: "STABLE", UNCERTAIN: "UNCERTAIN" }, + prisma: prismaMock, +})); + +const { buildResearchExportZip } = await import("./researchExport"); + +const decimal = (value: number) => ({ toNumber: () => value }); +const date = new Date("2026-05-10T12:00:00.000Z"); + +const readUint16 = (bytes: Uint8Array, offset: number) => + new DataView(bytes.buffer, bytes.byteOffset + offset, 2).getUint16(0, true); +const readUint32 = (bytes: Uint8Array, offset: number) => + new DataView(bytes.buffer, bytes.byteOffset + offset, 4).getUint32(0, true); + +const unzipStoredFiles = (bytes: Uint8Array): Record => { + const decoder = new TextDecoder(); + const files: Record = {}; + let offset = 0; + + while (readUint32(bytes, offset) === 0x04034b50) { + const compressedSize = readUint32(bytes, offset + 18); + const filenameLength = readUint16(bytes, offset + 26); + const extraLength = readUint16(bytes, offset + 28); + const filenameStart = offset + 30; + const dataStart = filenameStart + filenameLength + extraLength; + const filename = decoder.decode(bytes.slice(filenameStart, filenameStart + filenameLength)); + files[filename] = decoder.decode(bytes.slice(dataStart, dataStart + compressedSize)); + offset = dataStart + compressedSize; + } + + expect(readUint32(bytes, offset)).toBe(0x02014b50); + expect(readUint32(bytes, bytes.length - 22)).toBe(0x06054b50); + return files; +}; + +const serverFixture = () => ({ + id: "server-1", + inequalityCondition: "LOW", + uncertaintyCondition: "STABLE", + randomSeed: "seed-1", + config: { + mapWidth: 1, + mapHeight: 1, + adminEmail: "researcher@example.org", + adminPassword: "super-secret", + allowedIpAddress: "203.0.113.9", + authToken: "token-123", + }, + createdAt: date, + updatedAt: date, + treasury: decimal(7), + players: [ + { + id: "player-1", + serverId: "server-1", + parcel: { quality: decimal(0.7) }, + wealth: decimal(105), + productiveCapital: decimal(2), + safeAsset: decimal(4), + exited: false, + roundExited: null, + createdAt: date, + }, + ], + parcels: [ + { + id: "parcel-1", + serverId: "server-1", + x: 0, + y: 0, + soil: decimal(0.8), + water: decimal(0.7), + marketAccess: decimal(0.6), + risk: decimal(0.1), + quality: decimal(0.7), + ownerId: "player-1", + }, + ], + decisions: [ + { + id: "decision-1", + serverId: "server-1", + playerId: "player-1", + roundNumber: 1, + actionType: "PUBLIC_CONTRIBUTION", + amount: decimal(5), + targetPlayerId: null, + createdAt: date, + }, + ], + contracts: [], + events: [], + treasuryTransactions: [ + { + id: "transaction-1", + serverId: "server-1", + playerId: "player-1", + roundNumber: 1, + type: "CONTRIBUTION", + amount: decimal(5), + description: "Public contribution", + createdAt: date, + }, + ], + playerRoundStates: [ + { + roundNumber: 1, + playerId: "player-1", + serverId: "server-1", + state: { roundSummary: { totalOutput: 0 } }, + }, + ], + serverConfigs: [ + { + key: "runtime", + value: { formalFixedFee: 2, password: "hidden", nested: { ip: "198.51.100.4" } }, + createdAt: date, + updatedAt: date, + }, + ], +}); + +describe("research export zip", () => { + it("builds a valid ZIP with expected CSV files", async () => { + prismaMock.server.findMany.mockResolvedValue([serverFixture()]); + + const zip = await buildResearchExportZip({ type: "server", serverId: "server-1" }); + const files = unzipStoredFiles(zip); + + expect(Object.keys(files).sort()).toEqual([ + "contracts.csv", + "decisions.csv", + "parcels.csv", + "players.csv", + "round_outcomes.csv", + "server_configs.csv", + "server_events.csv", + "server_summary.csv", + "treasury_transactions.csv", + ].sort()); + expect(files["players.csv"]).toContain("player_id,server_id"); + expect(files["decisions.csv"]).toContain("PUBLIC_CONTRIBUTION"); + expect(files["treasury_transactions.csv"]).toContain("CONTRIBUTION"); + }); + + it("does not export emails, passwords, IP addresses, or auth credentials", async () => { + prismaMock.server.findMany.mockResolvedValue([serverFixture()]); + + const zip = await buildResearchExportZip({ type: "server", serverId: "server-1" }); + const exportedText = Object.values(unzipStoredFiles(zip)).join("\n"); + + expect(exportedText).not.toContain("researcher@example.org"); + expect(exportedText).not.toContain("super-secret"); + expect(exportedText).not.toContain("203.0.113.9"); + expect(exportedText).not.toContain("198.51.100.4"); + expect(exportedText).not.toContain("token-123"); + }); +}); diff --git a/apps/web/lib/services/researchExport.ts b/apps/web/lib/services/researchExport.ts index 2974bc8..ac7f4fa 100644 --- a/apps/web/lib/services/researchExport.ts +++ b/apps/web/lib/services/researchExport.ts @@ -22,6 +22,25 @@ const jsonObject = (value: Prisma.JsonValue): Record => ? (value as Record) : {}; + +const SENSITIVE_CONFIG_KEY_PATTERN = /(email|password|secret|token|credential|authorization|auth|ipAddress|ip_address|ip)/i; + +const sanitizeForExport = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(sanitizeForExport); + } + + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record) + .filter(([key]) => !SENSITIVE_CONFIG_KEY_PATTERN.test(key)) + .map(([key, entry]) => [key, sanitizeForExport(entry)]), + ); + } + + return value; +}; + const csvEscape = (value: unknown): string => { if (value === null || value === undefined) return ""; const text = value instanceof Date ? value.toISOString() : typeof value === "object" ? JSON.stringify(value) : String(value); @@ -268,7 +287,7 @@ export const buildResearchExportZip = async (scope: ExportScope): Promise + new NextResponse("Admin credentials are required.", { + status: 401, + headers: { + "WWW-Authenticate": `Basic realm="${REALM}", charset="UTF-8"`, + "Cache-Control": "no-store", + }, + }); + +const serverError = () => + new NextResponse("Admin authentication is not configured.", { + status: 500, + headers: { "Cache-Control": "no-store" }, + }); + +const parseBasicAuth = (header: string | null) => { + if (!header?.startsWith("Basic ")) return null; + + try { + const decoded = atob(header.slice("Basic ".length)); + const separator = decoded.indexOf(":"); + if (separator === -1) return null; + return { + email: decoded.slice(0, separator), + password: decoded.slice(separator + 1), + }; + } catch { + return null; + } +}; + +const constantTimeEqual = (left: string, right: string) => { + const encoder = new TextEncoder(); + const leftBytes = encoder.encode(left); + const rightBytes = encoder.encode(right); + const length = Math.max(leftBytes.length, rightBytes.length); + let diff = leftBytes.length ^ rightBytes.length; + + for (let index = 0; index < length; index += 1) { + diff |= (leftBytes[index] ?? 0) ^ (rightBytes[index] ?? 0); + } + + return diff === 0; +}; + +export function middleware(request: NextRequest) { + const adminEmail = process.env.ADMIN_EMAIL; + const adminPassword = process.env.ADMIN_PASSWORD; + + if (!adminEmail || !adminPassword) { + return serverError(); + } + + const credentials = parseBasicAuth(request.headers.get("authorization")); + if ( + !credentials || + !constantTimeEqual(credentials.email, adminEmail) || + !constantTimeEqual(credentials.password, adminPassword) + ) { + return unauthorized(); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ["/admin", "/admin/:path*", "/api/admin", "/api/admin/:path*"], +}; diff --git a/configs/highineq-stable.json b/configs/highineq-stable.json index 87089fa..0eddc2c 100644 --- a/configs/highineq-stable.json +++ b/configs/highineq-stable.json @@ -26,8 +26,8 @@ "productiveInvestmentDepreciation": 0.0 }, "contracts": { - "formalFeeRate": 0.08, - "informalFeeRate": 0.02, + "formalFixedFee": 2, + "informalFixedFee": 0.5, "formalDefaultRisk": 0.02, "informalDefaultRisk": 0.15 }, diff --git a/configs/highineq-uncertain.json b/configs/highineq-uncertain.json index 36fd8b6..11440a4 100644 --- a/configs/highineq-uncertain.json +++ b/configs/highineq-uncertain.json @@ -26,8 +26,8 @@ "productiveInvestmentDepreciation": 0.0 }, "contracts": { - "formalFeeRate": 0.08, - "informalFeeRate": 0.02, + "formalFixedFee": 2, + "informalFixedFee": 0.5, "formalDefaultRisk": 0.02, "informalDefaultRisk": 0.15 }, diff --git a/configs/lowineq-stable.json b/configs/lowineq-stable.json index 55465e2..39d3bd6 100644 --- a/configs/lowineq-stable.json +++ b/configs/lowineq-stable.json @@ -26,8 +26,8 @@ "productiveInvestmentDepreciation": 0.0 }, "contracts": { - "formalFeeRate": 0.08, - "informalFeeRate": 0.02, + "formalFixedFee": 2, + "informalFixedFee": 0.5, "formalDefaultRisk": 0.02, "informalDefaultRisk": 0.15 }, diff --git a/configs/lowineq-uncertain.json b/configs/lowineq-uncertain.json index 614d902..dffce7c 100644 --- a/configs/lowineq-uncertain.json +++ b/configs/lowineq-uncertain.json @@ -26,8 +26,8 @@ "productiveInvestmentDepreciation": 0.0 }, "contracts": { - "formalFeeRate": 0.08, - "informalFeeRate": 0.02, + "formalFixedFee": 2, + "informalFixedFee": 0.5, "formalDefaultRisk": 0.02, "informalDefaultRisk": 0.15 }, diff --git a/configs/pilot-8servers.json b/configs/pilot-8servers.json index d067231..aef9c3d 100644 --- a/configs/pilot-8servers.json +++ b/configs/pilot-8servers.json @@ -26,8 +26,8 @@ "productiveInvestmentDepreciation": 0.0 }, "contracts": { - "formalFeeRate": 0.08, - "informalFeeRate": 0.02, + "formalFixedFee": 2, + "informalFixedFee": 0.5, "formalDefaultRisk": 0.02, "informalDefaultRisk": 0.15 }, diff --git a/docker-compose.yml b/docker-compose.yml index 7ca0f44..1e8d7ee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,34 @@ services: timeout: 5s retries: 5 + migrate: + build: + context: . + dockerfile: apps/web/Dockerfile + target: tools + environment: + DATABASE_URL: postgresql://parcel:parcel_password@postgres:5432/parcel_society?schema=public + depends_on: + postgres: + condition: service_healthy + command: pnpm db:migrate + + seed-demo: + build: + context: . + dockerfile: apps/web/Dockerfile + target: tools + environment: + DATABASE_URL: postgresql://parcel:parcel_password@postgres:5432/parcel_society?schema=public + ADMIN_EMAIL: admin@example.com + ADMIN_PASSWORD: changeme + depends_on: + postgres: + condition: service_healthy + migrate: + condition: service_completed_successfully + command: pnpm seed:demo + web: build: context: . @@ -22,14 +50,18 @@ services: environment: DATABASE_URL: postgresql://parcel:parcel_password@postgres:5432/parcel_society?schema=public APP_SECRET: replace-with-a-long-random-secret - ADMIN_EMAIL: admin@example.org - ADMIN_PASSWORD: replace-with-a-development-password + ADMIN_EMAIL: admin@example.com + ADMIN_PASSWORD: changeme NODE_ENV: production ports: - "3000:3000" depends_on: postgres: condition: service_healthy + migrate: + condition: service_completed_successfully + seed-demo: + condition: service_completed_successfully volumes: postgres-data: diff --git a/docs/deployment.md b/docs/deployment.md index ad1acd6..6476766 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -69,17 +69,13 @@ openssl rand -base64 32 Use the same PostgreSQL password in `POSTGRES_PASSWORD` and `DATABASE_URL`. The production Compose file keeps PostgreSQL on the private Docker network and binds the web app to `127.0.0.1:3000` for reverse proxying. -## Build and start production containers +## Build, migrate, seed, and start production containers ```bash make prod-up ``` -This builds the standalone Next.js image and starts `postgres` and `web` with `restart: unless-stopped`. - -## Run migrations - -Run database schema deployment after the database is healthy: +This builds the standalone Next.js image and starts `postgres` and `web` with `restart: unless-stopped`. Run database schema deployment after PostgreSQL is healthy: ```bash make migrate @@ -87,15 +83,21 @@ make migrate Production deployments use versioned Prisma migrations via `prisma migrate deploy`; do not use `db push` against production data. -## Create the first admin +## Create the first admin or demo data -The application authenticates the environment admin with `ADMIN_EMAIL` and `ADMIN_PASSWORD`. To ensure the admin user/profile exists in the database and to create demo servers, run: +The application authenticates the environment admin with HTTP Basic Auth from `ADMIN_EMAIL` and `ADMIN_PASSWORD`. To ensure the admin user/profile exists in the database, run: ```bash make seed ``` -You can then open `/admin/login`, enter those credentials, and manage servers. Rotate the password after sharing temporary access. +For a demo/pilot environment that needs sample servers, use the demo seed instead: + +```bash +pnpm seed:demo +``` + +You can then open `/admin`; the browser prompts for Basic Auth before rendering admin pages. Rotate the password after sharing temporary access. ## Configure Nginx and HTTPS @@ -138,6 +140,7 @@ A healthy response looks like: { "ok": true, "database": "connected", + "applicationTable": "Server", "timestamp": "2026-05-10T00:00:00.000Z" } ``` diff --git a/docs/game-mechanics.md b/docs/game-mechanics.md index db59733..6bf8bfc 100644 --- a/docs/game-mechanics.md +++ b/docs/game-mechanics.md @@ -32,7 +32,7 @@ The initial action vocabulary is intentionally constrained: - **Produce**: convert current opportunities into output. - **Productive investment**: allocate resources toward future production. - **Safe asset**: protect resources in a lower-risk option. -- **Public contribution**: contribute to the shared treasury or public good. +- **Public contribution**: contribute to the shared treasury. In the MVP this increases the treasury balance only; it does not trigger an automatic same-round payout to players. - **Informal contract**: cooperate without formal enforcement. - **Formal contract**: cooperate with a stronger institutional backing. - **Lobbying**: allocate resources toward rent-seeking or institutional advantage. @@ -45,13 +45,13 @@ Do not expand this action list without a clear research-design reason. 1. The server exposes the current round state. 2. Active players submit decisions subject to action-point and validation rules. 3. Administrators or automated session control resolve the round. -4. The engine applies production, investment, contracts, shocks, treasury updates, and scoring. +4. The engine applies production, investment, contracts, shocks, treasury updates, and scoring. Public contributions are recorded as treasury inflows only. 5. The app stores player-round state, decisions, contracts, events, and treasury transactions. 6. Participants see round summaries and continue until the season ends or they exit. ## Institutions -Stable institutions keep enforcement and shock rules predictable. Uncertain institutions introduce controlled uncertainty while remaining reproducible through explicit configuration and seeds. +Stable institutions keep enforcement and shock rules predictable. Uncertain institutions introduce controlled uncertainty while remaining reproducible through explicit configuration and seeds. Contract fees are modeled as fixed per-contract fees (`formalFixedFee` and `informalFixedFee` in shared server config), not percentage rates. ## Design principles diff --git a/eslint.config.mjs b/eslint.config.mjs index d87e9d2..3950cc8 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,6 +1,56 @@ import js from "@eslint/js"; import tseslint from "typescript-eslint"; +const readonlyGlobals = (...names) => + Object.fromEntries(names.map((name) => [name, "readonly"])); + +const nodeGlobals = readonlyGlobals( + "Buffer", + "__dirname", + "__filename", + "console", + "global", + "process", + "setImmediate", + "clearImmediate", +); + +const webGlobals = readonlyGlobals( + "AbortController", + "Blob", + "BodyInit", + "Crypto", + "Document", + "Element", + "Event", + "File", + "FormData", + "Headers", + "HeadersInit", + "HTMLInputElement", + "MouseEvent", + "Request", + "Response", + "ResponseInit", + "TextDecoder", + "TextEncoder", + "URL", + "URLSearchParams", + "Window", + "atob", + "btoa", + "clearInterval", + "clearTimeout", + "crypto", + "document", + "fetch", + "localStorage", + "navigator", + "setInterval", + "setTimeout", + "window", +); + export default tseslint.config( { ignores: [ @@ -18,7 +68,8 @@ export default tseslint.config( files: ["**/*.{ts,tsx}"], languageOptions: { globals: { - console: "readonly", + ...nodeGlobals, + ...webGlobals, React: "readonly", }, parserOptions: { diff --git a/package.json b/package.json index 28c5d57..2da6682 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "db:migrate": "pnpm --filter @parcel-society/db db:migrate", "db:studio": "pnpm --filter @parcel-society/db db:studio", "seed": "pnpm --filter @parcel-society/db seed", - "seed:demo": "pnpm --filter @parcel-society/db seed:demo" + "seed:demo": "pnpm --filter @parcel-society/db seed:demo", + "db:deploy": "pnpm --filter @parcel-society/db db:deploy" }, "devDependencies": { "@eslint/js": "^9.26.0", diff --git a/packages/db/src/demoSeed.ts b/packages/db/src/demoSeed.ts index c664d6a..2193a10 100644 --- a/packages/db/src/demoSeed.ts +++ b/packages/db/src/demoSeed.ts @@ -91,7 +91,6 @@ const demoConfig = (spec: (typeof demoServerSpecs)[number]): EngineConfig => ({ startingWealth: 100, investmentUnitCost: 10, safeAssetReturn: 0.03, - publicGoodMultiplier: 1.5, lobbyingCost: 5, }); @@ -397,7 +396,6 @@ const resolveSeedRound = async ({ informalDefaultRisk: config.informalDefaultRisk, formalDefaultRisk: config.formalDefaultRisk, safeAssetReturn: config.safeAssetReturn, - publicGoodMultiplier: config.publicGoodMultiplier, investmentUnitCost: config.investmentUnitCost, lobbyingCost: config.lobbyingCost, }, @@ -471,7 +469,6 @@ export async function seedDemo( informalDefaultRisk: config.informalDefaultRisk, shockProbability: config.shockProbability, safeAssetReturn: config.safeAssetReturn, - publicGoodMultiplier: config.publicGoodMultiplier, investmentUnitCost: config.investmentUnitCost, lobbyingCost: config.lobbyingCost, productionA: config.production.A, diff --git a/packages/engine/src/decisions.ts b/packages/engine/src/decisions.ts index 385218a..12bf158 100644 --- a/packages/engine/src/decisions.ts +++ b/packages/engine/src/decisions.ts @@ -139,15 +139,6 @@ export const applyValidDecisions = ({ } } - if (publicContributions > 0) { - const activePlayers = updatedPlayers.filter((player) => !player.exited); - const publicGoodPayout = - (publicContributions * config.publicGoodMultiplier) / - activePlayers.length; - for (const player of activePlayers) { - player.wealth += publicGoodPayout; - } - } return { players: updatedPlayers, diff --git a/packages/engine/src/hardening.test.ts b/packages/engine/src/hardening.test.ts index ddc5eeb..26f8467 100644 --- a/packages/engine/src/hardening.test.ts +++ b/packages/engine/src/hardening.test.ts @@ -19,7 +19,6 @@ const config: EngineConfig = { startingWealth: 100, investmentUnitCost: 10, safeAssetReturn: 0.03, - publicGoodMultiplier: 1.5, lobbyingCost: 5, }; diff --git a/packages/engine/src/index.test.ts b/packages/engine/src/index.test.ts index b12566a..bef2f07 100644 --- a/packages/engine/src/index.test.ts +++ b/packages/engine/src/index.test.ts @@ -150,6 +150,25 @@ describe("engine package", () => { expect(high).toBeGreaterThan(low); }); + it("records public contributions as treasury inflows without same-round payouts", () => { + const players = createInitialPlayers(2, config); + const result = resolveRound({ + server: createInitialServerState(config), + players, + parcels: [], + decisions: [ + { playerId: "player-1", type: "PUBLIC_CONTRIBUTION", amount: 5 }, + ], + config, + seed: "public-contribution-seed", + }); + + expect(result.server.treasury).toBe(5); + expect(result.players.find((player) => player.id === "player-1")?.wealth).toBe(config.startingWealth - 5); + expect(result.players.find((player) => player.id === "player-2")?.wealth).toBe(config.startingWealth); + expect(result.roundSummary.publicContributions).toBe(5); + }); + it("resolves a round into a valid state", () => { const parcels = [ownedParcel()]; const players = createInitialPlayers(2, config).map((player, index) => ({ diff --git a/packages/engine/src/serverSimulator.ts b/packages/engine/src/serverSimulator.ts index 197b5cb..b69fc96 100644 --- a/packages/engine/src/serverSimulator.ts +++ b/packages/engine/src/serverSimulator.ts @@ -36,7 +36,6 @@ export const DEFAULT_ENGINE_CONFIG: EngineConfig = { startingWealth: 100, investmentUnitCost: 10, safeAssetReturn: 0.02, - publicGoodMultiplier: 1.4, lobbyingCost: 5, uncertaintyRuleChangeRounds: DEFAULT_RULE_CHANGE_ROUNDS, uncertaintyPossibleEvents: DEFAULT_RULE_CHANGE_EVENTS, diff --git a/packages/engine/src/types.ts b/packages/engine/src/types.ts index b7c0421..82c3ee0 100644 --- a/packages/engine/src/types.ts +++ b/packages/engine/src/types.ts @@ -33,7 +33,6 @@ export interface EngineConfig { startingWealth: number; investmentUnitCost: number; safeAssetReturn: number; - publicGoodMultiplier: number; lobbyingCost: number; uncertaintyRuleChangeRounds?: readonly number[]; uncertaintyPossibleEvents?: readonly RuleChangeEventType[]; diff --git a/packages/shared/src/serverConfig.ts b/packages/shared/src/serverConfig.ts index 262dc12..fe4d604 100644 --- a/packages/shared/src/serverConfig.ts +++ b/packages/shared/src/serverConfig.ts @@ -52,8 +52,8 @@ export const serverConfigSchema = z .strict(), contracts: z .object({ - formalFeeRate: probability, - informalFeeRate: probability, + formalFixedFee: finiteNonnegative, + informalFixedFee: finiteNonnegative, formalDefaultRisk: probability, informalDefaultRisk: probability, }) @@ -121,8 +121,8 @@ export function serverConfigToEngineOverrides(config: ServerConfig) { productionBetaQ: config.economy.production.betaQ, productionBetaK: config.economy.production.betaK, taxRate: config.economy.taxRate, - formalContractFee: config.contracts.formalFeeRate, - informalContractFee: config.contracts.informalFeeRate, + formalContractFee: config.contracts.formalFixedFee, + informalContractFee: config.contracts.informalFixedFee, formalDefaultRisk: config.contracts.formalDefaultRisk, informalDefaultRisk: config.contracts.informalDefaultRisk, shockProbability: config.shocks.baseProbability,