From 961e483a5c723bd9a3bc943f085e28d36a5b9156 Mon Sep 17 00:00:00 2001
From: Spbd1 <148923621+Spbd1@users.noreply.github.com>
Date: Sun, 10 May 2026 09:11:10 +0000
Subject: [PATCH] Harden reproducibility security and deployment
---
.../servers/[serverId]/final-summary/route.ts | 31 +-
apps/web/app/game/[serverId]/final/page.tsx | 11 +-
apps/web/lib/api/auth.ts | 32 +-
apps/web/lib/services/game.ts | 16 +-
apps/web/lib/services/researchExport.ts | 33 ++
docs/deployment.md | 2 +-
packages/db/package.json | 2 +-
.../migration.sql | 298 ++++++++++++++++++
.../db/prisma/migrations/migration_lock.toml | 3 +
packages/engine/src/index.test.ts | 45 +++
packages/engine/src/roundResolver.ts | 1 +
packages/engine/src/serverSimulator.ts | 28 +-
packages/engine/src/types.ts | 2 +
13 files changed, 484 insertions(+), 20 deletions(-)
create mode 100644 packages/db/prisma/migrations/20260510120000_initial_schema/migration.sql
create mode 100644 packages/db/prisma/migrations/migration_lock.toml
diff --git a/apps/web/app/api/servers/[serverId]/final-summary/route.ts b/apps/web/app/api/servers/[serverId]/final-summary/route.ts
index 7cb97fb..9e3223d 100644
--- a/apps/web/app/api/servers/[serverId]/final-summary/route.ts
+++ b/apps/web/app/api/servers/[serverId]/final-summary/route.ts
@@ -8,14 +8,39 @@ export async function GET(_request: Request, context: { params: Promise<{ server
const { serverId } = serverIdParamsSchema.parse(await context.params);
const auth = await getParticipantAuth();
const player = await assertParticipantOnServer(auth.user.id, serverId);
- const [server, decisions, contracts] = await Promise.all([
- prisma.server.findUnique({ where: { id: serverId }, include: { players: true } }),
+ const [server, decisions, contracts, participantCount, exitedCount, wealthAggregate] = await Promise.all([
+ prisma.server.findUnique({
+ where: { id: serverId },
+ select: {
+ id: true,
+ name: true,
+ status: true,
+ treasury: true,
+ currentRound: true,
+ seasonLength: true,
+ inequalityCondition: true,
+ uncertaintyCondition: true,
+ },
+ }),
prisma.decision.findMany({ where: { serverId, playerId: player.id } }),
prisma.contract.findMany({
where: { serverId, OR: [{ senderId: player.id }, { receiverId: player.id }] },
}),
+ prisma.player.count({ where: { serverId } }),
+ prisma.player.count({ where: { serverId, exited: true } }),
+ prisma.player.aggregate({ where: { serverId }, _avg: { wealth: true } }),
]);
- return applyAuthCookie(apiOk({ server, player, decisions, contracts }), auth);
+ return applyAuthCookie(apiOk({
+ server,
+ player,
+ decisions,
+ contracts,
+ serverOutcomes: {
+ participantCount,
+ exitedCount,
+ averageWealth: wealthAggregate._avg.wealth ?? 0,
+ },
+ }), auth);
} catch (error) {
return handleApiError(error);
}
diff --git a/apps/web/app/game/[serverId]/final/page.tsx b/apps/web/app/game/[serverId]/final/page.tsx
index e7e55c3..6b4cab7 100644
--- a/apps/web/app/game/[serverId]/final/page.tsx
+++ b/apps/web/app/game/[serverId]/final/page.tsx
@@ -9,8 +9,9 @@ type DecimalValue = number | string | { toString(): string };
type Player = { wealth: DecimalValue; productiveCapital: DecimalValue; safeAsset: DecimalValue; exited: boolean };
type Decision = { actionType: string; amount: DecimalValue };
type Contract = { fulfilled: boolean | null; defaulted: boolean | null };
-type Server = { status: string; treasury: DecimalValue; players: Array<{ exited: boolean; wealth: DecimalValue }> };
-type FinalResponse = { server: Server | null; player: Player; decisions: Decision[]; contracts: Contract[] };
+type Server = { status: string; treasury: DecimalValue };
+type ServerOutcomes = { participantCount: number; exitedCount: number; averageWealth: DecimalValue };
+type FinalResponse = { server: Server | null; player: Player; decisions: Decision[]; contracts: Contract[]; serverOutcomes: ServerOutcomes };
const money = (value: DecimalValue | undefined) => Number(value ?? 0).toFixed(2);
@@ -36,8 +37,8 @@ export default function FinalPage() {
investments: decisions.filter((decision) => decision.actionType === "PRODUCTIVE_INVESTMENT").reduce((sum, decision) => sum + Number(decision.amount), 0),
reliability: resolved === 0 ? "No resolved contracts" : `${Math.round((fulfilled / resolved) * 100)}% fulfilled`,
exited: data?.player.exited || decisions.some((decision) => decision.actionType === "EXIT"),
- averageWealth: data?.server?.players.length ? data.server.players.reduce((sum, player) => sum + Number(player.wealth), 0) / data.server.players.length : 0,
- exits: data?.server?.players.filter((player) => player.exited).length ?? 0,
+ averageWealth: Number(data?.serverOutcomes.averageWealth ?? 0),
+ exits: data?.serverOutcomes.exitedCount ?? 0,
};
}, [data]);
@@ -65,7 +66,7 @@ export default function FinalPage() {
Server-level outcomes
- Server status
- {data?.server?.status ?? "Unknown"}
- - Participants
- {data?.server?.players.length ?? 0}
+ - Participants
- {data?.serverOutcomes.participantCount ?? 0}
- Exited participants
- {totals.exits}
diff --git a/apps/web/lib/api/auth.ts b/apps/web/lib/api/auth.ts
index e528865..5cbdc22 100644
--- a/apps/web/lib/api/auth.ts
+++ b/apps/web/lib/api/auth.ts
@@ -1,3 +1,4 @@
+import { createHmac, timingSafeEqual } from "node:crypto";
import { cookies } from "next/headers";
import { prisma, UserRole } from "@parcel-society/db";
import { ApiException } from "./responses";
@@ -5,6 +6,31 @@ import { rateLimit } from "./rateLimit";
const PARTICIPANT_COOKIE = "parcel_society_user_id";
+const appSecret = (): string => {
+ const secret = process.env.APP_SECRET;
+ if (secret) return secret;
+ if (process.env.NODE_ENV !== "production") return "parcel-society-development-secret";
+ throw new ApiException(500, "APP_SECRET_NOT_CONFIGURED", "Participant session signing is not configured.");
+};
+
+const signParticipantId = (userId: string): string =>
+ createHmac("sha256", appSecret()).update(userId).digest("base64url");
+
+const encodeParticipantCookie = (userId: string): string => `${userId}.${signParticipantId(userId)}`;
+
+const decodeParticipantCookie = (value: string | undefined): string | null => {
+ if (!value) return null;
+ const separator = value.lastIndexOf(".");
+ if (separator <= 0) return null;
+ const userId = value.slice(0, separator);
+ const signature = value.slice(separator + 1);
+ const expected = signParticipantId(userId);
+ const signatureBuffer = Buffer.from(signature);
+ const expectedBuffer = Buffer.from(expected);
+ if (signatureBuffer.length !== expectedBuffer.length) return null;
+ return timingSafeEqual(signatureBuffer, expectedBuffer) ? userId : null;
+};
+
export type AuthContext = {
user: {
id: string;
@@ -17,7 +43,7 @@ export type AuthContext = {
export const getParticipantAuth = async (): Promise => {
const cookieStore = await cookies();
- const existingUserId = cookieStore.get(PARTICIPANT_COOKIE)?.value;
+ const existingUserId = decodeParticipantCookie(cookieStore.get(PARTICIPANT_COOKIE)?.value);
if (existingUserId) {
const user = await prisma.user.findUnique({
@@ -33,7 +59,7 @@ export const getParticipantAuth = async (): Promise => {
});
return {
user,
- setCookie: { name: PARTICIPANT_COOKIE, value: user.id },
+ setCookie: { name: PARTICIPANT_COOKIE, value: encodeParticipantCookie(user.id) },
};
};
@@ -149,7 +175,7 @@ export const applyAuthCookie = (
if (auth.setCookie) {
response.headers.append(
"Set-Cookie",
- `${auth.setCookie.name}=${auth.setCookie.value}; Path=/; HttpOnly; SameSite=Lax`,
+ `${auth.setCookie.name}=${auth.setCookie.value}; Path=/; HttpOnly; SameSite=Lax${process.env.NODE_ENV === "production" ? "; Secure" : ""}`,
);
}
return response;
diff --git a/apps/web/lib/services/game.ts b/apps/web/lib/services/game.ts
index 1d9a760..de6f657 100644
--- a/apps/web/lib/services/game.ts
+++ b/apps/web/lib/services/game.ts
@@ -12,6 +12,7 @@ import {
type Prisma,
} from "@parcel-society/db";
import {
+ createRandom,
decisionCost,
generateMap,
resolveRound,
@@ -98,6 +99,16 @@ export const defaultEngineConfig = (server: {
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)
+ : undefined,
+ uncertaintyPossibleEvents: Array.isArray(overrides.uncertaintyPossibleEvents)
+ ? overrides.uncertaintyPossibleEvents.filter((event): event is "TAX_CHANGE" | "FORMAL_CONTRACT_FEE_CHANGE" | "SHOCK_PROBABILITY_CHANGE" =>
+ event === "TAX_CHANGE" ||
+ event === "FORMAL_CONTRACT_FEE_CHANGE" ||
+ event === "SHOCK_PROBABILITY_CHANGE",
+ )
+ : undefined,
};
};
@@ -137,7 +148,10 @@ export const joinWaitingServer = async ({
throw new ApiException(409, "NO_AVAILABLE_PARCELS", "No parcels are available.");
}
- const parcel = availableParcels[Math.floor(Math.random() * availableParcels.length)];
+ const orderedAvailableParcels = [...availableParcels].sort(
+ (left, right) => left.y - right.y || left.x - right.x || left.id.localeCompare(right.id),
+ );
+ const parcel = createRandom(`${server.randomSeed}:join:${server.players.length}`).pick(orderedAvailableParcels);
const config = defaultEngineConfig(server);
const player = await tx.player.create({
data: {
diff --git a/apps/web/lib/services/researchExport.ts b/apps/web/lib/services/researchExport.ts
index adbb5f6..2974bc8 100644
--- a/apps/web/lib/services/researchExport.ts
+++ b/apps/web/lib/services/researchExport.ts
@@ -183,6 +183,16 @@ const roundOutcomeHeaders = [
"average_wealth",
"median_wealth",
];
+const serverConfigHeaders = [
+ "server_id",
+ "inequality_condition",
+ "uncertainty_condition",
+ "random_seed",
+ "config_key",
+ "config_json",
+ "created_at",
+ "updated_at",
+];
const serverSummaryHeaders = [
"server_id",
"inequality_condition",
@@ -225,6 +235,7 @@ export const buildResearchExportZip = async (scope: ExportScope): Promise ({
+ server_id: server.id,
+ inequality_condition: server.inequalityCondition,
+ uncertainty_condition: server.uncertaintyCondition,
+ random_seed: server.randomSeed,
+ config_key: configEntry.key,
+ config_json: configEntry.value,
+ created_at: configEntry.createdAt,
+ updated_at: configEntry.updatedAt,
+ })));
const rounds = [...new Set([
...decisions.map((decision) => decision.roundNumber),
...contracts.map((contract) => contract.roundNumber),
@@ -407,5 +439,6 @@ export const buildResearchExportZip = async (scope: ExportScope): Promise {
).toBe(true);
});
});
+
+describe("research treatment reproducibility", () => {
+ it("only applies configured uncertainty rule changes on uncertain servers", () => {
+ const stable = resolveRound({
+ server: createInitialServerState({
+ ...config,
+ uncertainty: "STABLE",
+ uncertaintyRuleChangeRounds: [1],
+ uncertaintyPossibleEvents: ["TAX_CHANGE"],
+ }),
+ players: createInitialPlayers(1, config),
+ parcels: [ownedParcel()],
+ decisions: [],
+ config: {
+ ...config,
+ uncertainty: "STABLE",
+ uncertaintyRuleChangeRounds: [1],
+ uncertaintyPossibleEvents: ["TAX_CHANGE"],
+ },
+ seed: "uncertainty-seed",
+ });
+ const uncertain = resolveRound({
+ server: createInitialServerState({
+ ...config,
+ uncertainty: "UNCERTAIN",
+ uncertaintyRuleChangeRounds: [1],
+ uncertaintyPossibleEvents: ["TAX_CHANGE"],
+ }),
+ players: createInitialPlayers(1, config),
+ parcels: [ownedParcel()],
+ decisions: [],
+ config: {
+ ...config,
+ uncertainty: "UNCERTAIN",
+ uncertaintyRuleChangeRounds: [1],
+ uncertaintyPossibleEvents: ["TAX_CHANGE"],
+ },
+ seed: "uncertainty-seed",
+ });
+
+ expect(stable.serverEvents).toHaveLength(0);
+ expect(uncertain.serverEvents).toHaveLength(1);
+ expect(uncertain.serverEvents[0]?.type).toBe("TAX_CHANGE");
+ });
+});
diff --git a/packages/engine/src/roundResolver.ts b/packages/engine/src/roundResolver.ts
index ed72bde..2aceeb5 100644
--- a/packages/engine/src/roundResolver.ts
+++ b/packages/engine/src/roundResolver.ts
@@ -24,6 +24,7 @@ export const resolveRound = ({
server: { ...server },
seed,
round,
+ config,
});
const workingServer = ruleChange.server;
const validation = validateDecisions({
diff --git a/packages/engine/src/serverSimulator.ts b/packages/engine/src/serverSimulator.ts
index 0b2aa5d..197b5cb 100644
--- a/packages/engine/src/serverSimulator.ts
+++ b/packages/engine/src/serverSimulator.ts
@@ -12,6 +12,13 @@ import type {
ServerState,
} from "./types";
+const DEFAULT_RULE_CHANGE_ROUNDS: readonly number[] = [3, 5];
+const DEFAULT_RULE_CHANGE_EVENTS: readonly RuleChangeEventType[] = [
+ "TAX_CHANGE",
+ "FORMAL_CONTRACT_FEE_CHANGE",
+ "SHOCK_PROBABILITY_CHANGE",
+];
+
export const DEFAULT_ENGINE_CONFIG: EngineConfig = {
seed: "parcel-society",
inequality: "LOW",
@@ -31,6 +38,8 @@ export const DEFAULT_ENGINE_CONFIG: EngineConfig = {
safeAssetReturn: 0.02,
publicGoodMultiplier: 1.4,
lobbyingCost: 5,
+ uncertaintyRuleChangeRounds: DEFAULT_RULE_CHANGE_ROUNDS,
+ uncertaintyPossibleEvents: DEFAULT_RULE_CHANGE_EVENTS,
};
export const createInitialServerState = (
@@ -67,21 +76,28 @@ export const applyRuleChangeIfNeeded = ({
server,
seed,
round,
+ config,
}: {
server: ServerState;
seed: string | number;
round: number;
+ config?: Pick;
}): { server: ServerState; events: ServerEvent[] } => {
- if (server.uncertainty !== "UNCERTAIN" || (round !== 3 && round !== 5)) {
+ const ruleChangeRounds = config?.uncertaintyRuleChangeRounds ?? DEFAULT_RULE_CHANGE_ROUNDS;
+ const possibleEvents = config?.uncertaintyPossibleEvents?.length
+ ? config.uncertaintyPossibleEvents
+ : DEFAULT_RULE_CHANGE_EVENTS;
+
+ if (
+ server.uncertainty !== "UNCERTAIN" ||
+ !ruleChangeRounds.includes(round) ||
+ possibleEvents.length === 0
+ ) {
return { server, events: [] };
}
const random = createRandom(`${seed}:rule-change:${round}`);
- const eventType = random.pick([
- "TAX_CHANGE",
- "FORMAL_CONTRACT_FEE_CHANGE",
- "SHOCK_PROBABILITY_CHANGE",
- ]);
+ const eventType = random.pick(possibleEvents);
const nextServer = { ...server, events: [...server.events] };
const direction = random.boolean(0.5) ? 1 : -1;
const event: ServerEvent = {
diff --git a/packages/engine/src/types.ts b/packages/engine/src/types.ts
index b5b4e39..b7c0421 100644
--- a/packages/engine/src/types.ts
+++ b/packages/engine/src/types.ts
@@ -35,6 +35,8 @@ export interface EngineConfig {
safeAssetReturn: number;
publicGoodMultiplier: number;
lobbyingCost: number;
+ uncertaintyRuleChangeRounds?: readonly number[];
+ uncertaintyPossibleEvents?: readonly RuleChangeEventType[];
}
export interface ProductionConfig {