From 9a434fd03a32e3262473799feed06f9763bf4d7a Mon Sep 17 00:00:00 2001 From: evanpelle Date: Fri, 5 Sep 2025 10:11:16 -0700 Subject: [PATCH 01/20] remove gatekeeper submodule because it is not AGPL compatible (#2012) ## Description: Gatekeeper is a private submodule was planned to be used for security, bot detection etc, but actually just a no op. Since gatekeeper is private, it's not AGPL compatible. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- src/server/GameServer.ts | 197 ++++++------ src/server/Gatekeeper.ts | 160 --------- src/server/Master.ts | 86 +++-- src/server/Worker.ts | 678 +++++++++++++++++++-------------------- src/server/gatekeeper | 1 - 5 files changed, 456 insertions(+), 666 deletions(-) delete mode 100644 src/server/Gatekeeper.ts delete mode 160000 src/server/gatekeeper diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index b16dae9b3..0ac2621f5 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -24,7 +24,6 @@ import { GameEnv, ServerConfig } from "../core/configuration/Config"; import { GameType } from "../core/game/Game"; import { archive } from "./Archive"; import { Client } from "./Client"; -import { gatekeeper } from "./Gatekeeper"; export enum GamePhase { Lobby = "LOBBY", Active = "ACTIVE", @@ -198,125 +197,119 @@ export class GameServer { this.allClients.set(client.clientID, client); client.ws.removeAllListeners("message"); - client.ws.on( - "message", - gatekeeper.wsHandler(client.ip, async (message: string) => { - try { - const parsed = ClientMessageSchema.safeParse(JSON.parse(message)); - if (!parsed.success) { - const error = z.prettifyError(parsed.error); - this.log.error("Failed to parse client message", error, { - clientID: client.clientID, - }); - client.ws.send( - JSON.stringify({ - type: "error", - error, - message, - } satisfies ServerErrorMessage), - ); - client.ws.close(1002, "ClientMessageSchema"); - return; - } - const clientMsg = parsed.data; - switch (clientMsg.type) { - case "intent": { - if (clientMsg.intent.clientID !== client.clientID) { + client.ws.on("message", async (message: string) => { + try { + const parsed = ClientMessageSchema.safeParse(JSON.parse(message)); + if (!parsed.success) { + const error = z.prettifyError(parsed.error); + this.log.error("Failed to parse client message", error, { + clientID: client.clientID, + }); + client.ws.send( + JSON.stringify({ + type: "error", + error, + message, + } satisfies ServerErrorMessage), + ); + client.ws.close(1002, "ClientMessageSchema"); + return; + } + const clientMsg = parsed.data; + switch (clientMsg.type) { + case "intent": { + if (clientMsg.intent.clientID !== client.clientID) { + this.log.warn( + `client id mismatch, client: ${client.clientID}, intent: ${clientMsg.intent.clientID}`, + ); + return; + } + switch (clientMsg.intent.type) { + case "mark_disconnected": { this.log.warn( - `client id mismatch, client: ${client.clientID}, intent: ${clientMsg.intent.clientID}`, + `Should not receive mark_disconnected intent from client`, ); return; } - switch (clientMsg.intent.type) { - case "mark_disconnected": { - this.log.warn( - `Should not receive mark_disconnected intent from client`, - ); - return; - } - // Handle kick_player intent via WebSocket - case "kick_player": { - const authenticatedClientID = client.clientID; + // Handle kick_player intent via WebSocket + case "kick_player": { + const authenticatedClientID = client.clientID; - // Check if the authenticated client is the lobby creator - if (authenticatedClientID !== this.LobbyCreatorID) { - this.log.warn(`Only lobby creator can kick players`, { - clientID: authenticatedClientID, - creatorID: this.LobbyCreatorID, - target: clientMsg.intent.target, - gameID: this.id, - }); - return; - } - - // Don't allow lobby creator to kick themselves - if (authenticatedClientID === clientMsg.intent.target) { - this.log.warn(`Cannot kick yourself`, { - clientID: authenticatedClientID, - }); - return; - } - - // Log and execute the kick - this.log.info(`Lobby creator initiated kick of player`, { - creatorID: authenticatedClientID, + // Check if the authenticated client is the lobby creator + if (authenticatedClientID !== this.LobbyCreatorID) { + this.log.warn(`Only lobby creator can kick players`, { + clientID: authenticatedClientID, + creatorID: this.LobbyCreatorID, target: clientMsg.intent.target, gameID: this.id, - kickMethod: "websocket", }); - - this.kickClient(clientMsg.intent.target); return; } - default: { - this.addIntent(clientMsg.intent); - break; + + // Don't allow lobby creator to kick themselves + if (authenticatedClientID === clientMsg.intent.target) { + this.log.warn(`Cannot kick yourself`, { + clientID: authenticatedClientID, + }); + return; } - } - break; - } - case "ping": { - this.lastPingUpdate = Date.now(); - client.lastPing = Date.now(); - break; - } - case "hash": { - client.hashes.set(clientMsg.turnNumber, clientMsg.hash); - break; - } - case "winner": { - if ( - this.outOfSyncClients.has(client.clientID) || - this.kickedClients.has(client.clientID) || - this.winner !== null - ) { + + // Log and execute the kick + this.log.info(`Lobby creator initiated kick of player`, { + creatorID: authenticatedClientID, + target: clientMsg.intent.target, + gameID: this.id, + kickMethod: "websocket", + }); + + this.kickClient(clientMsg.intent.target); return; } - this.winner = clientMsg; - this.archiveGame(); - break; - } - default: { - this.log.warn( - `Unknown message type: ${(clientMsg as any).type}`, - { - clientID: client.clientID, - }, - ); - break; + default: { + this.addIntent(clientMsg.intent); + break; + } } + break; } - } catch (error) { - this.log.info( - `error handline websocket request in game server: ${error}`, - { + case "ping": { + this.lastPingUpdate = Date.now(); + client.lastPing = Date.now(); + break; + } + case "hash": { + client.hashes.set(clientMsg.turnNumber, clientMsg.hash); + break; + } + case "winner": { + if ( + this.outOfSyncClients.has(client.clientID) || + this.kickedClients.has(client.clientID) || + this.winner !== null + ) { + return; + } + this.winner = clientMsg; + this.archiveGame(); + break; + } + default: { + this.log.warn(`Unknown message type: ${(clientMsg as any).type}`, { clientID: client.clientID, - }, - ); + }); + break; + } } - }), - ); + } catch (error) { + this.log.info( + `error handline websocket request in game server: ${error}`, + { + clientID: client.clientID, + }, + ); + } + }); client.ws.on("close", () => { this.log.info("client disconnected", { clientID: client.clientID, diff --git a/src/server/Gatekeeper.ts b/src/server/Gatekeeper.ts deleted file mode 100644 index ee47cbf50..000000000 --- a/src/server/Gatekeeper.ts +++ /dev/null @@ -1,160 +0,0 @@ -// src/server/Security.ts -import { NextFunction, Request, Response } from "express"; -import fs from "fs"; -import http from "http"; -import path from "path"; -import { fileURLToPath } from "url"; - -export enum LimiterType { - Get = "get", - Post = "post", - Put = "put", - WebSocket = "websocket", -} - -export interface Gatekeeper { - // The wrapper for request handlers with optional rate limiting - httpHandler: ( - limiterType: LimiterType, - fn: (req: Request, res: Response, next: NextFunction) => Promise, - ) => (req: Request, res: Response, next: NextFunction) => Promise; - - // The wrapper for WebSocket message handlers with rate limiting - wsHandler: ( - req: http.IncomingMessage | string, - fn: (message: string) => Promise, - ) => (message: string) => Promise; -} - -let gk: Gatekeeper | null = null; - -async function getGatekeeperCached(): Promise { - if (gk !== null) { - return gk; - } - return getGatekeeper().then((g) => { - gk = g; - return gk; - }); -} - -// Function to get the appropriate security middleware implementation -async function getGatekeeper(): Promise { - try { - // Get the current file's directory - const __filename = fileURLToPath(import.meta.url); - const __dirname = path.dirname(__filename); - - try { - // Check if the file exists before attempting to import it - const realMiddlewarePath = path.resolve( - __dirname, - "./gatekeeper/RealGatekeeper.js", - ); - const tsMiddlewarePath = path.resolve( - __dirname, - "./gatekeeper/RealGatekeeper.ts", - ); - - if ( - !fs.existsSync(realMiddlewarePath) && - !fs.existsSync(tsMiddlewarePath) - ) { - console.log("RealGatekeeper file not found, using NoOpGatekeeper"); - return new NoOpGatekeeper(); - } - - // Use dynamic import for ES modules - // Using a type assertion to avoid TypeScript errors for optional modules - const module = await import( - "./gatekeeper/RealGatekeeper.js" as string - ).catch(() => import("./gatekeeper/RealGatekeeper.js" as string)); - - if (!module || !module.RealGatekeeper) { - console.log( - "RealGatekeeper class not found in module, using NoOpGatekeeper", - ); - return new NoOpGatekeeper(); - } - - console.log("Successfully loaded real gatekeeper"); - return new module.RealGatekeeper(); - } catch (error) { - console.log("Failed to load real gatekeeper:", error); - return new NoOpGatekeeper(); - } - } catch (e) { - // Fall back to no-op if real implementation isn't available - console.log("using no-op gatekeeper", e); - return new NoOpGatekeeper(); - } -} - -export class GatekeeperWrapper implements Gatekeeper { - constructor(private getGK: () => Promise) {} - - httpHandler( - limiterType: LimiterType, - fn: (req: Request, res: Response, next: NextFunction) => Promise, - ) { - return async (req: Request, res: Response, next: NextFunction) => { - try { - const gk = await this.getGK(); - const handler = gk.httpHandler(limiterType, fn); - return handler(req, res, next); - } catch (error) { - next(error); - } - }; - } - - // Corrected implementation for WebSocket handler wrapper - wsHandler( - req: http.IncomingMessage | string, - fn: (message: string) => Promise, - ) { - return async (message: string) => { - try { - const gk = await this.getGK(); - const handler = gk.wsHandler(req, fn); - return handler(message); - } catch (error) { - console.error("WebSocket handler error:", error); - } - }; - } -} - -export class NoOpGatekeeper implements Gatekeeper { - // Simple pass-through with no rate limiting - httpHandler( - limiterType: LimiterType, - fn: (req: Request, res: Response, next: NextFunction) => Promise, - ) { - return async (req: Request, res: Response, next: NextFunction) => { - try { - await fn(req, res, next); - } catch (error) { - next(error); - } - }; - } - - // Corrected implementation for WebSocket handler wrapper - wsHandler( - req: http.IncomingMessage | string, - fn: (message: string) => Promise, - ) { - return async (message: string) => { - try { - await fn(message); - } catch (error) { - console.error("WebSocket handler error:", error); - } - }; - } -} - -export const gatekeeper: Gatekeeper = new GatekeeperWrapper(() => - getGatekeeperCached(), -); diff --git a/src/server/Master.ts b/src/server/Master.ts index 2b835ff85..993a4cec5 100644 --- a/src/server/Master.ts +++ b/src/server/Master.ts @@ -7,7 +7,6 @@ import { fileURLToPath } from "url"; import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; import { GameInfo, ID } from "../core/Schemas"; import { generateID } from "../core/Util"; -import { gatekeeper, LimiterType } from "./Gatekeeper"; import { logger } from "./Logger"; import { MapPlaylist } from "./MapPlaylist"; @@ -142,62 +141,53 @@ export async function startMaster() { }); } -app.get( - "/api/env", - gatekeeper.httpHandler(LimiterType.Get, async (req, res) => { - const envConfig = { - game_env: process.env.GAME_ENV, - }; - if (!envConfig.game_env) return res.sendStatus(500); - res.json(envConfig); - }), -); +app.get("/api/env", async (req, res) => { + const envConfig = { + game_env: process.env.GAME_ENV, + }; + if (!envConfig.game_env) return res.sendStatus(500); + res.json(envConfig); +}); // Add lobbies endpoint to list public games for this worker -app.get( - "/api/public_lobbies", - gatekeeper.httpHandler(LimiterType.Get, async (req, res) => { - res.send(publicLobbiesJsonStr); - }), -); +app.get("/api/public_lobbies", async (req, res) => { + res.send(publicLobbiesJsonStr); +}); -app.post( - "/api/kick_player/:gameID/:clientID", - gatekeeper.httpHandler(LimiterType.Post, async (req, res) => { - if (req.headers[config.adminHeader()] !== config.adminToken()) { - res.status(401).send("Unauthorized"); - return; - } +app.post("/api/kick_player/:gameID/:clientID", async (req, res) => { + if (req.headers[config.adminHeader()] !== config.adminToken()) { + res.status(401).send("Unauthorized"); + return; + } - const { gameID, clientID } = req.params; + const { gameID, clientID } = req.params; - if (!ID.safeParse(gameID).success || !ID.safeParse(clientID).success) { - res.sendStatus(400); - return; - } + if (!ID.safeParse(gameID).success || !ID.safeParse(clientID).success) { + res.sendStatus(400); + return; + } - try { - const response = await fetch( - `http://localhost:${config.workerPort(gameID)}/api/kick_player/${gameID}/${clientID}`, - { - method: "POST", - headers: { - [config.adminHeader()]: config.adminToken(), - }, + try { + const response = await fetch( + `http://localhost:${config.workerPort(gameID)}/api/kick_player/${gameID}/${clientID}`, + { + method: "POST", + headers: { + [config.adminHeader()]: config.adminToken(), }, - ); + }, + ); - if (!response.ok) { - throw new Error(`Failed to kick player: ${response.statusText}`); - } - - res.status(200).send("Player kicked successfully"); - } catch (error) { - log.error(`Error kicking player from game ${gameID}:`, error); - res.status(500).send("Failed to kick player"); + if (!response.ok) { + throw new Error(`Failed to kick player: ${response.statusText}`); } - }), -); + + res.status(200).send("Player kicked successfully"); + } catch (error) { + log.error(`Error kicking player from game ${gameID}:`, error); + res.status(500).send("Failed to kick player"); + } +}); async function fetchLobbies(): Promise { const fetchPromises: Promise[] = []; diff --git a/src/server/Worker.ts b/src/server/Worker.ts index d16da80fb..ae6e413e7 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -20,7 +20,6 @@ import { CreateGameInputSchema, GameInputSchema } from "../core/WorkerSchemas"; import { archive, readGameRecord } from "./Archive"; import { Client } from "./Client"; import { GameManager } from "./GameManager"; -import { gatekeeper, LimiterType } from "./Gatekeeper"; import { getUserMe, verifyClientToken } from "./jwt"; import { logger } from "./Logger"; @@ -91,394 +90,363 @@ export async function startWorker() { }), ); - app.post( - "/api/create_game/:id", - gatekeeper.httpHandler(LimiterType.Post, async (req, res) => { - const id = req.params.id; - const creatorClientID = (() => { - if (typeof req.query.creatorClientID !== "string") return undefined; + app.post("/api/create_game/:id", async (req, res) => { + const id = req.params.id; + const creatorClientID = (() => { + if (typeof req.query.creatorClientID !== "string") return undefined; - const trimmed = req.query.creatorClientID.trim(); - return ID.safeParse(trimmed).success ? trimmed : undefined; - })(); + const trimmed = req.query.creatorClientID.trim(); + return ID.safeParse(trimmed).success ? trimmed : undefined; + })(); - if (!id) { - log.warn(`cannot create game, id not found`); - return res.status(400).json({ error: "Game ID is required" }); - } - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const clientIP = req.ip || req.socket.remoteAddress || "unknown"; - const result = CreateGameInputSchema.safeParse(req.body); - if (!result.success) { - const error = z.prettifyError(result.error); - return res.status(400).json({ error }); - } + if (!id) { + log.warn(`cannot create game, id not found`); + return res.status(400).json({ error: "Game ID is required" }); + } + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const clientIP = req.ip || req.socket.remoteAddress || "unknown"; + const result = CreateGameInputSchema.safeParse(req.body); + if (!result.success) { + const error = z.prettifyError(result.error); + return res.status(400).json({ error }); + } - const gc = result.data; - if ( - gc?.gameType === GameType.Public && - req.headers[config.adminHeader()] !== config.adminToken() - ) { - log.warn( - `cannot create public game ${id}, ip ${ipAnonymize(clientIP)} incorrect admin token`, - ); - return res.status(401).send("Unauthorized"); - } - - // Double-check this worker should host this game - const expectedWorkerId = config.workerIndex(id); - if (expectedWorkerId !== workerId) { - log.warn( - `This game ${id} should be on worker ${expectedWorkerId}, but this is worker ${workerId}`, - ); - return res.status(400).json({ error: "Worker, game id mismatch" }); - } - - // Pass creatorClientID to createGame - const game = gm.createGame(id, gc, creatorClientID); - - log.info( - `Worker ${workerId}: IP ${ipAnonymize(clientIP)} creating ${game.isPublic() ? "Public" : "Private"}${gc?.gameMode ? ` ${gc.gameMode}` : ""} game with id ${id}${creatorClientID ? `, creator: ${creatorClientID}` : ""}`, + const gc = result.data; + if ( + gc?.gameType === GameType.Public && + req.headers[config.adminHeader()] !== config.adminToken() + ) { + log.warn( + `cannot create public game ${id}, ip ${ipAnonymize(clientIP)} incorrect admin token`, ); - res.json(game.gameInfo()); - }), - ); + return res.status(401).send("Unauthorized"); + } + + // Double-check this worker should host this game + const expectedWorkerId = config.workerIndex(id); + if (expectedWorkerId !== workerId) { + log.warn( + `This game ${id} should be on worker ${expectedWorkerId}, but this is worker ${workerId}`, + ); + return res.status(400).json({ error: "Worker, game id mismatch" }); + } + + // Pass creatorClientID to createGame + const game = gm.createGame(id, gc, creatorClientID); + + log.info( + `Worker ${workerId}: IP ${ipAnonymize(clientIP)} creating ${game.isPublic() ? "Public" : "Private"}${gc?.gameMode ? ` ${gc.gameMode}` : ""} game with id ${id}${creatorClientID ? `, creator: ${creatorClientID}` : ""}`, + ); + res.json(game.gameInfo()); + }); // Add other endpoints from your original server - app.post( - "/api/start_game/:id", - gatekeeper.httpHandler(LimiterType.Post, async (req, res) => { - log.info(`starting private lobby with id ${req.params.id}`); - const game = gm.game(req.params.id); - if (!game) { - return; - } - if (game.isPublic()) { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const clientIP = req.ip || req.socket.remoteAddress || "unknown"; - log.info( - `cannot start public game ${game.id}, game is public, ip: ${ipAnonymize(clientIP)}`, - ); - return; - } - game.start(); - res.status(200).json({ success: true }); - }), - ); + app.post("/api/start_game/:id", async (req, res) => { + log.info(`starting private lobby with id ${req.params.id}`); + const game = gm.game(req.params.id); + if (!game) { + return; + } + if (game.isPublic()) { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const clientIP = req.ip || req.socket.remoteAddress || "unknown"; + log.info( + `cannot start public game ${game.id}, game is public, ip: ${ipAnonymize(clientIP)}`, + ); + return; + } + game.start(); + res.status(200).json({ success: true }); + }); - app.put( - "/api/game/:id", - gatekeeper.httpHandler(LimiterType.Put, async (req, res) => { - const result = GameInputSchema.safeParse(req.body); - if (!result.success) { - const error = z.prettifyError(result.error); - return res.status(400).json({ error }); - } - const config = result.data; - // TODO: only update public game if from local host - const lobbyID = req.params.id; - if (config.gameType === GameType.Public) { - log.info(`cannot update game ${lobbyID} to public`); - return res.status(400).json({ error: "Cannot update public game" }); - } - const game = gm.game(lobbyID); - if (!game) { - return res.status(400).json({ error: "Game not found" }); - } - if (game.isPublic()) { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const clientIP = req.ip || req.socket.remoteAddress || "unknown"; - log.warn( - `cannot update public game ${game.id}, ip: ${ipAnonymize(clientIP)}`, - ); - return res.status(400).json({ error: "Cannot update public game" }); - } - if (game.hasStarted()) { - log.warn(`cannot update game ${game.id} after it has started`); - return res - .status(400) - .json({ error: "Cannot update game after it has started" }); - } - game.updateGameConfig(config); - res.status(200).json({ success: true }); - }), - ); + app.put("/api/game/:id", async (req, res) => { + const result = GameInputSchema.safeParse(req.body); + if (!result.success) { + const error = z.prettifyError(result.error); + return res.status(400).json({ error }); + } + const config = result.data; + // TODO: only update public game if from local host + const lobbyID = req.params.id; + if (config.gameType === GameType.Public) { + log.info(`cannot update game ${lobbyID} to public`); + return res.status(400).json({ error: "Cannot update public game" }); + } + const game = gm.game(lobbyID); + if (!game) { + return res.status(400).json({ error: "Game not found" }); + } + if (game.isPublic()) { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const clientIP = req.ip || req.socket.remoteAddress || "unknown"; + log.warn( + `cannot update public game ${game.id}, ip: ${ipAnonymize(clientIP)}`, + ); + return res.status(400).json({ error: "Cannot update public game" }); + } + if (game.hasStarted()) { + log.warn(`cannot update game ${game.id} after it has started`); + return res + .status(400) + .json({ error: "Cannot update game after it has started" }); + } + game.updateGameConfig(config); + res.status(200).json({ success: true }); + }); - app.get( - "/api/game/:id/exists", - gatekeeper.httpHandler(LimiterType.Get, async (req, res) => { - const lobbyId = req.params.id; - res.json({ - exists: gm.game(lobbyId) !== null, + app.get("/api/game/:id/exists", async (req, res) => { + const lobbyId = req.params.id; + res.json({ + exists: gm.game(lobbyId) !== null, + }); + }); + + app.get("/api/game/:id", async (req, res) => { + const game = gm.game(req.params.id); + if (game === null) { + log.info(`lobby ${req.params.id} not found`); + return res.status(404).json({ error: "Game not found" }); + } + res.json(game.gameInfo()); + }); + + app.get("/api/archived_game/:id", async (req, res) => { + const gameRecord = await readGameRecord(req.params.id); + + if (!gameRecord) { + return res.status(404).json({ + success: false, + error: "Game not found", + exists: false, }); - }), - ); + } - app.get( - "/api/game/:id", - gatekeeper.httpHandler(LimiterType.Get, async (req, res) => { - const game = gm.game(req.params.id); - if (game === null) { - log.info(`lobby ${req.params.id} not found`); - return res.status(404).json({ error: "Game not found" }); - } - res.json(game.gameInfo()); - }), - ); - - app.get( - "/api/archived_game/:id", - gatekeeper.httpHandler(LimiterType.Get, async (req, res) => { - const gameRecord = await readGameRecord(req.params.id); - - if (!gameRecord) { - return res.status(404).json({ - success: false, - error: "Game not found", - exists: false, - }); - } - - if ( - config.env() !== GameEnv.Dev && - gameRecord.gitCommit !== config.gitCommit() - ) { - log.warn( - `git commit mismatch for game ${req.params.id}, expected ${config.gitCommit()}, got ${gameRecord.gitCommit}`, - ); - return res.status(409).json({ - success: false, - error: "Version mismatch", - exists: true, - details: { - expectedCommit: config.gitCommit(), - actualCommit: gameRecord.gitCommit, - }, - }); - } - - return res.status(200).json({ - success: true, + if ( + config.env() !== GameEnv.Dev && + gameRecord.gitCommit !== config.gitCommit() + ) { + log.warn( + `git commit mismatch for game ${req.params.id}, expected ${config.gitCommit()}, got ${gameRecord.gitCommit}`, + ); + return res.status(409).json({ + success: false, + error: "Version mismatch", exists: true, - gameRecord: gameRecord, + details: { + expectedCommit: config.gitCommit(), + actualCommit: gameRecord.gitCommit, + }, }); - }), - ); + } - app.post( - "/api/archive_singleplayer_game", - gatekeeper.httpHandler(LimiterType.Post, async (req, res) => { - const result = GameRecordSchema.safeParse(req.body); - if (!result.success) { - const error = z.prettifyError(result.error); - log.info(error); - return res.status(400).json({ error }); - } + return res.status(200).json({ + success: true, + exists: true, + gameRecord: gameRecord, + }); + }); - const gameRecord: GameRecord = result.data; - archive(gameRecord); - res.json({ - success: true, - }); - }), - ); + app.post("/api/archive_singleplayer_game", async (req, res) => { + const result = GameRecordSchema.safeParse(req.body); + if (!result.success) { + const error = z.prettifyError(result.error); + log.info(error); + return res.status(400).json({ error }); + } - app.post( - "/api/kick_player/:gameID/:clientID", - gatekeeper.httpHandler(LimiterType.Post, async (req, res) => { - if (req.headers[config.adminHeader()] !== config.adminToken()) { - res.status(401).send("Unauthorized"); - return; - } + const gameRecord: GameRecord = result.data; + archive(gameRecord); + res.json({ + success: true, + }); + }); - const { gameID, clientID } = req.params; + app.post("/api/kick_player/:gameID/:clientID", async (req, res) => { + if (req.headers[config.adminHeader()] !== config.adminToken()) { + res.status(401).send("Unauthorized"); + return; + } - const game = gm.game(gameID); - if (!game) { - res.status(404).send("Game not found"); - return; - } + const { gameID, clientID } = req.params; - game.kickClient(clientID); - res.status(200).send("Player kicked successfully"); - }), - ); + const game = gm.game(gameID); + if (!game) { + res.status(404).send("Game not found"); + return; + } + + game.kickClient(clientID); + res.status(200).send("Player kicked successfully"); + }); // WebSocket handling wss.on("connection", (ws: WebSocket, req) => { - ws.on( - "message", - gatekeeper.wsHandler(req, async (message: string) => { - const forwarded = req.headers["x-forwarded-for"]; - const ip = Array.isArray(forwarded) - ? forwarded[0] - : // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - forwarded || req.socket.remoteAddress || "unknown"; + ws.on("message", async (message: string) => { + const forwarded = req.headers["x-forwarded-for"]; + const ip = Array.isArray(forwarded) + ? forwarded[0] + : // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + forwarded || req.socket.remoteAddress || "unknown"; - try { - // Parse and handle client messages - const parsed = ClientMessageSchema.safeParse( - JSON.parse(message.toString()), + try { + // Parse and handle client messages + const parsed = ClientMessageSchema.safeParse( + JSON.parse(message.toString()), + ); + if (!parsed.success) { + const error = z.prettifyError(parsed.error); + log.warn("Error parsing client message", error); + ws.send( + JSON.stringify({ + type: "error", + error: error.toString(), + } satisfies ServerErrorMessage), ); - if (!parsed.success) { - const error = z.prettifyError(parsed.error); - log.warn("Error parsing client message", error); - ws.send( - JSON.stringify({ - type: "error", - error: error.toString(), - } satisfies ServerErrorMessage), - ); - ws.close(1002, "ClientJoinMessageSchema"); - return; - } - const clientMsg = parsed.data; + ws.close(1002, "ClientJoinMessageSchema"); + return; + } + const clientMsg = parsed.data; - if (clientMsg.type === "ping") { - // Ignore ping - return; - } else if (clientMsg.type !== "join") { - log.warn( - `Invalid message before join: ${JSON.stringify(clientMsg)}`, - ); - return; - } + if (clientMsg.type === "ping") { + // Ignore ping + return; + } else if (clientMsg.type !== "join") { + log.warn(`Invalid message before join: ${JSON.stringify(clientMsg)}`); + return; + } - // Verify this worker should handle this game - const expectedWorkerId = config.workerIndex(clientMsg.gameID); - if (expectedWorkerId !== workerId) { - log.warn( - `Worker mismatch: Game ${clientMsg.gameID} should be on worker ${expectedWorkerId}, but this is worker ${workerId}`, - ); - return; - } + // Verify this worker should handle this game + const expectedWorkerId = config.workerIndex(clientMsg.gameID); + if (expectedWorkerId !== workerId) { + log.warn( + `Worker mismatch: Game ${clientMsg.gameID} should be on worker ${expectedWorkerId}, but this is worker ${workerId}`, + ); + return; + } - // Verify token signature - const result = await verifyClientToken(clientMsg.token, config); - if (result === false) { - log.warn("Unauthorized: Invalid token"); + // Verify token signature + const result = await verifyClientToken(clientMsg.token, config); + if (result === false) { + log.warn("Unauthorized: Invalid token"); + ws.close(1002, "Unauthorized"); + return; + } + const { persistentId, claims } = result; + + let roles: string[] | undefined; + let flares: string[] | undefined; + + const allowedFlares = config.allowedFlares(); + if (claims === null) { + if (allowedFlares !== undefined) { + log.warn("Unauthorized: Anonymous user attempted to join game"); ws.close(1002, "Unauthorized"); return; } - const { persistentId, claims } = result; + } else { + // Verify token and get player permissions + const result = await getUserMe(clientMsg.token, config); + if (result === false) { + log.warn("Unauthorized: Invalid session"); + ws.close(1002, "Unauthorized"); + return; + } + roles = result.player.roles; + flares = result.player.flares; - let roles: string[] | undefined; - let flares: string[] | undefined; - - const allowedFlares = config.allowedFlares(); - if (claims === null) { - if (allowedFlares !== undefined) { - log.warn("Unauthorized: Anonymous user attempted to join game"); - ws.close(1002, "Unauthorized"); + if (allowedFlares !== undefined) { + const allowed = + allowedFlares.length === 0 || + allowedFlares.some((f) => flares?.includes(f)); + if (!allowed) { + log.warn( + "Forbidden: player without an allowed flare attempted to join game", + ); + ws.close(1002, "Forbidden"); return; } - } else { - // Verify token and get player permissions - const result = await getUserMe(clientMsg.token, config); - if (result === false) { - log.warn("Unauthorized: Invalid session"); - ws.close(1002, "Unauthorized"); - return; - } - roles = result.player.roles; - flares = result.player.flares; - - if (allowedFlares !== undefined) { - const allowed = - allowedFlares.length === 0 || - allowedFlares.some((f) => flares?.includes(f)); - if (!allowed) { - log.warn( - "Forbidden: player without an allowed flare attempted to join game", - ); - ws.close(1002, "Forbidden"); - return; - } - } } - - // Check if the flag is allowed - if (clientMsg.flag !== undefined) { - if (clientMsg.flag.startsWith("!")) { - const allowed = privilegeRefresher - .get() - .isCustomFlagAllowed(clientMsg.flag, flares); - if (allowed !== true) { - log.warn(`Custom flag ${allowed}: ${clientMsg.flag}`); - ws.close(1002, `Custom flag ${allowed}`); - return; - } - } - } - - let pattern: string | undefined; - // Check if the pattern is allowed - if (clientMsg.patternName !== undefined) { - const result = privilegeRefresher - .get() - .isPatternAllowed(clientMsg.patternName, flares); - switch (result.type) { - case "allowed": - pattern = result.pattern; - break; - case "unknown": - log.warn(`Pattern ${clientMsg.patternName} unknown`); - ws.close( - 1002, - "Could not look up pattern, backend may be offline", - ); - return; - case "forbidden": - log.warn(`Pattern ${clientMsg.patternName}: ${result.reason}`); - ws.close( - 1002, - `Pattern ${clientMsg.patternName}: ${result.reason}`, - ); - return; - default: - assertNever(result); - } - } - - // Create client and add to game - const client = new Client( - clientMsg.clientID, - persistentId, - claims, - roles, - flares, - ip, - clientMsg.username, - ws, - clientMsg.flag, - pattern, - ); - - const wasFound = gm.addClient( - client, - clientMsg.gameID, - clientMsg.lastTurn, - ); - - if (!wasFound) { - log.info( - `game ${clientMsg.gameID} not found on worker ${workerId}`, - ); - // Handle game not found case - } - - // Handle other message types - } catch (error) { - ws.close(1011, "Internal server error"); - log.warn( - `error handling websocket message for ${ipAnonymize(ip)}: ${error}`.substring( - 0, - 250, - ), - ); } - }), - ); + + // Check if the flag is allowed + if (clientMsg.flag !== undefined) { + if (clientMsg.flag.startsWith("!")) { + const allowed = privilegeRefresher + .get() + .isCustomFlagAllowed(clientMsg.flag, flares); + if (allowed !== true) { + log.warn(`Custom flag ${allowed}: ${clientMsg.flag}`); + ws.close(1002, `Custom flag ${allowed}`); + return; + } + } + } + + let pattern: string | undefined; + // Check if the pattern is allowed + if (clientMsg.patternName !== undefined) { + const result = privilegeRefresher + .get() + .isPatternAllowed(clientMsg.patternName, flares); + switch (result.type) { + case "allowed": + pattern = result.pattern; + break; + case "unknown": + log.warn(`Pattern ${clientMsg.patternName} unknown`); + ws.close( + 1002, + "Could not look up pattern, backend may be offline", + ); + return; + case "forbidden": + log.warn(`Pattern ${clientMsg.patternName}: ${result.reason}`); + ws.close( + 1002, + `Pattern ${clientMsg.patternName}: ${result.reason}`, + ); + return; + default: + assertNever(result); + } + } + + // Create client and add to game + const client = new Client( + clientMsg.clientID, + persistentId, + claims, + roles, + flares, + ip, + clientMsg.username, + ws, + clientMsg.flag, + pattern, + ); + + const wasFound = gm.addClient( + client, + clientMsg.gameID, + clientMsg.lastTurn, + ); + + if (!wasFound) { + log.info(`game ${clientMsg.gameID} not found on worker ${workerId}`); + // Handle game not found case + } + + // Handle other message types + } catch (error) { + ws.close(1011, "Internal server error"); + log.warn( + `error handling websocket message for ${ipAnonymize(ip)}: ${error}`.substring( + 0, + 250, + ), + ); + } + }); ws.on("error", (error: Error) => { if ((error as any).code === "WS_ERR_UNEXPECTED_RSV_1") { diff --git a/src/server/gatekeeper b/src/server/gatekeeper deleted file mode 160000 index f5f7e6362..000000000 --- a/src/server/gatekeeper +++ /dev/null @@ -1 +0,0 @@ -Subproject commit f5f7e6362f54fc0d7d7b1d74e60a62f83fa8fb3f From b0f8eb862e8f6aa301d49a2c0ef9550b161c79aa Mon Sep 17 00:00:00 2001 From: evanpelle Date: Fri, 5 Sep 2025 14:32:16 -0700 Subject: [PATCH 02/20] Implement vote for winner (#2013) ## Description: Require majority of ips to report a winner. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- src/server/Client.ts | 4 ++- src/server/GameServer.ts | 60 ++++++++++++++++++++++++++++++++++------ 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/src/server/Client.ts b/src/server/Client.ts index 68f0a2bfb..ecac7f885 100644 --- a/src/server/Client.ts +++ b/src/server/Client.ts @@ -1,13 +1,15 @@ import WebSocket from "ws"; import { TokenPayload } from "../core/ApiSchemas"; import { Tick } from "../core/game/Game"; -import { ClientID } from "../core/Schemas"; +import { ClientID, Winner } from "../core/Schemas"; export class Client { public lastPing: number = Date.now(); public hashes: Map = new Map(); + public reportedWinner: Winner | null = null; + constructor( public readonly clientID: ClientID, public readonly persistentID: string, diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 0ac2621f5..c4d917e14 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -64,6 +64,11 @@ export class GameServer { private websockets: Set = new Set(); + private winnerVotes: Map< + string, + { winner: ClientSendWinnerMessage; ips: Set } + > = new Map(); + constructor( public readonly id: string, readonly log_: Logger, @@ -184,6 +189,7 @@ export class GameServer { } client.lastPing = existing.lastPing; + client.reportedWinner = existing.reportedWinner; this.activeClients = this.activeClients.filter((c) => c !== existing); } @@ -283,15 +289,7 @@ export class GameServer { break; } case "winner": { - if ( - this.outOfSyncClients.has(client.clientID) || - this.kickedClients.has(client.clientID) || - this.winner !== null - ) { - return; - } - this.winner = clientMsg; - this.archiveGame(); + this.handleWinner(client, clientMsg); break; } default: { @@ -793,4 +791,48 @@ export class GameServer { outOfSyncClients, }; } + + private handleWinner(client: Client, clientMsg: ClientSendWinnerMessage) { + if ( + this.outOfSyncClients.has(client.clientID) || + this.kickedClients.has(client.clientID) || + this.winner !== null || + client.reportedWinner !== null + ) { + return; + } + client.reportedWinner = clientMsg.winner; + + // Add client vote + const winnerKey = JSON.stringify(clientMsg.winner); + if (!this.winnerVotes.has(winnerKey)) { + this.winnerVotes.set(winnerKey, { ips: new Set(), winner: clientMsg }); + } + const potentialWinner = this.winnerVotes.get(winnerKey)!; + potentialWinner.ips.add(client.ip); + + const activeUniqueIPs = new Set(this.activeClients.map((c) => c.ip)); + + const ratio = `${potentialWinner.ips.size}/${activeUniqueIPs.size}`; + this.log.info( + `recieved winner vote ${clientMsg.winner}, ${ratio} votes for this winner`, + { + clientID: client.clientID, + }, + ); + + if (potentialWinner.ips.size * 2 < activeUniqueIPs.size) { + return; + } + + // Vote succeeded + this.winner = potentialWinner.winner; + this.log.info( + `Winner determined by ${potentialWinner.ips.size}/${activeUniqueIPs.size} active IPs`, + { + winnerKey: winnerKey, + }, + ); + this.archiveGame(); + } } From 9c919926036e0fdac120d27baeaf20a320ca1e96 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Sun, 7 Sep 2025 14:36:44 -0700 Subject: [PATCH 03/20] Support affiliate patterns (#2027) ## Description: Patterns have an optional affiliateCode that is associated with an affiliate/youtuber. These patterns are not shown by default. You can see them by going to openfront.io/#affiliate=XXX ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- src/client/Main.ts | 7 +++++++ src/client/TerritoryPatternsModal.ts | 29 +++++++++++++++++++++++----- src/core/CosmeticSchemas.ts | 1 + 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/client/Main.ts b/src/client/Main.ts index 046becf7c..2ac00af2d 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -483,6 +483,13 @@ class Client { console.log(`joining lobby ${lobbyId}`); } } + if (decodedHash.startsWith("#affiliate=")) { + const affiliateCode = decodedHash.replace("#affiliate=", ""); + strip(); + if (affiliateCode) { + this.patternsModal.open(affiliateCode); + } + } } private async handleJoinLobby(event: CustomEvent) { diff --git a/src/client/TerritoryPatternsModal.ts b/src/client/TerritoryPatternsModal.ts index 995246f37..53357f792 100644 --- a/src/client/TerritoryPatternsModal.ts +++ b/src/client/TerritoryPatternsModal.ts @@ -27,6 +27,8 @@ export class TerritoryPatternsModal extends LitElement { private isActive = false; + private affiliateCode: string | null = null; + constructor() { super(); } @@ -51,6 +53,17 @@ export class TerritoryPatternsModal extends LitElement { private renderPatternGrid(): TemplateResult { const buttons: TemplateResult[] = []; for (const [name, pattern] of this.patterns) { + if (this.affiliateCode === null) { + if (pattern.affiliateCode !== null && pattern.product !== null) { + // Patterns with affiliate code are not for sale by default. + continue; + } + } else { + if (pattern.affiliateCode !== this.affiliateCode) { + continue; + } + } + buttons.push(html` - this.selectPattern(null)} - > + ${this.affiliateCode === null + ? html` + this.selectPattern(null)} + > + ` + : html``} ${buttons} `; @@ -86,13 +103,15 @@ export class TerritoryPatternsModal extends LitElement { `; } - public async open() { + public async open(affiliateCode?: string) { this.isActive = true; + this.affiliateCode = affiliateCode ?? null; await this.refresh(); } public close() { this.isActive = false; + this.affiliateCode = null; this.modalEl?.close(); } diff --git a/src/core/CosmeticSchemas.ts b/src/core/CosmeticSchemas.ts index f52c811c1..5ff3d23fc 100644 --- a/src/core/CosmeticSchemas.ts +++ b/src/core/CosmeticSchemas.ts @@ -44,6 +44,7 @@ export const PatternSchema = z export const PatternInfoSchema = z.object({ name: PatternNameSchema, pattern: PatternSchema, + affiliateCode: z.string().nullable(), product: ProductSchema.nullable(), }); From 27837012cf57e1172b03d4c05723332b67699ec3 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Sun, 7 Sep 2025 19:42:53 -0700 Subject: [PATCH 04/20] perf: Update myplayer only on tick (#2029) ## Description: During replays, myPlayer() was undefined, causing to search through all clients an each call. Instead just update myPlayer on on tick/update. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- src/core/game/GameView.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 0956542fa..fbb35472d 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -443,6 +443,9 @@ export class GameView implements GameMap { ); } }); + + this._myPlayer ??= this.playerByClientID(this._myClientID); + for (const unit of this._units.values()) { unit._wasUpdated = false; unit.lastPos = unit.lastPos.slice(-1); @@ -503,7 +506,6 @@ export class GameView implements GameMap { } myPlayer(): PlayerView | null { - this._myPlayer ??= this.playerByClientID(this._myClientID); return this._myPlayer; } From 043462e28aa75ed494a337971ea89337e764111c Mon Sep 17 00:00:00 2001 From: evanpelle Date: Mon, 8 Sep 2025 16:36:20 -0700 Subject: [PATCH 05/20] Archive games by using the api service endpoint instead of R2 (#2030) ## Description: This removes the dependencies on R2, and allows contributors to replay games without R2 access. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- .github/workflows/deploy.yml | 1 + .github/workflows/release.yml | 4 + deploy.sh | 1 + example.env | 3 + src/core/Schemas.ts | 5 +- src/core/configuration/Config.ts | 1 + src/core/configuration/DefaultConfig.ts | 4 + src/core/configuration/DevConfig.ts | 4 + src/server/Archive.ts | 198 +++++------------------- tests/util/TestServerConfig.ts | 3 + 10 files changed, 63 insertions(+), 161 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 164712bcc..c40facca2 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -120,6 +120,7 @@ jobs: R2_ACCESS_KEY: ${{ secrets.R2_ACCESS_KEY }} R2_BUCKET: ${{ secrets.R2_BUCKET }} R2_SECRET_KEY: ${{ secrets.R2_SECRET_KEY }} + API_KEY: ${{ secrets.API_KEY }} SERVER_HOST_MASTERS: ${{ secrets.SERVER_HOST_MASTERS }} SERVER_HOST_NBG1: ${{ secrets.SERVER_HOST_NBG1 }} SERVER_HOST_STAGING: ${{ secrets.SERVER_HOST_STAGING }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b1674e15f..ffe2664d4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -77,6 +77,7 @@ jobs: R2_ACCESS_KEY: ${{ secrets.R2_ACCESS_KEY }} R2_BUCKET: ${{ secrets.R2_BUCKET }} R2_SECRET_KEY: ${{ secrets.R2_SECRET_KEY }} + API_KEY: ${{ secrets.API_KEY }} SERVER_HOST_STAGING: ${{ secrets.SERVER_HOST_STAGING }} SSH_KEY: ~/.ssh/id_rsa run: | @@ -133,6 +134,7 @@ jobs: R2_ACCESS_KEY: ${{ secrets.R2_ACCESS_KEY }} R2_BUCKET: ${{ secrets.R2_BUCKET }} R2_SECRET_KEY: ${{ secrets.R2_SECRET_KEY }} + API_KEY: ${{ secrets.API_KEY }} SERVER_HOST_NBG1: ${{ secrets.SERVER_HOST_NBG1 }} SSH_KEY: ~/.ssh/id_rsa run: | @@ -189,6 +191,7 @@ jobs: R2_ACCESS_KEY: ${{ secrets.R2_ACCESS_KEY }} R2_BUCKET: ${{ secrets.R2_BUCKET }} R2_SECRET_KEY: ${{ secrets.R2_SECRET_KEY }} + API_KEY: ${{ secrets.API_KEY }} SERVER_HOST_NBG1: ${{ secrets.SERVER_HOST_NBG1 }} SSH_KEY: ~/.ssh/id_rsa run: | @@ -245,6 +248,7 @@ jobs: R2_ACCESS_KEY: ${{ secrets.R2_ACCESS_KEY }} R2_BUCKET: ${{ secrets.R2_BUCKET }} R2_SECRET_KEY: ${{ secrets.R2_SECRET_KEY }} + API_KEY: ${{ secrets.API_KEY }} SERVER_HOST_NBG1: ${{ secrets.SERVER_HOST_NBG1 }} SSH_KEY: ~/.ssh/id_rsa run: | diff --git a/deploy.sh b/deploy.sh index 2d9eb8645..6cef45e4d 100755 --- a/deploy.sh +++ b/deploy.sh @@ -176,6 +176,7 @@ R2_ACCESS_KEY=$R2_ACCESS_KEY R2_SECRET_KEY=$R2_SECRET_KEY R2_BUCKET=$R2_BUCKET CF_API_TOKEN=$CF_API_TOKEN +API_KEY=$API_KEY DOMAIN=$DOMAIN SUBDOMAIN=$SUBDOMAIN OTEL_USERNAME=$OTEL_USERNAME diff --git a/example.env b/example.env index d186dab02..0ba76e79a 100644 --- a/example.env +++ b/example.env @@ -19,6 +19,9 @@ R2_ACCESS_KEY=your_r2_access_key R2_SECRET_KEY=your_r2_secret_key R2_BUCKET=your-bucket-name +# API Key +API_KEY=your_api_key_here + # Server Hosts SERVER_HOST_STAGING=123.456.78.90 SERVER_HOST_EU=123.456.78.91 diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index ee06566ed..c1f1ff79f 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -507,7 +507,10 @@ export const GameEndInfoSchema = GameStartInfoSchema.extend({ }); export type GameEndInfo = z.infer; -const GitCommitSchema = z.string().regex(/^[0-9a-fA-F]{40}$/); +const GitCommitSchema = z + .string() + .regex(/^[0-9a-fA-F]{40}$/) + .or(z.literal("DEV")); export const AnalyticsRecordSchema = z.object({ info: GameEndInfoSchema, diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index a97dc2811..7a391b028 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -48,6 +48,7 @@ export interface ServerConfig { r2Endpoint(): string; r2AccessKey(): string; r2SecretKey(): string; + apiKey(): string; otelEndpoint(): string; otelAuthHeader(): string; otelEnabled(): boolean; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 07aa45a0f..742efe245 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -153,6 +153,10 @@ export abstract class DefaultServerConfig implements ServerConfig { return process.env.R2_BUCKET ?? ""; } + apiKey(): string { + return process.env.API_KEY ?? ""; + } + adminHeader(): string { return "x-admin-key"; } diff --git a/src/core/configuration/DevConfig.ts b/src/core/configuration/DevConfig.ts index 3a14b39bb..d530795c5 100644 --- a/src/core/configuration/DevConfig.ts +++ b/src/core/configuration/DevConfig.ts @@ -9,6 +9,10 @@ export class DevServerConfig extends DefaultServerConfig { return "WARNING_DEV_ADMIN_KEY_DO_NOT_USE_IN_PRODUCTION"; } + apiKey(): string { + return "WARNING_DEV_API_KEY_DO_NOT_USE_IN_PRODUCTION"; + } + env(): GameEnv { return GameEnv.Dev; } diff --git a/src/server/Archive.ts b/src/server/Archive.ts index 6b3675d94..7a29f0f3c 100644 --- a/src/server/Archive.ts +++ b/src/server/Archive.ts @@ -1,186 +1,64 @@ -import { S3 } from "@aws-sdk/client-s3"; import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; -import { AnalyticsRecord, GameID, GameRecord } from "../core/Schemas"; -import { replacer } from "../core/Util"; +import { GameID, GameRecord, GameRecordSchema, ID } from "../core/Schemas"; import { logger } from "./Logger"; const config = getServerConfigFromServer(); const log = logger.child({ component: "Archive" }); -// R2 client configuration -const r2 = new S3({ - region: "auto", // R2 ignores region, but it's required by the SDK - endpoint: config.r2Endpoint(), - credentials: { - accessKeyId: config.r2AccessKey(), - secretAccessKey: config.r2SecretKey(), - }, -}); - -const bucket = config.r2Bucket(); -const gameFolder = "games"; -const analyticsFolder = "analytics"; - export async function archive(gameRecord: GameRecord) { try { gameRecord.gitCommit = config.gitCommit(); - // Archive to R2 - await archiveAnalyticsToR2(gameRecord); - - // Archive full game if there are turns - if (gameRecord.turns.length > 0) { - log.info( - `${gameRecord.info.gameID}: game has more than zero turns, attempting to write to full game to R2`, - ); - await archiveFullGameToR2(gameRecord); - } - } catch (error: unknown) { - // If the error is not an instance of Error, log it as a string - if (!(error instanceof Error)) { - log.error( - `${gameRecord.info.gameID}: Final archive error. Non-Error type: ${String(error)}`, - ); + const url = `${config.jwtIssuer()}/game/${gameRecord.info.gameID}`; + const response = await fetch(url, { + method: "POST", + body: JSON.stringify(gameRecord), + headers: { + "Content-Type": "application/json", + "x-api-key": config.apiKey(), + }, + }); + if (!response.ok) { + log.error(`error archiving game record: ${response.statusText}`, { + gameID: gameRecord.info.gameID, + }); return; } - - const { message, stack, name } = error; - log.error(`${gameRecord.info.gameID}: Final archive error: ${error}`, { - message: message, - stack: stack, - name: name, - ...(error && typeof error === "object" ? error : {}), - }); - } -} - -async function archiveAnalyticsToR2(gameRecord: GameRecord) { - // Create analytics data object - const { info, version, gitCommit, subdomain, domain } = gameRecord; - const analyticsData: AnalyticsRecord = { - info, - version, - gitCommit, - subdomain, - domain, - }; - - try { - // Store analytics data using just the game ID as the key - const analyticsKey = `${info.gameID}.json`; - - await r2.putObject({ - Bucket: bucket, - Key: `${analyticsFolder}/${analyticsKey}`, - Body: JSON.stringify(analyticsData, replacer), - ContentType: "application/json", - }); - - log.info(`${info.gameID}: successfully wrote game analytics to R2`); - } catch (error: unknown) { - // If the error is not an instance of Error, log it as a string - if (!(error instanceof Error)) { - log.error( - `${gameRecord.info.gameID}: Error writing game analytics to R2. Non-Error type: ${String(error)}`, - ); - return; - } - - const { message, stack, name } = error; - log.error(`${info.gameID}: Error writing game analytics to R2: ${error}`, { - message: message, - stack: stack, - name: name, - ...(error && typeof error === "object" ? error : {}), - }); - throw error; - } -} - -async function archiveFullGameToR2(gameRecord: GameRecord) { - // Create a deep copy to avoid modifying the original - const recordCopy = structuredClone(gameRecord); - - // Players may see this so make sure to clear PII - recordCopy.info.players.forEach((p) => { - p.persistentID = "REDACTED"; - }); - - try { - await r2.putObject({ - Bucket: bucket, - Key: `${gameFolder}/${recordCopy.info.gameID}`, - Body: JSON.stringify(recordCopy, replacer), - ContentType: "application/json", - }); } catch (error) { - log.error(`error saving game ${gameRecord.info.gameID}`); - throw error; + log.error(`error archiving game record: ${error}`, { + gameID: gameRecord.info.gameID, + }); + return; } - - log.info(`${gameRecord.info.gameID}: game record successfully written to R2`); } export async function readGameRecord( gameId: GameID, ): Promise { try { - // Check if file exists and download in one operation - const response = await r2.getObject({ - Bucket: bucket, - Key: `${gameFolder}/${gameId}`, // Fixed - needed to include gameFolder - }); - // Parse the response body - if (response.Body === undefined) return null; - const bodyContents = await response.Body.transformToString(); - return JSON.parse(bodyContents) as GameRecord; - } catch (error: unknown) { - // If the error is not an instance of Error, log it as a string - if (!(error instanceof Error)) { - log.error( - `${gameId}: Error reading game record from R2. Non-Error type: ${String(error)}`, - ); + if (!ID.safeParse(gameId).success) { + log.error(`invalid game ID: ${gameId}`); return null; } - const { message, stack, name } = error; - // Log the error for monitoring purposes - log.error(`${gameId}: Error reading game record from R2: ${error}`, { - message: message, - stack: stack, - name: name, - ...(error && typeof error === "object" ? error : {}), + const url = `${config.jwtIssuer()}/game/${gameId}`; + const response = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + const record = await response.json(); + if (!response.ok) { + log.error(`error reading game record: ${response.statusText}`, { + gameID: gameId, + }); + return null; + } + return GameRecordSchema.parse(record); + } catch (error) { + log.error(`error reading game record: ${error}`, { + gameID: gameId, }); - - // Return null instead of throwing the error return null; } } - -export async function gameRecordExists(gameId: GameID): Promise { - try { - await r2.headObject({ - Bucket: bucket, - Key: `${gameFolder}/${gameId}`, // Fixed - needed to include gameFolder - }); - return true; - } catch (error: unknown) { - // If the error is not an instance of Error, log it as a string - if (!(error instanceof Error)) { - log.error( - `${gameId}: Error checking archive existence. Non-Error type: ${String(error)}`, - ); - return false; - } - const { message, stack, name } = error; - if (name === "NotFound") { - return false; - } - log.error(`${gameId}: Error checking archive existence: ${error}`, { - message: message, - stack: stack, - name: name, - ...(error && typeof error === "object" ? error : {}), - }); - return false; - } -} diff --git a/tests/util/TestServerConfig.ts b/tests/util/TestServerConfig.ts index 86a277d65..6488dc99b 100644 --- a/tests/util/TestServerConfig.ts +++ b/tests/util/TestServerConfig.ts @@ -4,6 +4,9 @@ import { GameMapType } from "../../src/core/game/Game"; import { GameID } from "../../src/core/Schemas"; export class TestServerConfig implements ServerConfig { + apiKey(): string { + throw new Error("Method not implemented."); + } allowedFlares(): string[] | undefined { throw new Error("Method not implemented."); } From defb6bb1d441774bd91e5805c97802f55f7dd19e Mon Sep 17 00:00:00 2001 From: evanpelle Date: Mon, 8 Sep 2025 18:14:08 -0700 Subject: [PATCH 06/20] Store full game for singleplayer, add more validation (#2031) ## Description: onunload allows up to 64kb, but reducing the number of hash messages and compressing using gzip, we can reduce the GameRecord size to stay under 64kb. I played a 10 minute game and the compressed GameRecord was only a few kb. Also verify the game is singleplayer and has only 1 player ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- package-lock.json | 42 ++++++++++----------- package.json | 2 + src/client/ClientGameRunner.ts | 6 +-- src/client/LocalServer.ts | 68 ++++++++++++++++++++++++++++------ src/client/Transport.ts | 4 +- src/server/Worker.ts | 48 ++++++++++++++++++------ 6 files changed, 119 insertions(+), 51 deletions(-) diff --git a/package-lock.json b/package-lock.json index acaa7eaff..af38b8920 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,8 +16,10 @@ "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.32.0", "@opentelemetry/winston-transport": "^0.11.0", + "@types/compression": "^1.8.1", "colord": "^2.9.3", "colorjs.io": "^0.5.2", + "compression": "^1.8.1", "dompurify": "^3.1.7", "dotenv": "^16.5.0", "express": "^4.21.1", @@ -6323,7 +6325,6 @@ "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dev": true, "license": "MIT", "dependencies": { "@types/connect": "*", @@ -6347,11 +6348,20 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==", + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/node": "*" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -6699,7 +6709,6 @@ "version": "4.17.23", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", @@ -6712,7 +6721,6 @@ "version": "4.19.6", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -6760,7 +6768,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, "license": "MIT" }, "node_modules/@types/http-proxy": { @@ -6851,7 +6858,6 @@ "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, "license": "MIT" }, "node_modules/@types/msgpack5": { @@ -6901,14 +6907,12 @@ "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/readable-stream": { @@ -6939,7 +6943,6 @@ "version": "0.17.5", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", - "dev": true, "license": "MIT", "dependencies": { "@types/mime": "^1", @@ -6960,7 +6963,6 @@ "version": "1.15.8", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz", "integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==", - "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", @@ -9134,7 +9136,6 @@ "version": "2.0.18", "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", - "dev": true, "license": "MIT", "dependencies": { "mime-db": ">= 1.43.0 < 2" @@ -9144,17 +9145,16 @@ } }, "node_modules/compression": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.0.tgz", - "integrity": "sha512-k6WLKfunuqCYD3t6AsuPGvQWaKwuLLh2/xHNcX4qE+vIfDNXpSqnrhwA7O53R7WVQUnt8dVAIW+YHr7xTgOgGA==", - "dev": true, + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", "license": "MIT", "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", - "on-headers": "~1.0.2", + "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" }, @@ -9166,7 +9166,6 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "2.0.0" @@ -9176,14 +9175,12 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, "license": "MIT" }, "node_modules/compression/node_modules/negotiator": { "version": "0.6.4", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -15916,10 +15913,9 @@ } }, "node_modules/on-headers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", - "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", - "dev": true, + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", "license": "MIT", "engines": { "node": ">= 0.8" diff --git a/package.json b/package.json index d86bcd6e4..ff51e1e23 100644 --- a/package.json +++ b/package.json @@ -107,8 +107,10 @@ "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.32.0", "@opentelemetry/winston-transport": "^0.11.0", + "@types/compression": "^1.8.1", "colord": "^2.9.3", "colorjs.io": "^0.5.2", + "compression": "^1.8.1", "dompurify": "^3.1.7", "dotenv": "^16.5.0", "express": "^4.21.1", diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index c6f17706b..68ebb2077 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -271,7 +271,7 @@ export class ClientGameRunner { this.lobby.clientID, ); console.error(gu.stack); - this.stop(true); + this.stop(); return; } this.transport.turnComplete(); @@ -361,12 +361,12 @@ export class ClientGameRunner { this.transport.connect(onconnect, onmessage); } - public stop(saveFullGame: boolean = false) { + public stop() { if (!this.isActive) return; this.isActive = false; this.worker.cleanup(); - this.transport.leaveGame(saveFullGame); + this.transport.leaveGame(); if (this.connectionCheckInterval) { clearInterval(this.connectionCheckInterval); this.connectionCheckInterval = null; diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index 24cf46782..5f73f9c05 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -103,8 +103,10 @@ export class LocalServer { } if (clientMsg.type === "hash") { if (!this.lobbyConfig.gameRecord) { - // If we are playing a singleplayer then store hash. - this.turns[clientMsg.turnNumber].hash = clientMsg.hash; + if (clientMsg.turnNumber % 100 === 0) { + // In singleplayer, only store hash every 100 turns to reduce size of game record. + this.turns[clientMsg.turnNumber].hash = clientMsg.hash; + } return; } // If we are replaying a game then verify hash. @@ -169,7 +171,7 @@ export class LocalServer { }); } - public endGame(saveFullGame: boolean = false) { + public endGame() { console.log("local server ending game"); clearInterval(this.turnCheckInterval); if (this.isReplay) { @@ -196,23 +198,65 @@ export class LocalServer { this.winner?.winner, this.lobbyConfig.serverConfig, ); - if (!saveFullGame) { - // Clear turns because beacon only supports up to 64kb - record.turns = []; - } - // For unload events, sendBeacon is the only reliable method + const result = GameRecordSchema.safeParse(record); if (!result.success) { const error = z.prettifyError(result.error); console.error("Error parsing game record", error); return; } - const blob = new Blob([JSON.stringify(result.data, replacer)], { - type: "application/json", - }); const workerPath = this.lobbyConfig.serverConfig.workerPath( this.lobbyConfig.gameStartInfo.gameID, ); - navigator.sendBeacon(`/${workerPath}/api/archive_singleplayer_game`, blob); + + const jsonString = JSON.stringify(result.data, replacer); + + compress(jsonString) + .then((compressedData) => { + return fetch(`/${workerPath}/api/archive_singleplayer_game`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Encoding": "gzip", + }, + body: compressedData, + keepalive: true, // Ensures request completes even if page unloads + }); + }) + .catch((error) => { + console.error("Failed to archive singleplayer game:", error); + }); } } + +async function compress(data: string): Promise { + const stream = new CompressionStream("gzip"); + const writer = stream.writable.getWriter(); + const reader = stream.readable.getReader(); + + // Write the data to the compression stream + writer.write(new TextEncoder().encode(data)); + writer.close(); + + // Read the compressed data + const chunks: Uint8Array[] = []; + let done = false; + while (!done) { + const { value, done: readerDone } = await reader.read(); + done = readerDone; + if (value) { + chunks.push(value); + } + } + + // Combine all chunks into a single Uint8Array + const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0); + const compressedData = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + compressedData.set(chunk, offset); + offset += chunk.length; + } + + return compressedData; +} diff --git a/src/client/Transport.ts b/src/client/Transport.ts index abd0410ff..b49419881 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -373,9 +373,9 @@ export class Transport { } satisfies ClientJoinMessage); } - leaveGame(saveFullGame: boolean = false) { + leaveGame() { if (this.isLocal) { - this.localServer.endGame(saveFullGame); + this.localServer.endGame(); return; } this.stopPing(); diff --git a/src/server/Worker.ts b/src/server/Worker.ts index ae6e413e7..6bcf46d99 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -1,3 +1,4 @@ +import compression from "compression"; import express, { NextFunction, Request, Response } from "express"; import rateLimit from "express-rate-limit"; import http from "http"; @@ -81,6 +82,7 @@ export async function startWorker() { }); app.set("trust proxy", 3); + app.use(compression()); app.use(express.json()); app.use(express.static(path.join(__dirname, "../../out"))); app.use( @@ -247,18 +249,42 @@ export async function startWorker() { }); app.post("/api/archive_singleplayer_game", async (req, res) => { - const result = GameRecordSchema.safeParse(req.body); - if (!result.success) { - const error = z.prettifyError(result.error); - log.info(error); - return res.status(400).json({ error }); - } + try { + const record = req.body; - const gameRecord: GameRecord = result.data; - archive(gameRecord); - res.json({ - success: true, - }); + const result = GameRecordSchema.safeParse(record); + if (!result.success) { + const error = z.prettifyError(result.error); + log.info(error); + return res.status(400).json({ error }); + } + const gameRecord: GameRecord = result.data; + + if (gameRecord.info.config.gameType !== GameType.Singleplayer) { + log.warn( + `cannot archive singleplayer with game type ${gameRecord.info.config.gameType}`, + { + gameID: gameRecord.info.gameID, + }, + ); + return res.status(400).json({ error: "Invalid request" }); + } + + if (result.data.info.players.length !== 1) { + log.warn(`cannot archive singleplayer game multiple players`, { + gameID: gameRecord.info.gameID, + }); + return res.status(400).json({ error: "Invalid request" }); + } + + archive(gameRecord); + res.json({ + success: true, + }); + } catch (error) { + log.error("Error processing archive request:", error); + res.status(500).json({ error: "Internal server error" }); + } }); app.post("/api/kick_player/:gameID/:clientID", async (req, res) => { From fd0fbfab9efeb856282af010deae93113a403500 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Tue, 9 Sep 2025 14:37:06 -0700 Subject: [PATCH 07/20] Fix archive (#2035) ## Description: Describe the PR. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- src/client/ClientGameRunner.ts | 5 ++--- src/client/LocalPersistantStats.ts | 6 +++--- src/client/LocalServer.ts | 13 ++++++++----- src/core/Schemas.ts | 18 ++++++++++++++++-- src/core/Util.ts | 18 +++++------------- src/server/Archive.ts | 28 ++++++++++++++++++++++++++-- src/server/GameServer.ts | 27 ++++++++++++++------------- src/server/Worker.ts | 15 +++++++++------ 8 files changed, 83 insertions(+), 47 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 68ebb2077..e4eb7e162 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -8,7 +8,7 @@ import { PlayerRecord, ServerMessage, } from "../core/Schemas"; -import { createGameRecord } from "../core/Util"; +import { createPartialGameRecord } from "../core/Util"; import { ServerConfig } from "../core/configuration/Config"; import { getConfig } from "../core/configuration/ConfigLoader"; import { PlayerActions, UnitType } from "../core/game/Game"; @@ -221,7 +221,7 @@ export class ClientGameRunner { if (this.lobby.gameStartInfo === undefined) { throw new Error("missing gameStartInfo"); } - const record = createGameRecord( + const record = createPartialGameRecord( this.lobby.gameStartInfo.gameID, this.lobby.gameStartInfo.config, players, @@ -230,7 +230,6 @@ export class ClientGameRunner { startTime(), Date.now(), update.winner, - this.lobby.serverConfig, ); endGame(record); } diff --git a/src/client/LocalPersistantStats.ts b/src/client/LocalPersistantStats.ts index c6dd1df5a..f1e78b924 100644 --- a/src/client/LocalPersistantStats.ts +++ b/src/client/LocalPersistantStats.ts @@ -1,11 +1,11 @@ -import { GameConfig, GameID, GameRecord } from "../core/Schemas"; +import { GameConfig, GameID, PartialGameRecord } from "../core/Schemas"; import { replacer } from "../core/Util"; export interface LocalStatsData { [key: GameID]: { lobby: Partial; // Only once the game is over - gameRecord?: GameRecord; + gameRecord?: PartialGameRecord; }; } @@ -41,7 +41,7 @@ export function startTime() { return _startTime; } -export function endGame(gameRecord: GameRecord) { +export function endGame(gameRecord: PartialGameRecord) { if (localStorage === undefined) { return; } diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index 5f73f9c05..b1aa7a100 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -4,14 +4,18 @@ import { AllPlayersStats, ClientMessage, ClientSendWinnerMessage, - GameRecordSchema, Intent, + PartialGameRecordSchema, PlayerRecord, ServerMessage, ServerStartGameMessage, Turn, } from "../core/Schemas"; -import { createGameRecord, decompressGameRecord, replacer } from "../core/Util"; +import { + createPartialGameRecord, + decompressGameRecord, + replacer, +} from "../core/Util"; import { LobbyConfig } from "./ClientGameRunner"; import { ReplaySpeedChangeEvent } from "./InputHandler"; import { getPersistentID } from "./Main"; @@ -188,7 +192,7 @@ export class LocalServer { if (this.lobbyConfig.gameStartInfo === undefined) { throw new Error("missing gameStartInfo"); } - const record = createGameRecord( + const record = createPartialGameRecord( this.lobbyConfig.gameStartInfo.gameID, this.lobbyConfig.gameStartInfo.config, players, @@ -196,10 +200,9 @@ export class LocalServer { this.startedAt, Date.now(), this.winner?.winner, - this.lobbyConfig.serverConfig, ); - const result = GameRecordSchema.safeParse(record); + const result = PartialGameRecordSchema.safeParse(record); if (!result.success) { const error = z.prettifyError(result.error); console.error("Error parsing game record", error); diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index c1f1ff79f..abbaa1792 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -492,7 +492,7 @@ export const ClientMessageSchema = z.discriminatedUnion("type", [ // export const PlayerRecordSchema = PlayerSchema.extend({ - persistentID: PersistentIdSchema, // WARNING: PII + persistentID: PersistentIdSchema.nullable(), // WARNING: PII stats: PlayerStatsSchema, }); export type PlayerRecord = z.infer; @@ -512,16 +512,30 @@ const GitCommitSchema = z .regex(/^[0-9a-fA-F]{40}$/) .or(z.literal("DEV")); -export const AnalyticsRecordSchema = z.object({ +export const PartialAnalyticsRecordSchema = z.object({ info: GameEndInfoSchema, version: z.literal("v0.0.2"), +}); +export type ClientAnalyticsRecord = z.infer< + typeof PartialAnalyticsRecordSchema +>; + +export const AnalyticsRecordSchema = PartialAnalyticsRecordSchema.extend({ gitCommit: GitCommitSchema, subdomain: z.string(), domain: z.string(), }); + export type AnalyticsRecord = z.infer; export const GameRecordSchema = AnalyticsRecordSchema.extend({ turns: TurnSchema.array(), }); + +export const PartialGameRecordSchema = PartialAnalyticsRecordSchema.extend({ + turns: TurnSchema.array(), +}); + +export type PartialGameRecord = z.infer; + export type GameRecord = z.infer; diff --git a/src/core/Util.ts b/src/core/Util.ts index 0ded7f5de..8d30c1a23 100644 --- a/src/core/Util.ts +++ b/src/core/Util.ts @@ -6,12 +6,12 @@ import { GameConfig, GameID, GameRecord, + PartialGameRecord, PlayerRecord, Turn, Winner, } from "./Schemas"; -import { ServerConfig } from "./configuration/Config"; import { BOT_NAME_PREFIXES, BOT_NAME_SUFFIXES, @@ -150,7 +150,7 @@ export function onlyImages(html: string) { }); } -export function createGameRecord( +export function createPartialGameRecord( gameID: GameID, config: GameConfig, // username does not need to be set. @@ -159,18 +159,13 @@ export function createGameRecord( start: number, end: number, winner: Winner, - serverConfig: ServerConfig, -): GameRecord { +): PartialGameRecord { const duration = Math.floor((end - start) / 1000); - const version = "v0.0.2"; - const gitCommit = serverConfig.gitCommit(); - const subdomain = serverConfig.subdomain(); - const domain = serverConfig.domain(); const num_turns = allTurns.length; const turns = allTurns.filter( (t) => t.intents.length !== 0 || t.hash !== undefined, ); - const record: GameRecord = { + const record: PartialGameRecord = { info: { gameID, config, @@ -181,10 +176,7 @@ export function createGameRecord( num_turns, winner, }, - version, - gitCommit, - subdomain, - domain, + version: "v0.0.2", turns, }; return record; diff --git a/src/server/Archive.ts b/src/server/Archive.ts index 7a29f0f3c..4361014fe 100644 --- a/src/server/Archive.ts +++ b/src/server/Archive.ts @@ -1,5 +1,12 @@ +import z from "zod"; import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; -import { GameID, GameRecord, GameRecordSchema, ID } from "../core/Schemas"; +import { + GameID, + GameRecord, + GameRecordSchema, + ID, + PartialGameRecord, +} from "../core/Schemas"; import { logger } from "./Logger"; const config = getServerConfigFromServer(); @@ -8,7 +15,13 @@ const log = logger.child({ component: "Archive" }); export async function archive(gameRecord: GameRecord) { try { - gameRecord.gitCommit = config.gitCommit(); + const parsed = GameRecordSchema.safeParse(gameRecord); + if (!parsed.success) { + log.error(`invalid game record: ${z.prettifyError(parsed.error)}`, { + gameID: gameRecord.info.gameID, + }); + return; + } const url = `${config.jwtIssuer()}/game/${gameRecord.info.gameID}`; const response = await fetch(url, { method: "POST", @@ -62,3 +75,14 @@ export async function readGameRecord( return null; } } + +export function finalizeGameRecord( + clientRecord: PartialGameRecord, +): GameRecord { + return { + ...clientRecord, + gitCommit: config.gitCommit(), + subdomain: config.subdomain(), + domain: config.domain(), + }; +} diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index c4d917e14..edd1abdcd 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -2,6 +2,8 @@ import ipAnonymize from "ip-anonymize"; import { Logger } from "winston"; import WebSocket from "ws"; import { z } from "zod"; +import { GameEnv, ServerConfig } from "../core/configuration/Config"; +import { GameType } from "../core/game/Game"; import { ClientID, ClientMessageSchema, @@ -19,10 +21,8 @@ import { ServerTurnMessage, Turn, } from "../core/Schemas"; -import { createGameRecord } from "../core/Util"; -import { GameEnv, ServerConfig } from "../core/configuration/Config"; -import { GameType } from "../core/game/Game"; -import { archive } from "./Archive"; +import { createPartialGameRecord } from "../core/Util"; +import { archive, finalizeGameRecord } from "./Archive"; import { Client } from "./Client"; export enum GamePhase { Lobby = "LOBBY", @@ -680,15 +680,16 @@ export class GameServer { }, ); archive( - createGameRecord( - this.id, - this.gameStartInfo.config, - playerRecords, - this.turns, - this._startTime ?? 0, - Date.now(), - this.winner?.winner, - this.config, + finalizeGameRecord( + createPartialGameRecord( + this.id, + this.gameStartInfo.config, + playerRecords, + this.turns, + this._startTime ?? 0, + Date.now(), + this.winner?.winner, + ), ), ); } diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 6bcf46d99..54bf28cd3 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -12,13 +12,12 @@ import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; import { GameType } from "../core/game/Game"; import { ClientMessageSchema, - GameRecord, - GameRecordSchema, ID, + PartialGameRecordSchema, ServerErrorMessage, } from "../core/Schemas"; import { CreateGameInputSchema, GameInputSchema } from "../core/WorkerSchemas"; -import { archive, readGameRecord } from "./Archive"; +import { archive, finalizeGameRecord, readGameRecord } from "./Archive"; import { Client } from "./Client"; import { GameManager } from "./GameManager"; import { getUserMe, verifyClientToken } from "./jwt"; @@ -252,13 +251,13 @@ export async function startWorker() { try { const record = req.body; - const result = GameRecordSchema.safeParse(record); + const result = PartialGameRecordSchema.safeParse(record); if (!result.success) { const error = z.prettifyError(result.error); log.info(error); return res.status(400).json({ error }); } - const gameRecord: GameRecord = result.data; + const gameRecord = result.data; if (gameRecord.info.config.gameType !== GameType.Singleplayer) { log.warn( @@ -277,7 +276,11 @@ export async function startWorker() { return res.status(400).json({ error: "Invalid request" }); } - archive(gameRecord); + log.info("archiving singleplayer game", { + gameID: gameRecord.info.gameID, + }); + + archive(finalizeGameRecord(gameRecord)); res.json({ success: true, }); From 5ffb4a41b76a1fd9c783e535ab410d7bcc32475a Mon Sep 17 00:00:00 2001 From: evanpelle Date: Tue, 9 Sep 2025 19:10:07 -0700 Subject: [PATCH 08/20] Fix back button, show join in singleplayer (#2039) ## Description: Add #refresh in the history so when back button is pressed the homepage is reloaded Add the join code in singleplayer because singleplayer games are now replayable ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- src/client/Main.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/client/Main.ts b/src/client/Main.ts index 2ac00af2d..1d435eea6 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -4,7 +4,6 @@ import { EventBus } from "../core/EventBus"; import { GameRecord, GameStartInfo, ID } from "../core/Schemas"; import { ServerConfig } from "../core/configuration/Config"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; -import { GameType } from "../core/game/Game"; import { UserSettings } from "../core/game/UserSettings"; import "./AccountModal"; import { joinLobby } from "./ClientGameRunner"; @@ -490,6 +489,9 @@ class Client { this.patternsModal.open(affiliateCode); } } + if (decodedHash.startsWith("#refresh")) { + window.location.href = "/"; + } } private async handleJoinLobby(event: CustomEvent) { @@ -575,9 +577,11 @@ class Client { (ad as HTMLElement).style.display = "none"; }); - if (lobby.gameStartInfo?.config.gameType !== GameType.Singleplayer) { - history.pushState(null, "", `#join=${lobby.gameID}`); + // Ensure there's a homepage entry in history before adding the lobby entry + if (window.location.hash === "" || window.location.hash === "#") { + history.pushState(null, "", window.location.origin + "#refresh"); } + history.pushState(null, "", `#join=${lobby.gameID}`); }, ); } From 319508c36084bbae85c4f66b4c24d4d746c047af Mon Sep 17 00:00:00 2001 From: evanpelle Date: Wed, 10 Sep 2025 07:36:42 -0700 Subject: [PATCH 09/20] bugfix: archive error, use replacer (#2041) ## Description: Use replacer when serializing GameRecord json ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- src/server/Archive.ts | 3 ++- src/server/Worker.ts | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/server/Archive.ts b/src/server/Archive.ts index 4361014fe..b540f9c8d 100644 --- a/src/server/Archive.ts +++ b/src/server/Archive.ts @@ -7,6 +7,7 @@ import { ID, PartialGameRecord, } from "../core/Schemas"; +import { replacer } from "../core/Util"; import { logger } from "./Logger"; const config = getServerConfigFromServer(); @@ -25,7 +26,7 @@ export async function archive(gameRecord: GameRecord) { const url = `${config.jwtIssuer()}/game/${gameRecord.info.gameID}`; const response = await fetch(url, { method: "POST", - body: JSON.stringify(gameRecord), + body: JSON.stringify(gameRecord, replacer), headers: { "Content-Type": "application/json", "x-api-key": config.apiKey(), diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 54bf28cd3..8187ccbf2 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -16,6 +16,7 @@ import { PartialGameRecordSchema, ServerErrorMessage, } from "../core/Schemas"; +import { replacer } from "../core/Util"; import { CreateGameInputSchema, GameInputSchema } from "../core/WorkerSchemas"; import { archive, finalizeGameRecord, readGameRecord } from "./Archive"; import { Client } from "./Client"; @@ -340,7 +341,9 @@ export async function startWorker() { // Ignore ping return; } else if (clientMsg.type !== "join") { - log.warn(`Invalid message before join: ${JSON.stringify(clientMsg)}`); + log.warn( + `Invalid message before join: ${JSON.stringify(clientMsg, replacer)}`, + ); return; } From eec3b0e2bba1a224f353f25264b21cb3fe133cf1 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Thu, 11 Sep 2025 14:07:07 -0700 Subject: [PATCH 10/20] Fetch archived games from api, allow development against production & staging (#2045) ## Description: Instead of going through the game server to fetch archived games, have the client fetch from api directly. Also loosen up cors restrictions & domain checks so localhost:9000 can talk to staging or production servers related to #1571 ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan --- README.md | 16 +++++ package.json | 2 + resources/lang/en.json | 5 +- src/client/JoinPrivateLobbyModal.ts | 105 ++++++++++++++++++---------- src/client/jwt.ts | 16 +++-- src/server/Worker.ts | 39 +---------- webpack.config.js | 1 + 7 files changed, 103 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index 99cbc6686..761bffc2a 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,22 @@ To run just the server with development settings: npm run start:server-dev ``` +### Connecting to staging or production backends + +Sometimes it's useful to connect to production servers when replaying a game, testing user profiles, purchases, or login flow. + +To connect to staging api servers: + +```bash +npm run dev:staging +``` + +To connect to production api servers: + +```bash +npm run dev:prod +``` + ## 🛠️ Development Tools - **Format code**: diff --git a/package.json b/package.json index ff51e1e23..89e61ed3a 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "start:server": "node --loader ts-node/esm --experimental-specifier-resolution=node src/server/Server.ts", "start:server-dev": "cross-env GAME_ENV=dev node --loader ts-node/esm --experimental-specifier-resolution=node src/server/Server.ts", "dev": "cross-env GAME_ENV=dev concurrently \"npm run start:client\" \"npm run start:server-dev\"", + "dev:staging": "cross-env GAME_ENV=dev API_DOMAIN=api.openfront.dev concurrently \"npm run start:client\" \"npm run start:server-dev\"", + "dev:prod": "cross-env GAME_ENV=dev API_DOMAIN=api.openfront.io concurrently \"npm run start:client\" \"npm run start:server-dev\"", "tunnel": "npm run build-prod && npm run start:server", "test": "jest", "perf": "npx tsx tests/perf/*.ts", diff --git a/resources/lang/en.json b/resources/lang/en.json index 1b5accb22..6ecacb4df 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -193,8 +193,9 @@ "join_lobby": "Join Lobby", "checking": "Checking lobby...", "not_found": "Lobby not found. Please check the ID and try again.", - "error": "An error occurred. Please try again.", - "joined_waiting": "Joined successfully! Waiting for game to start..." + "error": "An error occurred. Please try again or contact support.", + "joined_waiting": "Joined successfully! Waiting for game to start...", + "version_mismatch": "This game was created with a different version. Cannot join." }, "public_lobby": { "join": "Join next Game", diff --git a/src/client/JoinPrivateLobbyModal.ts b/src/client/JoinPrivateLobbyModal.ts index 1c875a7e7..965b268f1 100644 --- a/src/client/JoinPrivateLobbyModal.ts +++ b/src/client/JoinPrivateLobbyModal.ts @@ -1,12 +1,13 @@ import { LitElement, html } from "lit"; import { customElement, query, state } from "lit/decorators.js"; import { translateText } from "../client/Utils"; -import { GameInfo, GameRecord } from "../core/Schemas"; +import { GameInfo, GameRecordSchema } from "../core/Schemas"; import { generateID } from "../core/Util"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { JoinLobbyEvent } from "./Main"; import "./components/baseComponents/Button"; import "./components/baseComponents/Modal"; +import { getApiBase } from "./jwt"; @customElement("join-private-lobby-modal") export class JoinPrivateLobbyModal extends LitElement { @query("o-modal") private modalEl!: HTMLElement & { @@ -179,10 +180,19 @@ export class JoinPrivateLobbyModal extends LitElement { if (gameExists) return; // If not active, check archived games - const archivedGame = await this.checkArchivedGame(lobbyId); - if (archivedGame) return; - - this.message = `${translateText("private_lobby.not_found")}`; + switch (await this.checkArchivedGame(lobbyId)) { + case "success": + return; + case "not_found": + this.message = `${translateText("private_lobby.not_found")}`; + return; + case "version_mismatch": + this.message = `${translateText("private_lobby.version_mismatch")}`; + return; + case "error": + this.message = `${translateText("private_lobby.error")}`; + return; + } } catch (error) { console.error("Error checking lobby existence:", error); this.message = `${translateText("private_lobby.error")}`; @@ -222,49 +232,70 @@ export class JoinPrivateLobbyModal extends LitElement { return false; } - private async checkArchivedGame(lobbyId: string): Promise { - const config = await getServerConfigFromClient(); - const archiveUrl = `/${config.workerPath(lobbyId)}/api/archived_game/${lobbyId}`; - - const archiveResponse = await fetch(archiveUrl, { + private async checkArchivedGame( + lobbyId: string, + ): Promise<"success" | "not_found" | "version_mismatch" | "error"> { + const archivePromise = fetch(`${getApiBase()}/game/${lobbyId}`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + const gitCommitPromise = fetch(`/commit.txt`, { method: "GET", headers: { "Content-Type": "application/json" }, }); - const archiveData = await archiveResponse.json(); + const [archiveResponse, gitCommitResponse] = await Promise.all([ + archivePromise, + gitCommitPromise, + ]); - if ( - archiveData.success === false && - archiveData.error === "Version mismatch" - ) { + if (archiveResponse.status === 404) { + return "not_found"; + } + if (archiveResponse.status !== 200) { + return "error"; + } + + const archiveData = await archiveResponse.json(); + const parsed = GameRecordSchema.safeParse(archiveData); + if (!parsed.success) { + return "version_mismatch"; + } + + let myGitCommit = ""; + if (gitCommitResponse.status === 404) { + // commit.txt is not found when running locally + myGitCommit = "DEV"; + } else if (gitCommitResponse.status === 200) { + myGitCommit = await gitCommitResponse.text(); + } else { + console.error("Error getting git commit:", gitCommitResponse.status); + return "error"; + } + + // Allow DEV to join games created with a different version for debugging. + if (myGitCommit !== "DEV" && parsed.data.gitCommit !== myGitCommit) { console.warn( `Git commit hash mismatch for game ${lobbyId}`, archiveData.details, ); - this.message = - "This game was created with a different version. Cannot join."; - return true; + return "version_mismatch"; } - if (archiveData.exists) { - const gameRecord = archiveData.gameRecord as GameRecord; - - this.dispatchEvent( - new CustomEvent("join-lobby", { - detail: { - gameID: lobbyId, - gameRecord: gameRecord, - clientID: generateID(), - } as JoinLobbyEvent, - bubbles: true, - composed: true, - }), - ); - - return true; - } - - return false; + this.dispatchEvent( + new CustomEvent("join-lobby", { + detail: { + gameID: lobbyId, + gameRecord: parsed.data, + clientID: generateID(), + } as JoinLobbyEvent, + bubbles: true, + composed: true, + }), + ); + return "success"; } private async pollPlayers() { diff --git a/src/client/jwt.ts b/src/client/jwt.ts index 6ada4c9fb..bb531761d 100644 --- a/src/client/jwt.ts +++ b/src/client/jwt.ts @@ -17,9 +17,16 @@ function getAudience() { export function getApiBase() { const domainname = getAudience(); - return domainname === "localhost" - ? (localStorage.getItem("apiHost") ?? "http://localhost:8787") - : `https://api.${domainname}`; + + if (domainname === "localhost") { + const apiDomain = process?.env?.API_DOMAIN; + if (apiDomain) { + return `https://${apiDomain}`; + } + return localStorage.getItem("apiHost") ?? "http://localhost:8787"; + } + + return `https://api.${domainname}`; } function getToken(): string | null { @@ -159,7 +166,8 @@ function _isLoggedIn(): IsLoggedInResponse { logOut(); return false; } - if (aud !== getAudience()) { + const myAud = getAudience(); + if (myAud !== "localhost" && aud !== myAud) { // JWT was not issued for this website console.error( 'unexpected "aud" claim value', diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 8187ccbf2..24d6cb2cf 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -7,7 +7,6 @@ import path from "path"; import { fileURLToPath } from "url"; import { WebSocket, WebSocketServer } from "ws"; import { z } from "zod"; -import { GameEnv } from "../core/configuration/Config"; import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; import { GameType } from "../core/game/Game"; import { @@ -18,7 +17,7 @@ import { } from "../core/Schemas"; import { replacer } from "../core/Util"; import { CreateGameInputSchema, GameInputSchema } from "../core/WorkerSchemas"; -import { archive, finalizeGameRecord, readGameRecord } from "./Archive"; +import { archive, finalizeGameRecord } from "./Archive"; import { Client } from "./Client"; import { GameManager } from "./GameManager"; import { getUserMe, verifyClientToken } from "./jwt"; @@ -212,42 +211,6 @@ export async function startWorker() { res.json(game.gameInfo()); }); - app.get("/api/archived_game/:id", async (req, res) => { - const gameRecord = await readGameRecord(req.params.id); - - if (!gameRecord) { - return res.status(404).json({ - success: false, - error: "Game not found", - exists: false, - }); - } - - if ( - config.env() !== GameEnv.Dev && - gameRecord.gitCommit !== config.gitCommit() - ) { - log.warn( - `git commit mismatch for game ${req.params.id}, expected ${config.gitCommit()}, got ${gameRecord.gitCommit}`, - ); - return res.status(409).json({ - success: false, - error: "Version mismatch", - exists: true, - details: { - expectedCommit: config.gitCommit(), - actualCommit: gameRecord.gitCommit, - }, - }); - } - - return res.status(200).json({ - success: true, - exists: true, - gameRecord: gameRecord, - }); - }); - app.post("/api/archive_singleplayer_game", async (req, res) => { try { const record = req.body; diff --git a/webpack.config.js b/webpack.config.js index 0820e8cd0..bf929b3fa 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -129,6 +129,7 @@ export default async (env, argv) => { "process.env.STRIPE_PUBLISHABLE_KEY": JSON.stringify( process.env.STRIPE_PUBLISHABLE_KEY, ), + "process.env.API_DOMAIN": JSON.stringify(process.env.API_DOMAIN), }), new CopyPlugin({ patterns: [ From e26d164ef015697fb96397cc074aadc3f7f9ce88 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Thu, 11 Sep 2025 19:48:02 -0700 Subject: [PATCH 11/20] allow just otel key to avoid spaces in env variables --- src/server/Logger.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/server/Logger.ts b/src/server/Logger.ts index 25be2683e..bf83fe249 100644 --- a/src/server/Logger.ts +++ b/src/server/Logger.ts @@ -24,8 +24,9 @@ if (config.otelEnabled()) { console.log("OTEL enabled"); // Configure OpenTelemetry endpoint with basic auth (if provided) const headers: Record = {}; - headers["Authorization"] = config.otelAuthHeader(); - + // Remove "Basic" prefix from the auth header for backwards compatibility + headers["Authorization"] = + `Basic ${config.otelAuthHeader().replace("Basic", "").trim()}`; // Add OTLP exporter for logs const logExporter = new OTLPLogExporter({ url: `${config.otelEndpoint()}/v1/logs`, From 50dd0629450b49536b3cf5dee36143b3a12ef3db Mon Sep 17 00:00:00 2001 From: evanpelle Date: Thu, 11 Sep 2025 21:28:03 -0700 Subject: [PATCH 12/20] Logger: just use Basic + secret --- src/server/Logger.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/server/Logger.ts b/src/server/Logger.ts index bf83fe249..6a47b9ead 100644 --- a/src/server/Logger.ts +++ b/src/server/Logger.ts @@ -24,9 +24,7 @@ if (config.otelEnabled()) { console.log("OTEL enabled"); // Configure OpenTelemetry endpoint with basic auth (if provided) const headers: Record = {}; - // Remove "Basic" prefix from the auth header for backwards compatibility - headers["Authorization"] = - `Basic ${config.otelAuthHeader().replace("Basic", "").trim()}`; + headers["Authorization"] = "Basic " + config.otelAuthHeader(); // Add OTLP exporter for logs const logExporter = new OTLPLogExporter({ url: `${config.otelEndpoint()}/v1/logs`, From 95ad22873fcdf86aff24dede8df3fde6864daaaa Mon Sep 17 00:00:00 2001 From: evanpelle Date: Thu, 11 Sep 2025 21:29:10 -0700 Subject: [PATCH 13/20] Trim git comment to remove trailing \n --- src/client/JoinPrivateLobbyModal.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/JoinPrivateLobbyModal.ts b/src/client/JoinPrivateLobbyModal.ts index 965b268f1..f64dc5081 100644 --- a/src/client/JoinPrivateLobbyModal.ts +++ b/src/client/JoinPrivateLobbyModal.ts @@ -269,7 +269,7 @@ export class JoinPrivateLobbyModal extends LitElement { // commit.txt is not found when running locally myGitCommit = "DEV"; } else if (gitCommitResponse.status === 200) { - myGitCommit = await gitCommitResponse.text(); + myGitCommit = (await gitCommitResponse.text()).trim(); } else { console.error("Error getting git commit:", gitCommitResponse.status); return "error"; From b46d76932d9cc7013d45142fcdc94eaf9630b71c Mon Sep 17 00:00:00 2001 From: evanpelle Date: Thu, 11 Sep 2025 19:02:12 -0700 Subject: [PATCH 14/20] add falk1 server to deployment --- .github/workflows/deploy.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c40facca2..4a26134e5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -22,6 +22,7 @@ on: - masters - nbg1 - staging + - falk1 target_subdomain: description: "Deployment Subdomain" required: false @@ -93,6 +94,7 @@ jobs: env: SERVER_HOST_MASTERS: ${{ secrets.SERVER_HOST_MASTERS }} SERVER_HOST_NBG1: ${{ secrets.SERVER_HOST_NBG1 }} + SERVER_HOST_FALK1: ${{ secrets.SERVER_HOST_FALK1 }} SERVER_HOST_STAGING: ${{ secrets.SERVER_HOST_STAGING }} SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} run: | @@ -101,6 +103,7 @@ jobs: echo "${SSH_PRIVATE_KEY}" > ~/.ssh/id_rsa test -n "$SERVER_HOST_MASTERS" && ssh-keyscan -H "$SERVER_HOST_MASTERS" >> ~/.ssh/known_hosts test -n "$SERVER_HOST_NBG1" && ssh-keyscan -H "$SERVER_HOST_NBG1" >> ~/.ssh/known_hosts + test -n "$SERVER_HOST_FALK1" && ssh-keyscan -H "$SERVER_HOST_FALK1" >> ~/.ssh/known_hosts test -n "$SERVER_HOST_STAGING" && ssh-keyscan -H "$SERVER_HOST_STAGING" >> ~/.ssh/known_hosts chmod 600 ~/.ssh/id_rsa - name: 🚢 Deploy @@ -123,6 +126,7 @@ jobs: API_KEY: ${{ secrets.API_KEY }} SERVER_HOST_MASTERS: ${{ secrets.SERVER_HOST_MASTERS }} SERVER_HOST_NBG1: ${{ secrets.SERVER_HOST_NBG1 }} + SERVER_HOST_FALK1: ${{ secrets.SERVER_HOST_FALK1 }} SERVER_HOST_STAGING: ${{ secrets.SERVER_HOST_STAGING }} SSH_KEY: ~/.ssh/id_rsa VERSION_TAG: latest From 272c362182e04ef4164db1ecee4858f87345226d Mon Sep 17 00:00:00 2001 From: evanpelle Date: Thu, 11 Sep 2025 22:19:21 -0700 Subject: [PATCH 15/20] bugfix: use Basic prefix for otel metrics --- src/server/WorkerMetrics.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/WorkerMetrics.ts b/src/server/WorkerMetrics.ts index 86d320e75..6e40fb6ee 100644 --- a/src/server/WorkerMetrics.ts +++ b/src/server/WorkerMetrics.ts @@ -20,7 +20,7 @@ export function initWorkerMetrics(gameManager: GameManager): void { // Configure auth headers const headers: Record = {}; if (config.otelEnabled()) { - headers["Authorization"] = config.otelAuthHeader(); + headers["Authorization"] = "Basic " + config.otelAuthHeader(); } // Create metrics exporter From 9d24ab53ad8c22db9797c827b237d19aa762d840 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Fri, 12 Sep 2025 08:30:31 -0700 Subject: [PATCH 16/20] bugfix: use replacer when stringifying gamerecord for bigints --- src/client/ClientGameRunner.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index e4eb7e162..7645bdaad 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -8,7 +8,7 @@ import { PlayerRecord, ServerMessage, } from "../core/Schemas"; -import { createPartialGameRecord } from "../core/Util"; +import { createPartialGameRecord, replacer } from "../core/Util"; import { ServerConfig } from "../core/configuration/Config"; import { getConfig } from "../core/configuration/ConfigLoader"; import { PlayerActions, UnitType } from "../core/game/Game"; @@ -82,14 +82,18 @@ export function joinLobby( const onmessage = (message: ServerMessage) => { if (message.type === "prestart") { - console.log(`lobby: game prestarting: ${JSON.stringify(message)}`); + console.log( + `lobby: game prestarting: ${JSON.stringify(message, replacer)}`, + ); terrainLoad = loadTerrainMap(message.gameMap, terrainMapFileLoader); onPrestart(); } if (message.type === "start") { // Trigger prestart for singleplayer games onPrestart(); - console.log(`lobby: game started: ${JSON.stringify(message, null, 2)}`); + console.log( + `lobby: game started: ${JSON.stringify(message, replacer, 2)}`, + ); onJoin(); // For multiplayer games, GameStartInfo is not known until game starts. lobbyConfig.gameStartInfo = message.gameStartInfo; From ce4fd058c529e468e3a0e0320ef2dfad4a991fe0 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Fri, 12 Sep 2025 08:48:10 -0700 Subject: [PATCH 17/20] change production machine from nbg1 to falk1 --- .github/workflows/release.yml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ffe2664d4..527dc6eca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -109,13 +109,13 @@ jobs: - uses: actions/checkout@v4 - name: 🔑 Create SSH private key env: - SERVER_HOST_NBG1: ${{ secrets.SERVER_HOST_NBG1 }} + SERVER_HOST_FALK1: ${{ secrets.SERVER_HOST_FALK1 }} SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} run: | set -euxo pipefail mkdir -p ~/.ssh echo "${SSH_PRIVATE_KEY}" > ~/.ssh/id_rsa - test -n "$SERVER_HOST_NBG1" && ssh-keyscan -H "$SERVER_HOST_NBG1" >> ~/.ssh/known_hosts + test -n "$SERVER_HOST_FALK1" && ssh-keyscan -H "$SERVER_HOST_FALK1" >> ~/.ssh/known_hosts chmod 600 ~/.ssh/id_rsa - name: 🚀 Deploy image env: @@ -135,11 +135,11 @@ jobs: R2_BUCKET: ${{ secrets.R2_BUCKET }} R2_SECRET_KEY: ${{ secrets.R2_SECRET_KEY }} API_KEY: ${{ secrets.API_KEY }} - SERVER_HOST_NBG1: ${{ secrets.SERVER_HOST_NBG1 }} + SERVER_HOST_FALK1: ${{ secrets.SERVER_HOST_FALK1 }} SSH_KEY: ~/.ssh/id_rsa run: | set -euxo pipefail - ./deploy.sh prod nbg1 "${IMAGE_ID}" beta + ./deploy.sh prod falk1 "${IMAGE_ID}" beta - name: ⏳ Wait for deployment to start env: FQDN: beta.${{ vars.DOMAIN }} @@ -166,13 +166,13 @@ jobs: - uses: actions/checkout@v4 - name: 🔑 Create SSH private key env: - SERVER_HOST_NBG1: ${{ secrets.SERVER_HOST_NBG1 }} + SERVER_HOST_FALK1: ${{ secrets.SERVER_HOST_FALK1 }} SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} run: | set -euxo pipefail mkdir -p ~/.ssh echo "${SSH_PRIVATE_KEY}" > ~/.ssh/id_rsa - test -n "$SERVER_HOST_NBG1" && ssh-keyscan -H "$SERVER_HOST_NBG1" >> ~/.ssh/known_hosts + test -n "$SERVER_HOST_FALK1" && ssh-keyscan -H "$SERVER_HOST_FALK1" >> ~/.ssh/known_hosts chmod 600 ~/.ssh/id_rsa - name: 🚀 Deploy image env: @@ -192,11 +192,11 @@ jobs: R2_BUCKET: ${{ secrets.R2_BUCKET }} R2_SECRET_KEY: ${{ secrets.R2_SECRET_KEY }} API_KEY: ${{ secrets.API_KEY }} - SERVER_HOST_NBG1: ${{ secrets.SERVER_HOST_NBG1 }} + SERVER_HOST_FALK1: ${{ secrets.SERVER_HOST_FALK1 }} SSH_KEY: ~/.ssh/id_rsa run: | set -euxo pipefail - ./deploy.sh prod nbg1 "${IMAGE_ID}" blue + ./deploy.sh prod falk1 "${IMAGE_ID}" blue - name: ⏳ Wait for deployment to start env: FQDN: blue.${{ vars.DOMAIN }} @@ -223,13 +223,13 @@ jobs: - uses: actions/checkout@v4 - name: 🔑 Create SSH private key env: - SERVER_HOST_NBG1: ${{ secrets.SERVER_HOST_NBG1 }} + SERVER_HOST_FALK1: ${{ secrets.SERVER_HOST_FALK1 }} SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} run: | set -euxo pipefail mkdir -p ~/.ssh echo "${SSH_PRIVATE_KEY}" > ~/.ssh/id_rsa - test -n "$SERVER_HOST_NBG1" && ssh-keyscan -H "$SERVER_HOST_NBG1" >> ~/.ssh/known_hosts + test -n "$SERVER_HOST_FALK1" && ssh-keyscan -H "$SERVER_HOST_FALK1" >> ~/.ssh/known_hosts chmod 600 ~/.ssh/id_rsa - name: 🚀 Deploy image env: @@ -249,11 +249,11 @@ jobs: R2_BUCKET: ${{ secrets.R2_BUCKET }} R2_SECRET_KEY: ${{ secrets.R2_SECRET_KEY }} API_KEY: ${{ secrets.API_KEY }} - SERVER_HOST_NBG1: ${{ secrets.SERVER_HOST_NBG1 }} + SERVER_HOST_FALK1: ${{ secrets.SERVER_HOST_FALK1 }} SSH_KEY: ~/.ssh/id_rsa run: | set -euxo pipefail - ./deploy.sh prod nbg1 "${IMAGE_ID}" green + ./deploy.sh prod falk1 "${IMAGE_ID}" green - name: ⏳ Wait for deployment to start env: FQDN: green.${{ vars.DOMAIN }} From d522c33ba3aa2bfc3e902296b376e45129865988 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Fri, 12 Sep 2025 09:00:49 -0700 Subject: [PATCH 18/20] cache /commit.txt for only 5 seconds to prevent stale git commits --- nginx.conf | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/nginx.conf b/nginx.conf index 25c09b3d3..4732c9f80 100644 --- a/nginx.conf +++ b/nginx.conf @@ -132,6 +132,25 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + # /commit.txt endpoint - Cache for 5 seconds + location = /commit.txt { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + + # Cache configuration + proxy_cache API_CACHE; + proxy_cache_valid 200 5s; # Cache successful responses for 5 seconds + proxy_cache_use_stale error timeout http_500 http_502 http_503 http_504; + proxy_cache_lock on; + add_header X-Cache-Status $upstream_cache_status; + + # Standard proxy headers + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + # Binary files caching location ~* \.(bin|dat|exe|dll|so|dylib)$ { proxy_pass http://127.0.0.1:3000; From ab6578a6e397e2039c1f58fd707f8a86835eada2 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Fri, 12 Sep 2025 10:16:20 -0700 Subject: [PATCH 19/20] replace eu with falk1 for deployment --- build-deploy.sh | 16 ++++++++-------- deploy.sh | 14 +++++++------- example.env | 4 ++-- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/build-deploy.sh b/build-deploy.sh index c008be0df..370991b58 100755 --- a/build-deploy.sh +++ b/build-deploy.sh @@ -15,34 +15,34 @@ print_header "BUILD AND DEPLOY WRAPPER" echo "This script will run build.sh and deploy.sh in sequence." echo "You can also run them separately:" echo " ./build.sh [prod|staging] [version_tag]" -echo " ./deploy.sh [prod|staging] [eu|nbg1|staging|masters] [version_tag] [subdomain] [--enable_basic_auth]" +echo " ./deploy.sh [prod|staging] [falk1|nbg1|staging|masters] [version_tag] [subdomain] [--enable_basic_auth]" echo "" # Check command line arguments if [ $# -lt 3 ] || [ $# -gt 5 ]; then echo "Error: Please specify environment, host, and subdomain" - echo "Usage: $0 [prod|staging] [eu|nbg1|staging|masters] [subdomain] [--enable_basic_auth]" + echo "Usage: $0 [prod|staging] [falk1|nbg1|staging|masters] [subdomain] [--enable_basic_auth]" exit 1 fi # Validate first argument (environment) if [ "$1" != "prod" ] && [ "$1" != "staging" ]; then echo "Error: First argument must be either 'prod' or 'staging'" - echo "Usage: $0 [prod|staging] [eu|nbg1|staging|masters] [subdomain] [--enable_basic_auth]" + echo "Usage: $0 [prod|staging] [falk1|nbg1|staging|masters] [subdomain] [--enable_basic_auth]" exit 1 fi # Validate second argument (host) -if [ "$2" != "eu" ] && [ "$2" != "nbg1" ] && [ "$2" != "staging" ] && [ "$2" != "masters" ]; then - echo "Error: Second argument must be either 'eu', 'nbg1', 'staging', or 'masters'" - echo "Usage: $0 [prod|staging] [eu|nbg1|staging|masters] [subdomain] [--enable_basic_auth]" +if [ "$2" != "falk1" ] && [ "$2" != "nbg1" ] && [ "$2" != "staging" ] && [ "$2" != "masters" ]; then + echo "Error: Second argument must be either 'falk1', 'nbg1', 'staging', or 'masters'" + echo "Usage: $0 [prod|staging] [falk1|nbg1|staging|masters] [subdomain] [--enable_basic_auth]" exit 1 fi # Validate third argument (subdomain) if [ -z "$3" ]; then echo "Error: Subdomain is required" - echo "Usage: $0 [prod|staging] [eu|nbg1|staging|masters] [subdomain] [--enable_basic_auth]" + echo "Usage: $0 [prod|staging] [falk1|nbg1|staging|masters] [subdomain] [--enable_basic_auth]" exit 1 fi @@ -66,7 +66,7 @@ while [[ $# -gt 0 ]]; do ;; *) echo "Error: Unknown argument: $1" - echo "Usage: $0 [prod|staging] [eu|nbg1|staging|masters] [subdomain] [--enable_basic_auth]" + echo "Usage: $0 [prod|staging] [falk1|nbg1|staging|masters] [subdomain] [--enable_basic_auth]" exit 1 ;; esac diff --git a/deploy.sh b/deploy.sh index 6cef45e4d..bbacb85a0 100755 --- a/deploy.sh +++ b/deploy.sh @@ -37,21 +37,21 @@ print_header() { # Check command line arguments if [ $# -ne 4 ]; then echo "Error: Please specify environment, host, version tag, and subdomain" - echo "Usage: $0 [prod|staging] [eu|nbg1|staging|masters] [version_tag] [subdomain] [--enable_basic_auth]" + echo "Usage: $0 [prod|staging] [falk1|nbg1|staging|masters] [version_tag] [subdomain] [--enable_basic_auth]" exit 1 fi # Validate first argument (environment) if [ "$1" != "prod" ] && [ "$1" != "staging" ]; then echo "Error: First argument must be either 'prod' or 'staging'" - echo "Usage: $0 [prod|staging] [eu|nbg1|staging|masters] [version_tag] [subdomain] [--enable_basic_auth]" + echo "Usage: $0 [prod|staging] [falk1|nbg1|staging|masters] [version_tag] [subdomain] [--enable_basic_auth]" exit 1 fi # Validate second argument (host) -if [ "$2" != "eu" ] && [ "$2" != "nbg1" ] && [ "$2" != "staging" ] && [ "$2" != "masters" ]; then - echo "Error: Second argument must be either 'eu', 'nbg1', 'staging', or 'masters'" - echo "Usage: $0 [prod|staging] [eu|nbg1|staging|masters] [version_tag] [subdomain] [--enable_basic_auth]" +if [ "$2" != "falk1" ] && [ "$2" != "nbg1" ] && [ "$2" != "staging" ] && [ "$2" != "masters" ]; then + echo "Error: Second argument must be either 'falk1', 'nbg1', 'staging', or 'masters'" + echo "Usage: $0 [prod|staging] [falk1|nbg1|staging|masters] [version_tag] [subdomain] [--enable_basic_auth]" exit 1 fi @@ -97,8 +97,8 @@ elif [ "$HOST" == "masters" ]; then print_header "DEPLOYING TO MASTERS HOST" SERVER_HOST=$SERVER_HOST_MASTERS else - print_header "DEPLOYING TO EU HOST" - SERVER_HOST=$SERVER_HOST_EU + print_header "DEPLOYING TO FALK1 HOST" + SERVER_HOST=$SERVER_HOST_FALK1 fi # Check required environment variables diff --git a/example.env b/example.env index 0ba76e79a..4135026cf 100644 --- a/example.env +++ b/example.env @@ -24,8 +24,8 @@ API_KEY=your_api_key_here # Server Hosts SERVER_HOST_STAGING=123.456.78.90 -SERVER_HOST_EU=123.456.78.91 -SERVER_HOST_US=123.456.78.92 +SERVER_HOST_FALK1=123.456.78.91 +SERVER_HOST_NBG1=123.456.78.92 # Version VERSION_TAG="latest" From 25e8ec057972986b7a40e8aa51723465c02e682b Mon Sep 17 00:00:00 2001 From: evanpelle Date: Fri, 12 Sep 2025 10:27:20 -0700 Subject: [PATCH 20/20] use no-cache when retrieving commit.txt to avoid stale commit --- src/client/JoinPrivateLobbyModal.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/client/JoinPrivateLobbyModal.ts b/src/client/JoinPrivateLobbyModal.ts index f64dc5081..2c89e9804 100644 --- a/src/client/JoinPrivateLobbyModal.ts +++ b/src/client/JoinPrivateLobbyModal.ts @@ -244,6 +244,7 @@ export class JoinPrivateLobbyModal extends LitElement { const gitCommitPromise = fetch(`/commit.txt`, { method: "GET", headers: { "Content-Type": "application/json" }, + cache: "no-cache", }); const [archiveResponse, gitCommitResponse] = await Promise.all([