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
30 changes: 30 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions apps/web/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions apps/web/app/api/admin/export/all.zip/route.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand All @@ -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" });
}
}
10 changes: 7 additions & 3 deletions apps/web/app/api/admin/servers/[serverId]/archive/route.ts
Original file line number Diff line number Diff line change
@@ -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" });
}
}
8 changes: 6 additions & 2 deletions apps/web/app/api/admin/servers/[serverId]/export.zip/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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" });
}
}
8 changes: 6 additions & 2 deletions apps/web/app/api/admin/servers/[serverId]/export/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "";
Expand All @@ -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" } }),
Expand All @@ -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" });
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
}
}
10 changes: 7 additions & 3 deletions apps/web/app/api/admin/servers/[serverId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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]" });
}
}
9 changes: 6 additions & 3 deletions apps/web/app/api/admin/servers/[serverId]/start/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
}
}
8 changes: 5 additions & 3 deletions apps/web/app/api/admin/servers/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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: {
Expand All @@ -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" });
}
}
2 changes: 2 additions & 0 deletions apps/web/app/api/join-server/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 2 additions & 0 deletions apps/web/app/api/servers/[serverId]/decisions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/api/servers/[serverId]/state/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
27 changes: 26 additions & 1 deletion apps/web/app/components/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,31 @@ export type ApiResponse<T> =
| { ok: true; data: T }
| { ok: false; error: { code: string; message: string; details?: unknown } };

const fallbackMessages: Record<string, string> = {
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 <T>(path: string, init?: RequestInit): Promise<T> => {
const response = await fetch(path, {
...init,
Expand All @@ -12,7 +37,7 @@ export const requestJson = async <T>(path: string, init?: RequestInit): Promise<
});
const payload = (await response.json()) as ApiResponse<T>;
if (!payload.ok) {
throw new Error(payload.error.message);
throw new ApiClientError(payload.error);
}
return payload.data;
};
35 changes: 35 additions & 0 deletions apps/web/lib/api/audit.ts
Original file line number Diff line number Diff line change
@@ -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),
},
});
};
Loading
Loading