From 4ee5c1d6a8a3971fc5573a5211065ffa4cf89982 Mon Sep 17 00:00:00 2001 From: Spbd1 <148923621+Spbd1@users.noreply.github.com> Date: Sun, 10 May 2026 08:40:54 +0000 Subject: [PATCH] Harden MVP deployment paths --- .github/workflows/ci.yml | 30 ++++++ apps/web/Dockerfile | 3 + .../web/app/api/admin/export/all.zip/route.ts | 10 +- .../admin/servers/[serverId]/archive/route.ts | 10 +- .../servers/[serverId]/export.zip/route.ts | 8 +- .../admin/servers/[serverId]/export/route.ts | 8 +- .../servers/[serverId]/generate-map/route.ts | 6 +- .../servers/[serverId]/resolve-round/route.ts | 9 +- .../app/api/admin/servers/[serverId]/route.ts | 10 +- .../admin/servers/[serverId]/start/route.ts | 9 +- apps/web/app/api/admin/servers/route.ts | 8 +- apps/web/app/api/join-server/route.ts | 2 + .../api/servers/[serverId]/decisions/route.ts | 2 + .../app/api/servers/[serverId]/state/route.ts | 2 +- apps/web/app/components/api.ts | 27 +++++- apps/web/lib/api/audit.ts | 35 +++++++ apps/web/lib/api/auth.test.ts | 69 ++++++++++++++ apps/web/lib/api/auth.ts | 10 ++ apps/web/lib/api/hardening.test.ts | 37 ++++++++ apps/web/lib/api/rateLimit.ts | 41 +++++++++ apps/web/lib/api/responses.ts | 11 ++- apps/web/lib/api/schemas.ts | 18 ++-- apps/web/lib/services/game.ts | 13 ++- apps/web/next.config.ts | 2 +- packages/engine/src/hardening.test.ts | 92 +++++++++++++++++++ 25 files changed, 433 insertions(+), 39 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 apps/web/lib/api/audit.ts create mode 100644 apps/web/lib/api/auth.test.ts create mode 100644 apps/web/lib/api/hardening.test.ts create mode 100644 apps/web/lib/api/rateLimit.ts create mode 100644 packages/engine/src/hardening.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..51556c1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + push: + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10.28.1 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - name: Install + run: pnpm install --frozen-lockfile=false + - name: Generate Prisma client + run: pnpm --filter @parcel-society/db db:generate + - name: Typecheck + run: pnpm typecheck + - name: Lint + run: pnpm lint + - name: Test + run: pnpm test + - name: Build + run: pnpm build diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 8ee7c26..baaa598 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -7,11 +7,14 @@ WORKDIR /app FROM base AS deps COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml ./ COPY apps/web/package.json apps/web/package.json +COPY packages/db/package.json packages/db/package.json +COPY packages/engine/package.json packages/engine/package.json COPY packages/shared/package.json packages/shared/package.json RUN pnpm install --frozen-lockfile=false FROM deps AS builder COPY . . +RUN pnpm --filter @parcel-society/db db:generate RUN pnpm --filter @parcel-society/web build FROM node:22-alpine AS runner diff --git a/apps/web/app/api/admin/export/all.zip/route.ts b/apps/web/app/api/admin/export/all.zip/route.ts index fe59d21..b84655b 100644 --- a/apps/web/app/api/admin/export/all.zip/route.ts +++ b/apps/web/app/api/admin/export/all.zip/route.ts @@ -1,11 +1,15 @@ import { buildResearchExportZip } from "../../../../../lib/services/researchExport"; -import { requireAdminAuth } from "../../../../../lib/api/auth"; +import { requireSuperAdminAuth } from "../../../../../lib/api/auth"; import { handleApiError } from "../../../../../lib/api/responses"; +import { rateLimit } from "../../../../../lib/api/rateLimit"; +import { recordAdminAction } from "../../../../../lib/api/audit"; export async function GET(request: Request) { try { - await requireAdminAuth(request); + rateLimit({ request, key: "global-export-zip", limit: 3, windowMs: 60_000 }); + const auth = await requireSuperAdminAuth(request); const zip = await buildResearchExportZip({ type: "all" }); + await recordAdminAction({ auth, action: "EXPORT_DATA", entityType: "global", entityId: "all", after: { format: "zip" } }); return new Response(zip as BodyInit, { headers: { @@ -15,6 +19,6 @@ export async function GET(request: Request) { }, }); } catch (error) { - return handleApiError(error); + return handleApiError(error, { route: "GET /api/admin/export/all.zip" }); } } diff --git a/apps/web/app/api/admin/servers/[serverId]/archive/route.ts b/apps/web/app/api/admin/servers/[serverId]/archive/route.ts index b3ad966..adab0fd 100644 --- a/apps/web/app/api/admin/servers/[serverId]/archive/route.ts +++ b/apps/web/app/api/admin/servers/[serverId]/archive/route.ts @@ -1,15 +1,19 @@ import { prisma, ServerStatus } from "@parcel-society/db"; import { requireAdminAuth } from "../../../../../../lib/api/auth"; -import { apiOk, handleApiError } from "../../../../../../lib/api/responses"; +import { ApiException, apiOk, handleApiError } from "../../../../../../lib/api/responses"; import { serverIdParamsSchema } from "../../../../../../lib/api/schemas"; +import { recordAdminAction } from "../../../../../../lib/api/audit"; export async function POST(request: Request, context: { params: Promise<{ serverId: string }> }) { try { - await requireAdminAuth(request); + const auth = await requireAdminAuth(request); const { serverId } = serverIdParamsSchema.parse(await context.params); + const before = await prisma.server.findUnique({ where: { id: serverId } }); + if (!before) throw new ApiException(404, "SERVER_NOT_FOUND", "Server was not found."); const server = await prisma.server.update({ where: { id: serverId }, data: { status: ServerStatus.ARCHIVED } }); + await recordAdminAction({ auth, action: "ARCHIVE_SERVER", entityType: "server", entityId: server.id, before, after: server }); return apiOk({ server }); } catch (error) { - return handleApiError(error); + return handleApiError(error, { route: "POST /api/admin/servers/[serverId]/archive" }); } } diff --git a/apps/web/app/api/admin/servers/[serverId]/export.zip/route.ts b/apps/web/app/api/admin/servers/[serverId]/export.zip/route.ts index aa09979..6b8fa30 100644 --- a/apps/web/app/api/admin/servers/[serverId]/export.zip/route.ts +++ b/apps/web/app/api/admin/servers/[serverId]/export.zip/route.ts @@ -2,12 +2,16 @@ import { buildResearchExportZip } from "../../../../../../lib/services/researchE import { requireAdminAuth } from "../../../../../../lib/api/auth"; import { handleApiError } from "../../../../../../lib/api/responses"; import { serverIdParamsSchema } from "../../../../../../lib/api/schemas"; +import { rateLimit } from "../../../../../../lib/api/rateLimit"; +import { recordAdminAction } from "../../../../../../lib/api/audit"; export async function GET(request: Request, context: { params: Promise<{ serverId: string }> }) { try { - await requireAdminAuth(request); + rateLimit({ request, key: "server-export-zip", limit: 6, windowMs: 60_000 }); + const auth = await requireAdminAuth(request); const { serverId } = serverIdParamsSchema.parse(await context.params); const zip = await buildResearchExportZip({ type: "server", serverId }); + await recordAdminAction({ auth, action: "EXPORT_DATA", entityType: "server", entityId: serverId, after: { format: "zip" } }); return new Response(zip as BodyInit, { headers: { @@ -17,6 +21,6 @@ export async function GET(request: Request, context: { params: Promise<{ serverI }, }); } catch (error) { - return handleApiError(error); + return handleApiError(error, { route: "GET /api/admin/servers/[serverId]/export.zip" }); } } diff --git a/apps/web/app/api/admin/servers/[serverId]/export/route.ts b/apps/web/app/api/admin/servers/[serverId]/export/route.ts index 77722a3..220a122 100644 --- a/apps/web/app/api/admin/servers/[serverId]/export/route.ts +++ b/apps/web/app/api/admin/servers/[serverId]/export/route.ts @@ -2,6 +2,8 @@ import { prisma } from "@parcel-society/db"; import { requireAdminAuth } from "../../../../../../lib/api/auth"; import { handleApiError } from "../../../../../../lib/api/responses"; import { serverIdParamsSchema } from "../../../../../../lib/api/schemas"; +import { rateLimit } from "../../../../../../lib/api/rateLimit"; +import { recordAdminAction } from "../../../../../../lib/api/audit"; const csvEscape = (value: unknown): string => { if (value === null || value === undefined) return ""; @@ -23,7 +25,8 @@ const tableToCsv = (rows: object[]): string => { export async function GET(request: Request, context: { params: Promise<{ serverId: string }> }) { try { - await requireAdminAuth(request); + rateLimit({ request, key: "server-export", limit: 12, windowMs: 60_000 }); + const auth = await requireAdminAuth(request); const { serverId } = serverIdParamsSchema.parse(await context.params); const [players, parcels, decisions, contracts, serverEvents, treasuryTransactions, roundOutcomes] = await Promise.all([ prisma.player.findMany({ where: { serverId }, orderBy: { createdAt: "asc" } }), @@ -45,8 +48,9 @@ export async function GET(request: Request, context: { params: Promise<{ serverI round_outcomes: tableToCsv(roundOutcomes), }; + await recordAdminAction({ auth, action: "EXPORT_DATA", entityType: "server", entityId: serverId, after: { format: "json-csv", files: Object.keys(files) } }); return Response.json({ ok: true, data: { files } }); } catch (error) { - return handleApiError(error); + return handleApiError(error, { route: "GET /api/admin/servers/[serverId]/export" }); } } diff --git a/apps/web/app/api/admin/servers/[serverId]/generate-map/route.ts b/apps/web/app/api/admin/servers/[serverId]/generate-map/route.ts index e8ad6c9..c5d132f 100644 --- a/apps/web/app/api/admin/servers/[serverId]/generate-map/route.ts +++ b/apps/web/app/api/admin/servers/[serverId]/generate-map/route.ts @@ -2,15 +2,17 @@ import { requireAdminAuth } from "../../../../../../lib/api/auth"; import { apiOk, handleApiError } from "../../../../../../lib/api/responses"; import { generateMapSchema, serverIdParamsSchema } from "../../../../../../lib/api/schemas"; import { createServerMap } from "../../../../../../lib/services/game"; +import { recordAdminAction } from "../../../../../../lib/api/audit"; export async function POST(request: Request, context: { params: Promise<{ serverId: string }> }) { try { - await requireAdminAuth(request); + const auth = await requireAdminAuth(request); const { serverId } = serverIdParamsSchema.parse(await context.params); const body = generateMapSchema.parse(await request.json().catch(() => ({}))); const result = await createServerMap({ serverId, width: body.width, height: body.height }); + await recordAdminAction({ auth, action: "GENERATE_MAP", entityType: "server", entityId: serverId, after: { width: body.width, height: body.height, parcelCount: result.count } }); return apiOk(result); } catch (error) { - return handleApiError(error); + return handleApiError(error, { route: "POST /api/admin/servers/[serverId]/generate-map" }); } } diff --git a/apps/web/app/api/admin/servers/[serverId]/resolve-round/route.ts b/apps/web/app/api/admin/servers/[serverId]/resolve-round/route.ts index 12f269f..c449c8c 100644 --- a/apps/web/app/api/admin/servers/[serverId]/resolve-round/route.ts +++ b/apps/web/app/api/admin/servers/[serverId]/resolve-round/route.ts @@ -2,13 +2,16 @@ import { requireAdminAuth } from "../../../../../../lib/api/auth"; import { apiOk, handleApiError } from "../../../../../../lib/api/responses"; import { serverIdParamsSchema } from "../../../../../../lib/api/schemas"; import { resolveActiveRound } from "../../../../../../lib/services/game"; +import { recordAdminAction } from "../../../../../../lib/api/audit"; export async function POST(request: Request, context: { params: Promise<{ serverId: string }> }) { try { - await requireAdminAuth(request); + const auth = await requireAdminAuth(request); const { serverId } = serverIdParamsSchema.parse(await context.params); - return apiOk({ summary: await resolveActiveRound(serverId) }); + const summary = await resolveActiveRound(serverId); + await recordAdminAction({ auth, action: "RESOLVE_ROUND", entityType: "server", entityId: serverId, after: summary }); + return apiOk({ summary }); } catch (error) { - return handleApiError(error); + return handleApiError(error, { route: "POST /api/admin/servers/[serverId]/resolve-round" }); } } diff --git a/apps/web/app/api/admin/servers/[serverId]/route.ts b/apps/web/app/api/admin/servers/[serverId]/route.ts index f7f4773..df87d9a 100644 --- a/apps/web/app/api/admin/servers/[serverId]/route.ts +++ b/apps/web/app/api/admin/servers/[serverId]/route.ts @@ -2,6 +2,7 @@ import { prisma } from "@parcel-society/db"; import { requireAdminAuth } from "../../../../../lib/api/auth"; import { ApiException, apiOk, handleApiError } from "../../../../../lib/api/responses"; import { serverIdParamsSchema, updateServerSchema } from "../../../../../lib/api/schemas"; +import { recordAdminAction } from "../../../../../lib/api/audit"; export async function GET(request: Request, context: { params: Promise<{ serverId: string }> }) { try { @@ -20,18 +21,21 @@ export async function GET(request: Request, context: { params: Promise<{ serverI if (!server) throw new ApiException(404, "SERVER_NOT_FOUND", "Server was not found."); return apiOk({ server }); } catch (error) { - return handleApiError(error); + return handleApiError(error, { route: "GET /api/admin/servers/[serverId]" }); } } export async function PATCH(request: Request, context: { params: Promise<{ serverId: string }> }) { try { - await requireAdminAuth(request); + const auth = await requireAdminAuth(request); const { serverId } = serverIdParamsSchema.parse(await context.params); const body = updateServerSchema.parse(await request.json()); + const before = await prisma.server.findUnique({ where: { id: serverId } }); + if (!before) throw new ApiException(404, "SERVER_NOT_FOUND", "Server was not found."); const server = await prisma.server.update({ where: { id: serverId }, data: body }); + await recordAdminAction({ auth, action: "UPDATE_SERVER_CONFIG", entityType: "server", entityId: server.id, before, after: server }); return apiOk({ server }); } catch (error) { - return handleApiError(error); + return handleApiError(error, { route: "PATCH /api/admin/servers/[serverId]" }); } } diff --git a/apps/web/app/api/admin/servers/[serverId]/start/route.ts b/apps/web/app/api/admin/servers/[serverId]/start/route.ts index f12c2ba..be382f4 100644 --- a/apps/web/app/api/admin/servers/[serverId]/start/route.ts +++ b/apps/web/app/api/admin/servers/[serverId]/start/route.ts @@ -2,13 +2,16 @@ import { requireAdminAuth } from "../../../../../../lib/api/auth"; import { apiOk, handleApiError } from "../../../../../../lib/api/responses"; import { serverIdParamsSchema } from "../../../../../../lib/api/schemas"; import { startServer } from "../../../../../../lib/services/game"; +import { recordAdminAction } from "../../../../../../lib/api/audit"; export async function POST(request: Request, context: { params: Promise<{ serverId: string }> }) { try { - await requireAdminAuth(request); + const auth = await requireAdminAuth(request); const { serverId } = serverIdParamsSchema.parse(await context.params); - return apiOk(await startServer(serverId)); + const result = await startServer(serverId); + await recordAdminAction({ auth, action: "START_SERVER", entityType: "server", entityId: serverId, after: result }); + return apiOk(result); } catch (error) { - return handleApiError(error); + return handleApiError(error, { route: "POST /api/admin/servers/[serverId]/start" }); } } diff --git a/apps/web/app/api/admin/servers/route.ts b/apps/web/app/api/admin/servers/route.ts index cdcb624..d60be22 100644 --- a/apps/web/app/api/admin/servers/route.ts +++ b/apps/web/app/api/admin/servers/route.ts @@ -3,6 +3,7 @@ import { prisma, ServerStatus } from "@parcel-society/db"; import { requireAdminAuth } from "../../../../lib/api/auth"; import { apiOk, handleApiError } from "../../../../lib/api/responses"; import { createServerSchema } from "../../../../lib/api/schemas"; +import { recordAdminAction } from "../../../../lib/api/audit"; export async function GET(request: Request) { try { @@ -13,13 +14,13 @@ export async function GET(request: Request) { }); return apiOk({ servers }); } catch (error) { - return handleApiError(error); + return handleApiError(error, { route: "GET /api/admin/servers" }); } } export async function POST(request: Request) { try { - await requireAdminAuth(request); + const auth = await requireAdminAuth(request); const body = createServerSchema.parse(await request.json()); const server = await prisma.server.create({ data: { @@ -28,8 +29,9 @@ export async function POST(request: Request) { status: ServerStatus.DRAFT, }, }); + await recordAdminAction({ auth, action: "CREATE_SERVER", entityType: "server", entityId: server.id, after: server }); return apiOk({ server }, { status: 201 }); } catch (error) { - return handleApiError(error); + return handleApiError(error, { route: "POST /api/admin/servers" }); } } diff --git a/apps/web/app/api/join-server/route.ts b/apps/web/app/api/join-server/route.ts index 7472eac..24a78d8 100644 --- a/apps/web/app/api/join-server/route.ts +++ b/apps/web/app/api/join-server/route.ts @@ -3,9 +3,11 @@ import { joinServerSchema } from "../../../lib/api/schemas"; import { ApiException, apiOk, handleApiError } from "../../../lib/api/responses"; import { applyAuthCookie, getParticipantAuth } from "../../../lib/api/auth"; import { joinWaitingServer } from "../../../lib/services/game"; +import { rateLimit } from "../../../lib/api/rateLimit"; export async function POST(request: Request) { try { + rateLimit({ request, key: "join-server", limit: 10, windowMs: 60_000 }); const auth = await getParticipantAuth(); const body = joinServerSchema.parse(await request.json()); const passedCheck = await prisma.comprehensionCheck.findFirst({ diff --git a/apps/web/app/api/servers/[serverId]/decisions/route.ts b/apps/web/app/api/servers/[serverId]/decisions/route.ts index 4a06955..616cd76 100644 --- a/apps/web/app/api/servers/[serverId]/decisions/route.ts +++ b/apps/web/app/api/servers/[serverId]/decisions/route.ts @@ -3,9 +3,11 @@ import { applyAuthCookie, getParticipantAuth } from "../../../../../lib/api/auth import { apiOk, handleApiError } from "../../../../../lib/api/responses"; import { serverIdParamsSchema, submitDecisionsSchema } from "../../../../../lib/api/schemas"; import { submitPlayerDecisions } from "../../../../../lib/services/game"; +import { rateLimit } from "../../../../../lib/api/rateLimit"; export async function POST(request: Request, context: { params: Promise<{ serverId: string }> }) { try { + rateLimit({ request, key: "decision-submission", limit: 30, windowMs: 60_000 }); const { serverId } = serverIdParamsSchema.parse(await context.params); const auth = await getParticipantAuth(); const body = submitDecisionsSchema.parse(await request.json()); diff --git a/apps/web/app/api/servers/[serverId]/state/route.ts b/apps/web/app/api/servers/[serverId]/state/route.ts index f1f82c6..d08915a 100644 --- a/apps/web/app/api/servers/[serverId]/state/route.ts +++ b/apps/web/app/api/servers/[serverId]/state/route.ts @@ -13,7 +13,7 @@ export async function GET(_request: Request, context: { params: Promise<{ server include: { parcels: true, rounds: { orderBy: { roundNumber: "desc" }, take: 1 }, - players: { select: { id: true, parcelId: true, wealth: true, reputation: true, exited: true } }, + players: { select: { id: true, parcelId: true, exited: true } }, }, }); const roundNumber = server?.rounds[0]?.roundNumber ?? server?.currentRound ?? 0; diff --git a/apps/web/app/components/api.ts b/apps/web/app/components/api.ts index 7e05b3a..547f8f7 100644 --- a/apps/web/app/components/api.ts +++ b/apps/web/app/components/api.ts @@ -2,6 +2,31 @@ export type ApiResponse = | { ok: true; data: T } | { ok: false; error: { code: string; message: string; details?: unknown } }; +const fallbackMessages: Record = { + VALIDATION_ERROR: "Check your entries and try again.", + RATE_LIMITED: "Too many attempts. Please wait a minute and try again.", + UNAUTHORIZED: "Admin credentials are required or incorrect.", + FORBIDDEN: "You do not have access to that data.", + ROUND_NOT_ACTIVE: "This round is not active right now.", + PLAYER_EXITED: "You have exited and cannot submit more actions.", + INSUFFICIENT_WEALTH: "You do not have enough wealth for those actions.", + INVALID_DECISIONS: "One or more selected actions are not allowed.", + PLAYER_ALREADY_JOINED: "You have already joined this server.", + SERVER_NOT_WAITING: "This server is not open for new players.", + SERVER_FULL: "This server is full.", +}; + +export class ApiClientError extends Error { + readonly code: string; + readonly details?: unknown; + + constructor(error: { code: string; message: string; details?: unknown }) { + super(fallbackMessages[error.code] ?? error.message ?? "Request failed."); + this.code = error.code; + this.details = error.details; + } +} + export const requestJson = async (path: string, init?: RequestInit): Promise => { const response = await fetch(path, { ...init, @@ -12,7 +37,7 @@ export const requestJson = async (path: string, init?: RequestInit): Promise< }); const payload = (await response.json()) as ApiResponse; if (!payload.ok) { - throw new Error(payload.error.message); + throw new ApiClientError(payload.error); } return payload.data; }; diff --git a/apps/web/lib/api/audit.ts b/apps/web/lib/api/audit.ts new file mode 100644 index 0000000..5177c98 --- /dev/null +++ b/apps/web/lib/api/audit.ts @@ -0,0 +1,35 @@ +import { prisma, type Prisma } from "@parcel-society/db"; +import type { AuthContext } from "./auth"; + +const toJson = (value: unknown): Prisma.InputJsonValue | undefined => { + if (value === undefined || value === null) return undefined; + return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue; +}; + +export const recordAdminAction = async ({ + auth, + action, + entityType, + entityId, + before, + after, +}: { + auth: AuthContext; + action: string; + entityType: string; + entityId: string; + before?: unknown; + after?: unknown; +}) => { + const admin = await prisma.adminUser.findUnique({ where: { userId: auth.user.id } }); + await prisma.auditLog.create({ + data: { + adminId: admin?.id, + action, + entityType, + entityId, + before: toJson(before), + after: toJson(after), + }, + }); +}; diff --git a/apps/web/lib/api/auth.test.ts b/apps/web/lib/api/auth.test.ts new file mode 100644 index 0000000..051661d --- /dev/null +++ b/apps/web/lib/api/auth.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const prismaMock = vi.hoisted(() => ({ + user: { + findUnique: vi.fn(), + create: vi.fn(), + upsert: vi.fn(), + }, + adminUser: { + upsert: vi.fn(), + }, + player: { + findUnique: vi.fn(), + }, +})); + +vi.mock("next/headers", () => ({ cookies: vi.fn() })); +vi.mock("@parcel-society/db", () => ({ + UserRole: { PARTICIPANT: "PARTICIPANT", ADMIN: "ADMIN", SUPER_ADMIN: "SUPER_ADMIN" }, + prisma: prismaMock, +})); + +const { requireAdminAuth, requireSuperAdminAuth, assertParticipantOnServer } = await import("./auth"); + +const basic = (email: string, password: string, ip: string) => + new Request("https://example.test/api/admin", { + headers: { + authorization: `Basic ${Buffer.from(`${email}:${password}`).toString("base64")}`, + "x-forwarded-for": ip, + }, + }); + +describe("admin authorization", () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.ADMIN_EMAIL = "admin@example.org"; + process.env.ADMIN_PASSWORD = "secret"; + prismaMock.user.upsert.mockResolvedValue({ + id: "admin-user", + anonymousId: "anon", + email: "admin@example.org", + role: "SUPER_ADMIN", + }); + prismaMock.adminUser.upsert.mockResolvedValue({ id: "admin-profile" }); + }); + + it("rejects missing admin credentials", async () => { + await expect(requireAdminAuth(new Request("https://example.test/api/admin"))).rejects.toMatchObject({ + status: 401, + code: "UNAUTHORIZED", + }); + }); + + it("allows configured super admin credentials", async () => { + await expect(requireSuperAdminAuth(basic("admin@example.org", "secret", "203.0.113.20"))).resolves.toMatchObject({ + user: { role: "SUPER_ADMIN" }, + }); + }); +}); + +describe("participant authorization", () => { + it("protects participant data for non-members", async () => { + prismaMock.player.findUnique.mockResolvedValue(null); + await expect(assertParticipantOnServer("user-1", "server-1")).rejects.toMatchObject({ + status: 403, + code: "FORBIDDEN", + }); + }); +}); diff --git a/apps/web/lib/api/auth.ts b/apps/web/lib/api/auth.ts index 3bb8a53..46273bc 100644 --- a/apps/web/lib/api/auth.ts +++ b/apps/web/lib/api/auth.ts @@ -1,6 +1,7 @@ import { cookies } from "next/headers"; import { prisma, UserRole } from "@parcel-society/db"; import { ApiException } from "./responses"; +import { rateLimit } from "./rateLimit"; const PARTICIPANT_COOKIE = "parcel_society_user_id"; @@ -51,6 +52,7 @@ const parseBasicAuth = (request: Request): { email: string; password: string } | }; export const requireAdminAuth = async (request: Request): Promise => { + rateLimit({ request, key: "admin-login", limit: 20, windowMs: 60_000 }); const credentials = parseBasicAuth(request); const adminEmail = process.env.ADMIN_EMAIL; const adminPassword = process.env.ADMIN_PASSWORD; @@ -85,6 +87,14 @@ export const requireAdminAuth = async (request: Request): Promise = return { user }; }; +export const requireSuperAdminAuth = async (request: Request): Promise => { + const auth = await requireAdminAuth(request); + if (auth.user.role !== UserRole.SUPER_ADMIN) { + throw new ApiException(403, "SUPER_ADMIN_REQUIRED", "Super admin privileges are required."); + } + return auth; +}; + export const assertParticipantOnServer = async (userId: string, serverId: string) => { const player = await prisma.player.findUnique({ where: { userId_serverId: { userId, serverId } }, diff --git a/apps/web/lib/api/hardening.test.ts b/apps/web/lib/api/hardening.test.ts new file mode 100644 index 0000000..32570c9 --- /dev/null +++ b/apps/web/lib/api/hardening.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { ApiException } from "./responses"; +import { rateLimit } from "./rateLimit"; +import { createServerSchema, joinServerSchema, submitDecisionsSchema } from "./schemas"; + +const requestFrom = (ip: string) => new Request("https://example.test/api", { headers: { "x-forwarded-for": ip } }); + +describe("API hardening schemas", () => { + it("rejects duplicate or unknown join payload fields", () => { + expect(() => joinServerSchema.parse({ serverId: "server-1", extra: true })).toThrow(); + }); + + it("limits decision submissions to three action points", () => { + expect(() => + submitDecisionsSchema.parse({ + decisions: [ + { type: "PRODUCE", parcelId: "p1" }, + { type: "SAFE_ASSET", amount: 1 }, + { type: "PUBLIC_CONTRIBUTION", amount: 1 }, + { type: "LOBBYING", amount: 1 }, + ], + }), + ).toThrow(); + }); + + it("rejects malformed server creation inputs", () => { + expect(() => createServerSchema.parse({ name: "", maxPlayers: 0 })).toThrow(); + }); +}); + +describe("rate limiting", () => { + it("throws a consistent API exception after the limit", () => { + const request = requestFrom("203.0.113.10"); + rateLimit({ request, key: "test-login", limit: 1, windowMs: 60_000 }); + expect(() => rateLimit({ request, key: "test-login", limit: 1, windowMs: 60_000 })).toThrow(ApiException); + }); +}); diff --git a/apps/web/lib/api/rateLimit.ts b/apps/web/lib/api/rateLimit.ts new file mode 100644 index 0000000..89bc7a7 --- /dev/null +++ b/apps/web/lib/api/rateLimit.ts @@ -0,0 +1,41 @@ +import { ApiException } from "./responses"; + +type Bucket = { + count: number; + resetAt: number; +}; + +const buckets = new Map(); + +const clientIp = (request: Request): string => { + const forwarded = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim(); + return forwarded || request.headers.get("x-real-ip") || "unknown"; +}; + +export const rateLimit = ({ + request, + key, + limit, + windowMs, +}: { + request: Request; + key: string; + limit: number; + windowMs: number; +}) => { + const now = Date.now(); + const bucketKey = `${key}:${clientIp(request)}`; + const bucket = buckets.get(bucketKey); + + if (!bucket || bucket.resetAt <= now) { + buckets.set(bucketKey, { count: 1, resetAt: now + windowMs }); + return; + } + + bucket.count += 1; + if (bucket.count > limit) { + throw new ApiException(429, "RATE_LIMITED", "Too many requests. Please wait and try again.", { + retryAfterSeconds: Math.ceil((bucket.resetAt - now) / 1000), + }); + } +}; diff --git a/apps/web/lib/api/responses.ts b/apps/web/lib/api/responses.ts index 9c8a8bc..a43b6e1 100644 --- a/apps/web/lib/api/responses.ts +++ b/apps/web/lib/api/responses.ts @@ -35,8 +35,11 @@ export const apiError = ( ): NextResponse> => NextResponse.json({ ok: false, error: { code, message, details } }, { status }); -export const handleApiError = (error: unknown): NextResponse> => { +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); } @@ -44,6 +47,10 @@ export const handleApiError = (error: unknown): NextResponse> return apiError(400, "VALIDATION_ERROR", "Request validation failed.", error.flatten()); } - console.error(error); + if (error instanceof SyntaxError) { + return apiError(400, "INVALID_JSON", "Request body must be valid JSON."); + } + + console.error("Unhandled API error", { error, context }); return apiError(500, "INTERNAL_SERVER_ERROR", "An unexpected error occurred."); }; diff --git a/apps/web/lib/api/schemas.ts b/apps/web/lib/api/schemas.ts index 0b2db90..63c9fca 100644 --- a/apps/web/lib/api/schemas.ts +++ b/apps/web/lib/api/schemas.ts @@ -1,10 +1,10 @@ import { z } from "zod"; -export const serverIdParamsSchema = z.object({ serverId: z.string().min(1) }); +export const serverIdParamsSchema = z.object({ serverId: z.string().min(1) }).strict(); export const joinServerSchema = z.object({ serverId: z.string().min(1), -}); +}).strict(); export const decisionActionSchema = z.object({ type: z.enum([ @@ -21,15 +21,15 @@ export const decisionActionSchema = z.object({ parcelId: z.string().min(1).optional(), counterpartyId: z.string().min(1).optional(), payload: z.record(z.unknown()).default({}), -}); +}).strict(); export const submitDecisionsSchema = z.object({ - decisions: z.array(decisionActionSchema).min(1), -}); + decisions: z.array(decisionActionSchema).min(1).max(3), +}).strict(); export const comprehensionCheckSchema = z.object({ answers: z.array(z.enum(["a", "b", "c"])).length(5), -}); +}).strict(); export const createServerSchema = z.object({ name: z.string().min(1), @@ -40,15 +40,15 @@ export const createServerSchema = z.object({ seasonLength: z.number().int().positive().max(52).default(7), randomSeed: z.string().min(1).optional(), config: z.record(z.unknown()).default({}), -}); +}).strict(); export const updateServerSchema = createServerSchema.partial().extend({ status: z.enum(["DRAFT", "WAITING", "ACTIVE", "COMPLETED", "ARCHIVED"]).optional(), treasury: z.number().finite().nonnegative().optional(), currentRound: z.number().int().nonnegative().optional(), -}); +}).strict(); export const generateMapSchema = z.object({ width: z.number().int().positive().max(100).default(10), height: z.number().int().positive().max(100).default(10), -}); +}).strict(); diff --git a/apps/web/lib/services/game.ts b/apps/web/lib/services/game.ts index 212a681..1d9a760 100644 --- a/apps/web/lib/services/game.ts +++ b/apps/web/lib/services/game.ts @@ -123,7 +123,7 @@ export const joinWaitingServer = async ({ const existing = server.players.find((player) => player.userId === userId); if (existing) { - return existing; + throw new ApiException(409, "PLAYER_ALREADY_JOINED", "You have already joined this server."); } if (server.players.length >= server.maxPlayers) { throw new ApiException(409, "SERVER_FULL", "Server is already full."); @@ -268,10 +268,21 @@ export const submitPlayerDecisions = async ({ throw new ApiException(403, "FORBIDDEN", "You are not a participant on this server."); } + if (player.exited) { + throw new ApiException(409, "PLAYER_EXITED", "Exited players cannot submit decisions."); + } + const activeRound = server.rounds[0]; const existingDecisions = await prisma.decision.findMany({ where: { serverId, roundNumber: activeRound.roundNumber }, }); + if (existingDecisions.some((decision) => decision.playerId === player.id && decision.actionType === ActionType.EXIT)) { + throw new ApiException(409, "PLAYER_EXITED", "Exited players cannot submit more decisions."); + } + const exitIndex = decisions.findIndex((decision) => decision.type === ActionType.EXIT); + if (exitIndex !== -1 && exitIndex !== decisions.length - 1) { + throw new ApiException(400, "INVALID_DECISIONS", "Exit must be the final action in a submission."); + } const config = defaultEngineConfig(server); const candidateDecisions: EngineDecision[] = [ ...existingDecisions.map(engineDecision), diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 8b1aed2..56e9bb5 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -2,7 +2,7 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { output: "standalone", - transpilePackages: ["@parcel-society/shared"], + transpilePackages: ["@parcel-society/shared", "@parcel-society/db", "@parcel-society/engine"], }; export default nextConfig; diff --git a/packages/engine/src/hardening.test.ts b/packages/engine/src/hardening.test.ts new file mode 100644 index 0000000..ddc5eeb --- /dev/null +++ b/packages/engine/src/hardening.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { validateDecisions } from "./validation"; +import type { EngineConfig, Parcel, PlayerState } from "./types"; + +const config: EngineConfig = { + seed: "test", + inequality: "LOW", + uncertainty: "STABLE", + mapWidth: 1, + mapHeight: 1, + actionPointsPerRound: 3, + production: { A: 10, betaQ: 1, betaK: 0.35, minShockMultiplier: 0.4, maxShockMultiplier: 1 }, + taxRate: 0.15, + formalContractFee: 2, + informalContractFee: 0, + informalDefaultRisk: 0.25, + formalDefaultRisk: 0.05, + shockProbability: 0.1, + startingWealth: 100, + investmentUnitCost: 10, + safeAssetReturn: 0.03, + publicGoodMultiplier: 1.5, + lobbyingCost: 5, +}; + +const player = (overrides: Partial = {}): PlayerState => ({ + id: "player-1", + wealth: 10, + productiveCapital: 0, + safeAssets: 0, + reputation: 0, + exited: false, + parcelIds: ["parcel-1"], + actionPointsRemaining: 3, + contributedPublic: 0, + spentOnProductiveInvestment: 0, + spentOnSafeAssets: 0, + spentOnLobbying: 0, + ...overrides, +}); + +const parcel: Parcel = { + id: "parcel-1", + x: 0, + y: 0, + soil: 1, + water: 1, + marketAccess: 1, + risk: 0, + quality: 1, + ownerId: "player-1", +}; + +describe("decision submission constraints", () => { + it("blocks exited players from acting", () => { + const result = validateDecisions({ + players: [player({ exited: true })], + parcels: [parcel], + decisions: [{ playerId: "player-1", type: "PRODUCE", parcelId: "parcel-1" }], + config, + }); + expect(result.ok).toBe(false); + expect(result.errors[0]?.code).toBe("PLAYER_EXITED"); + }); + + it("blocks spending more wealth than the player has", () => { + const result = validateDecisions({ + players: [player({ wealth: 5 })], + parcels: [parcel], + decisions: [{ playerId: "player-1", type: "SAFE_ASSET", amount: 6 }], + config, + }); + expect(result.ok).toBe(false); + expect(result.errors[0]?.code).toBe("INSUFFICIENT_RESOURCES"); + }); + + it("blocks more than three action points", () => { + const result = validateDecisions({ + players: [player()], + parcels: [parcel], + decisions: [ + { playerId: "player-1", type: "PRODUCE", parcelId: "parcel-1" }, + { playerId: "player-1", type: "PRODUCE", parcelId: "parcel-1" }, + { playerId: "player-1", type: "PRODUCE", parcelId: "parcel-1" }, + { playerId: "player-1", type: "PRODUCE", parcelId: "parcel-1" }, + ], + config, + }); + expect(result.ok).toBe(false); + expect(result.errors.at(-1)?.code).toBe("INSUFFICIENT_ACTION_POINTS"); + }); +});