mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-25 16:44:36 +00:00
Add admin bot HTTP API for managing private games (#4388)
## What A trusted, server-side HTTP API so a bot authenticated with a shared secret can **create private games, change their settings, start them, kick players, and pause/resume** — without opening a WebSocket or joining as a player. Two endpoints under `/api/adminbot/`, reaching the owning worker via the existing `/wN/` nginx routing. They reuse the existing Zod schemas and `GameServer` methods, mirroring the WebSocket intent flow rather than inventing a new wire protocol. | Endpoint | Purpose | | --- | --- | | `POST /api/adminbot/create_game` | Create a private game; the worker mints a self-owned id and returns it (body: `GameConfigSchema.partial()`) | | `POST /api/adminbot/game/:id/intent` | Send a lobby-management intent (body: base `IntentSchema`) | ## How it works - **Auth:** `ADMIN_BOT_API_KEY` env var via the `x-admin-bot-key` header (timing-safe compare). The whole API is **disabled — 404 — when the var is unset**, so non-configured environments expose nothing. It's distinct from the per-instance `ADMIN_TOKEN`, which an external bot can't know. - **`GameServer.handleIntent`** is the unified intent dispatch for both the WebSocket `case "intent"` path and the admin-bot HTTP API. An `IntentActor` carries identity + authority (per-connection lobby-creator/role checks for the WS path; admin authority for the bot). It honors `update_game_config`, `toggle_game_start_timer`, `kick_player`, and `toggle_pause` — **on private games only** (`isPublic()` → 403). Gameplay intents and `mark_disconnected` are rejected (400). - **Private games only.** `create_game` rejects any `gameType` other than `Private` (Public *and* Singleplayer → 400); an omitted `gameType` defaults to `Private`. - **The bot is never a player.** It sends no `clientID`; the server stamps a placeholder `ADMIN_BOT_CLIENT_ID = "ADMINBOT"` (collision-proof — contains `I`/`O`, which `generateID()` never emits). A gameplay intent stamped with it would resolve to no player, so puppeteering is structurally impossible on top of the explicit 400. - **Determinism unchanged:** the only intent that reaches the sim is `toggle_pause`, via the same `addIntent` → turn queue → `ServerTurnMessage` path the WS uses. ## Notable details for review - **`hostCheats` is assigned unconditionally — on purpose.** `updateGameConfig` sets `this.gameConfig.hostCheats = gameConfig.hostCheats` unconditionally, unlike its sibling fields (which are guarded on `!== undefined`). The WS host clears cheats by re-sending the *full* config with `hostCheats: undefined`, so here `undefined` must mean "clear", not "leave unchanged". **Caveat for the admin bot**, which is a *partial*-update client: a partial `update_game_config` that omits `hostCheats` will clear it — the bot should send `hostCheats` explicitly (or a full config) when it wants to keep a previously-set value. - **Deploy wiring:** `ADMIN_BOT_API_KEY` is piped through the deploy steps' `env:` in `deploy.yml`/`release.yml` → `deploy.sh` heredoc → container via `update.sh`'s `--env-file`. The remaining manual step is creating the GitHub secret itself. ## Tests 19 new tests: - `GameServer.handleIntent` admin-bot behavior (per-intent, private-only, post-start guards, placeholder clientID, rejected gameplay/`mark_disconnected` intents). - `create_game` gameType guard (Public and Singleplayer both rejected). - `requireAdminBotKey` middleware (404 disabled / 401 missing / 401 wrong / pass). tsc + eslint clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -139,6 +139,7 @@ jobs:
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_AUTH_HEADER: ${{ secrets.OTEL_AUTH_HEADER }}
|
||||
API_KEY: ${{ secrets.API_KEY }}
|
||||
ADMIN_BOT_API_KEY: ${{ secrets.ADMIN_BOT_API_KEY }}
|
||||
NUM_WORKERS: ${{ vars.NUM_WORKERS }}
|
||||
TURNSTILE_SITE_KEY: ${{ vars.TURNSTILE_SITE_KEY }}
|
||||
SERVER_HOST_MASTERS: ${{ secrets.SERVER_HOST_MASTERS }}
|
||||
|
||||
@@ -72,6 +72,7 @@ jobs:
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_AUTH_HEADER: ${{ secrets.OTEL_AUTH_HEADER }}
|
||||
API_KEY: ${{ secrets.API_KEY }}
|
||||
ADMIN_BOT_API_KEY: ${{ secrets.ADMIN_BOT_API_KEY }}
|
||||
NUM_WORKERS: ${{ vars.NUM_WORKERS }}
|
||||
TURNSTILE_SITE_KEY: ${{ vars.TURNSTILE_SITE_KEY }}
|
||||
SERVER_HOST_STAGING: ${{ secrets.SERVER_HOST_STAGING }}
|
||||
@@ -124,6 +125,7 @@ jobs:
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_AUTH_HEADER: ${{ secrets.OTEL_AUTH_HEADER }}
|
||||
API_KEY: ${{ secrets.API_KEY }}
|
||||
ADMIN_BOT_API_KEY: ${{ secrets.ADMIN_BOT_API_KEY }}
|
||||
NUM_WORKERS: ${{ vars.NUM_WORKERS }}
|
||||
TURNSTILE_SITE_KEY: ${{ vars.TURNSTILE_SITE_KEY }}
|
||||
SERVER_HOST_FALK2: ${{ secrets.SERVER_HOST_FALK2 }}
|
||||
@@ -176,6 +178,7 @@ jobs:
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_AUTH_HEADER: ${{ secrets.OTEL_AUTH_HEADER }}
|
||||
API_KEY: ${{ secrets.API_KEY }}
|
||||
ADMIN_BOT_API_KEY: ${{ secrets.ADMIN_BOT_API_KEY }}
|
||||
NUM_WORKERS: ${{ vars.NUM_WORKERS }}
|
||||
TURNSTILE_SITE_KEY: ${{ vars.TURNSTILE_SITE_KEY }}
|
||||
SERVER_HOST_FALK2: ${{ secrets.SERVER_HOST_FALK2 }}
|
||||
@@ -228,6 +231,7 @@ jobs:
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_AUTH_HEADER: ${{ secrets.OTEL_AUTH_HEADER }}
|
||||
API_KEY: ${{ secrets.API_KEY }}
|
||||
ADMIN_BOT_API_KEY: ${{ secrets.ADMIN_BOT_API_KEY }}
|
||||
NUM_WORKERS: ${{ vars.NUM_WORKERS }}
|
||||
TURNSTILE_SITE_KEY: ${{ vars.TURNSTILE_SITE_KEY }}
|
||||
SERVER_HOST_FALK2: ${{ secrets.SERVER_HOST_FALK2 }}
|
||||
|
||||
@@ -140,6 +140,7 @@ HOST=$HOST
|
||||
GHCR_IMAGE=$GHCR_IMAGE
|
||||
GHCR_TOKEN=$GHCR_TOKEN
|
||||
API_KEY=$API_KEY
|
||||
ADMIN_BOT_API_KEY=$ADMIN_BOT_API_KEY
|
||||
DOMAIN=$DOMAIN
|
||||
SUBDOMAIN=$SUBDOMAIN
|
||||
CDN_BASE=$CDN_BASE
|
||||
|
||||
+11
@@ -100,6 +100,17 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Admin bot create-game: same random-worker balancing as /api/create_game.
|
||||
# The worker checks the admin bot key, mints a self-owned id, and returns it.
|
||||
location = /api/adminbot/create_game {
|
||||
proxy_pass http://openfront_workers;
|
||||
proxy_http_version 1.1;
|
||||
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;
|
||||
}
|
||||
|
||||
# Static file handling with proper MIME types and consistent caching
|
||||
location ~* \.(jpg|jpeg|png|gif|ico|svg|webp|woff|woff2|ttf|eot)$ {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"build-prod": "concurrently --kill-others-on-fail \"tsc --noEmit\" \"vite build\"",
|
||||
"start:client": "vite",
|
||||
"start:server": "tsx src/server/Server.ts",
|
||||
"start:server-dev": "cross-env GAME_ENV=dev NUM_WORKERS=2 TURNSTILE_SITE_KEY=1x00000000000000000000AA API_KEY=WARNING_DEV_API_KEY_DO_NOT_USE_IN_PRODUCTION DOMAIN=localhost GIT_COMMIT=DEV tsx src/server/Server.ts",
|
||||
"start:server-dev": "cross-env GAME_ENV=dev NUM_WORKERS=2 TURNSTILE_SITE_KEY=1x00000000000000000000AA API_KEY=WARNING_DEV_API_KEY_DO_NOT_USE_IN_PRODUCTION ADMIN_BOT_API_KEY=WARNING_DEV_ADMIN_BOT_KEY_DO_NOT_USE_IN_PRODUCTION DOMAIN=localhost GIT_COMMIT=DEV tsx src/server/Server.ts",
|
||||
"dev": "cross-env GAME_ENV=dev concurrently \"npm run start:client\" \"npm run start:server-dev\"",
|
||||
"dev:host": "cross-env GAME_ENV=dev VITE_HOST=lan 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\"",
|
||||
|
||||
+7
-1
@@ -490,7 +490,7 @@ export const ToggleGameStartTimerIntentSchema = z.object({
|
||||
type: z.literal("toggle_game_start_timer"),
|
||||
});
|
||||
|
||||
const IntentSchema = z.discriminatedUnion("type", [
|
||||
export const IntentSchema = z.discriminatedUnion("type", [
|
||||
AttackIntentSchema,
|
||||
CancelAttackIntentSchema,
|
||||
SpawnIntentSchema,
|
||||
@@ -522,6 +522,12 @@ const IntentSchema = z.discriminatedUnion("type", [
|
||||
export const StampedIntentSchema = IntentSchema.and(z.object({ clientID: ID }));
|
||||
export type StampedIntent = Intent & { clientID: ClientID };
|
||||
|
||||
// Placeholder clientID stamped onto admin-bot intents (HTTP admin API). The bot
|
||||
// is not a player, but toggle_pause — the one bot intent that reaches the turn
|
||||
// queue — needs a valid clientID. Chosen so it can never collide with a real id:
|
||||
// generateID() omits 0/l/I/O, and this contains I and O.
|
||||
export const ADMIN_BOT_CLIENT_ID: ClientID = "ADMINBOT";
|
||||
|
||||
//
|
||||
// Server utility types
|
||||
//
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import crypto from "crypto";
|
||||
import type {
|
||||
Express,
|
||||
NextFunction,
|
||||
Request,
|
||||
RequestHandler,
|
||||
Response,
|
||||
} from "express";
|
||||
import type { Logger } from "winston";
|
||||
import { z } from "zod";
|
||||
import { GameType } from "../core/game/Game";
|
||||
import {
|
||||
ADMIN_BOT_CLIENT_ID,
|
||||
GameConfigSchema,
|
||||
ID,
|
||||
IntentSchema,
|
||||
} from "../core/Schemas";
|
||||
import type { GameManager } from "./GameManager";
|
||||
import { ServerEnv } from "./ServerEnv";
|
||||
|
||||
function timingSafeEqualStr(a: string, b: string): boolean {
|
||||
const ab = Buffer.from(a);
|
||||
const bb = Buffer.from(b);
|
||||
if (ab.length !== bb.length) return false;
|
||||
return crypto.timingSafeEqual(ab, bb);
|
||||
}
|
||||
|
||||
// Gate for the admin bot HTTP API. 404 when the feature is disabled (key unset)
|
||||
// so the routes aren't advertised; 401 on a missing/incorrect key.
|
||||
export const requireAdminBotKey: RequestHandler = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
const expected = ServerEnv.adminBotKey();
|
||||
if (expected === undefined) {
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
const provided = req.headers[ServerEnv.adminBotHeader()];
|
||||
if (typeof provided !== "string" || !timingSafeEqualStr(provided, expected)) {
|
||||
res.status(401).json({ error: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
export function registerAdminBotRoutes(opts: {
|
||||
app: Express;
|
||||
gm: GameManager;
|
||||
workerId: number;
|
||||
log: Logger;
|
||||
}) {
|
||||
const { app, gm, workerId, log } = opts;
|
||||
|
||||
// Validate game id format and that this worker owns it. Returns false and
|
||||
// sends the error response when the id is bad/misrouted.
|
||||
const ownsGame = (id: string, res: Response): boolean => {
|
||||
if (!ID.safeParse(id).success) {
|
||||
res.status(400).json({ error: "Invalid game ID" });
|
||||
return false;
|
||||
}
|
||||
if (ServerEnv.workerIndex(id) !== workerId) {
|
||||
res.status(400).json({ error: "Worker, game id mismatch" });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// Create a private game. The worker mints a self-owned id and returns it, so
|
||||
// the bot doesn't need to know the sharding. nginx (and the vite dev proxy)
|
||||
// randomly route here to spread new games across workers.
|
||||
app.post("/api/adminbot/create_game", requireAdminBotKey, (req, res) => {
|
||||
const parsed = GameConfigSchema.partial().safeParse(req.body ?? {});
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: z.prettifyError(parsed.error) });
|
||||
}
|
||||
const config = parsed.data;
|
||||
// Private only: reject Public and Singleplayer. An omitted gameType defaults
|
||||
// to Private in createGame, so it's allowed through.
|
||||
if (config.gameType !== undefined && config.gameType !== GameType.Private) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "admin bot can only create private games" });
|
||||
}
|
||||
|
||||
const id = ServerEnv.generateGameIdForWorker(workerId);
|
||||
if (id === null) {
|
||||
log.warn(`admin bot: failed to mint game id on worker ${workerId}`);
|
||||
return res.status(500).json({ error: "Could not allocate game id" });
|
||||
}
|
||||
|
||||
const game = gm.createGame(id, config, undefined);
|
||||
if (game === null) {
|
||||
return res.status(409).json({ error: "Game ID already exists" });
|
||||
}
|
||||
log.info(`admin bot created game ${id}`);
|
||||
res.json({
|
||||
...game.gameInfo(),
|
||||
workerIndex: workerId,
|
||||
workerPath: ServerEnv.workerPath(id),
|
||||
});
|
||||
});
|
||||
|
||||
// Send an intent. Honors the lobby-management intents; everything else 400.
|
||||
app.post("/api/adminbot/game/:id/intent", requireAdminBotKey, (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
if (!ownsGame(id, res)) return;
|
||||
|
||||
const parsed = IntentSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: z.prettifyError(parsed.error) });
|
||||
}
|
||||
const game = gm.game(id);
|
||||
if (game === null) {
|
||||
return res.status(404).json({ error: "Game not found" });
|
||||
}
|
||||
|
||||
const result = game.handleIntent(parsed.data, {
|
||||
clientID: ADMIN_BOT_CLIENT_ID,
|
||||
isLobbyCreator: false,
|
||||
isAdmin: true,
|
||||
isAdminBot: true,
|
||||
});
|
||||
if (result.status !== 200) {
|
||||
return res.status(result.status).json({ error: result.error ?? "error" });
|
||||
}
|
||||
log.info(`admin bot intent ${parsed.data.type} on game ${id}`);
|
||||
res.json(game.gameInfo());
|
||||
});
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export class GameManager {
|
||||
|
||||
createGame(
|
||||
id: GameID,
|
||||
gameConfig: GameConfig | undefined,
|
||||
gameConfig: Partial<GameConfig> | undefined,
|
||||
creatorPersistentID?: string,
|
||||
startsAt?: number,
|
||||
publicGameType?: PublicGameType,
|
||||
|
||||
+150
-176
@@ -13,6 +13,7 @@ import {
|
||||
GameInfo,
|
||||
GameStartInfo,
|
||||
GameStartInfoSchema,
|
||||
Intent,
|
||||
PlayerRecord,
|
||||
PublicGameType,
|
||||
ServerDesyncSchema,
|
||||
@@ -35,6 +36,23 @@ export enum GamePhase {
|
||||
Finished = "FINISHED",
|
||||
}
|
||||
|
||||
// Identity + authority for an intent, supplied by whoever dispatched it: a
|
||||
// per-connection websocket client, or the trusted admin-bot HTTP API.
|
||||
export interface IntentActor {
|
||||
clientID: ClientID; // stamped onto the intent
|
||||
isLobbyCreator: boolean;
|
||||
isAdmin: boolean; // role-based admin/root (also true for the admin bot)
|
||||
isAdminBot: boolean; // the trusted admin-bot HTTP API
|
||||
}
|
||||
|
||||
// Outcome of dispatching an intent. `status` is an HTTP-style code: 200 on
|
||||
// success. The admin-bot route maps a non-200 straight to its response; the
|
||||
// websocket path logs it and drops the message.
|
||||
export interface IntentOutcome {
|
||||
status: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const KICK_REASON_DUPLICATE_SESSION = "kick_reason.duplicate_session";
|
||||
const KICK_REASON_LOBBY_CREATOR = "kick_reason.lobby_creator";
|
||||
const KICK_REASON_ADMIN = "kick_reason.admin";
|
||||
@@ -187,9 +205,128 @@ export class GameServer {
|
||||
if (gameConfig.waterNukes !== undefined) {
|
||||
this.gameConfig.waterNukes = gameConfig.waterNukes ?? undefined;
|
||||
}
|
||||
// Unconditional on purpose: the host clears cheats by omitting hostCheats
|
||||
// (the full config it sends has hostCheats: undefined when the toggle is
|
||||
// off), so `undefined` here means "clear", not "leave unchanged".
|
||||
this.gameConfig.hostCheats = gameConfig.hostCheats;
|
||||
}
|
||||
|
||||
// Dispatch a control/gameplay intent from either a websocket client or the
|
||||
// trusted admin-bot HTTP API. `actor` carries the authority; the per-intent
|
||||
// actions and game-state guards live here. Returns an HTTP-style outcome the
|
||||
// caller maps (the bot route -> response, the websocket path -> a log).
|
||||
public handleIntent(intent: Intent, actor: IntentActor): IntentOutcome {
|
||||
const stamped: StampedIntent = { ...intent, clientID: actor.clientID };
|
||||
|
||||
// The admin bot only manages private games.
|
||||
if (actor.isAdminBot && this.isPublic()) {
|
||||
return { status: 403, error: "admin bot cannot act on public games" };
|
||||
}
|
||||
|
||||
switch (stamped.type) {
|
||||
case "mark_disconnected":
|
||||
return { status: 400, error: "mark_disconnected is server-internal" };
|
||||
|
||||
case "kick_player": {
|
||||
if (!actor.isLobbyCreator && !actor.isAdmin) {
|
||||
return {
|
||||
status: 403,
|
||||
error: "only the lobby creator or an admin can kick players",
|
||||
};
|
||||
}
|
||||
if (stamped.clientID === stamped.target) {
|
||||
return { status: 400, error: "cannot kick yourself" };
|
||||
}
|
||||
const reason =
|
||||
actor.isAdmin && !actor.isLobbyCreator
|
||||
? KICK_REASON_ADMIN
|
||||
: KICK_REASON_LOBBY_CREATOR;
|
||||
this.log.info("player kicked", {
|
||||
kicker: stamped.clientID,
|
||||
target: stamped.target,
|
||||
isAdmin: actor.isAdmin,
|
||||
isAdminBot: actor.isAdminBot,
|
||||
gameID: this.id,
|
||||
});
|
||||
this.kickClient(stamped.target, reason);
|
||||
return { status: 200 };
|
||||
}
|
||||
|
||||
case "update_game_config": {
|
||||
if (!actor.isLobbyCreator && !actor.isAdminBot) {
|
||||
return {
|
||||
status: 403,
|
||||
error: "only the lobby creator can update game config",
|
||||
};
|
||||
}
|
||||
if (this.isPublic()) {
|
||||
return { status: 403, error: "cannot update a public game" };
|
||||
}
|
||||
if (this.hasStarted()) {
|
||||
return { status: 409, error: "game already started" };
|
||||
}
|
||||
if (stamped.config.gameType === GameType.Public) {
|
||||
return { status: 400, error: "cannot change a game to public" };
|
||||
}
|
||||
this.updateGameConfig(stamped.config);
|
||||
return { status: 200 };
|
||||
}
|
||||
|
||||
case "toggle_game_start_timer": {
|
||||
if (!actor.isLobbyCreator && !actor.isAdminBot) {
|
||||
return { status: 403, error: "only the lobby creator can start" };
|
||||
}
|
||||
if (this.isPublic()) {
|
||||
return { status: 403, error: "cannot start a public game" };
|
||||
}
|
||||
if (this.hasStarted()) {
|
||||
return { status: 409, error: "game already started" };
|
||||
}
|
||||
if (this.startsAt) {
|
||||
this.startsAt = undefined;
|
||||
} else {
|
||||
this.setStartsAt(
|
||||
Date.now() + (this.gameConfig.startDelay ?? 0) * 1000,
|
||||
);
|
||||
}
|
||||
return { status: 200 };
|
||||
}
|
||||
|
||||
case "toggle_pause": {
|
||||
if (!actor.isLobbyCreator && !actor.isAdminBot) {
|
||||
return { status: 403, error: "only the lobby creator can pause" };
|
||||
}
|
||||
// Pausing only makes sense once the game is running.
|
||||
if (!this.hasStarted()) {
|
||||
return { status: 409, error: "game not started" };
|
||||
}
|
||||
// Pausing: flush the intent into a turn before isPaused short-circuits
|
||||
// endTurn(). Unpausing: clear the flag first so the next turn runs.
|
||||
if (stamped.paused) {
|
||||
this.addIntent(stamped);
|
||||
this.endTurn();
|
||||
this.isPaused = true;
|
||||
} else {
|
||||
this.isPaused = false;
|
||||
this.addIntent(stamped);
|
||||
this.endTurn();
|
||||
}
|
||||
return { status: 200 };
|
||||
}
|
||||
|
||||
default: {
|
||||
// Gameplay intents: websocket players only, into the turn queue.
|
||||
if (actor.isAdminBot) {
|
||||
return { status: 400, error: "intent not permitted for admin bot" };
|
||||
}
|
||||
if (!this.isPaused) {
|
||||
this.addIntent(stamped);
|
||||
}
|
||||
return { status: 200 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private isKicked(clientID: ClientID): boolean {
|
||||
const persistentID = this.allClients.get(clientID)?.persistentID;
|
||||
return (
|
||||
@@ -405,183 +542,20 @@ export class GameServer {
|
||||
break;
|
||||
}
|
||||
case "intent": {
|
||||
// Server stamps clientID from the authenticated connection
|
||||
const stampedIntent = {
|
||||
...clientMsg.intent,
|
||||
// Server stamps clientID from the authenticated connection.
|
||||
const outcome = this.handleIntent(clientMsg.intent, {
|
||||
clientID: client.clientID,
|
||||
};
|
||||
switch (stampedIntent.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 isLobbyCreator = client.clientID === this.lobbyCreatorID;
|
||||
const isAdmin = isAdminRole(client.role);
|
||||
|
||||
// Check if the authenticated client is the lobby creator or admin
|
||||
if (!isLobbyCreator && !isAdmin) {
|
||||
this.log.warn(
|
||||
`Only lobby creator or admin can kick players`,
|
||||
{
|
||||
clientID: client.clientID,
|
||||
creatorID: this.lobbyCreatorID,
|
||||
target: stampedIntent.target,
|
||||
gameID: this.id,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't allow kicking yourself
|
||||
if (client.clientID === stampedIntent.target) {
|
||||
this.log.warn(`Cannot kick yourself`, {
|
||||
clientID: client.clientID,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Log and execute the kick
|
||||
this.log.info(`Player initiated kick`, {
|
||||
kickerID: client.clientID,
|
||||
isAdmin,
|
||||
target: stampedIntent.target,
|
||||
gameID: this.id,
|
||||
kickMethod: "websocket",
|
||||
});
|
||||
|
||||
this.kickClient(
|
||||
stampedIntent.target,
|
||||
isAdmin && !isLobbyCreator
|
||||
? KICK_REASON_ADMIN
|
||||
: KICK_REASON_LOBBY_CREATOR,
|
||||
);
|
||||
return;
|
||||
}
|
||||
case "update_game_config": {
|
||||
// Only lobby creator can update config
|
||||
if (client.clientID !== this.lobbyCreatorID) {
|
||||
this.log.warn(`Only lobby creator can update game config`, {
|
||||
clientID: client.clientID,
|
||||
creatorID: this.lobbyCreatorID,
|
||||
gameID: this.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isPublic()) {
|
||||
this.log.warn(`Cannot update public game via WebSocket`, {
|
||||
gameID: this.id,
|
||||
clientID: client.clientID,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.hasStarted()) {
|
||||
this.log.warn(
|
||||
`Cannot update game config after it has started`,
|
||||
{
|
||||
gameID: this.id,
|
||||
clientID: client.clientID,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (stampedIntent.config.gameType === GameType.Public) {
|
||||
this.log.warn(`Cannot update game to public via WebSocket`, {
|
||||
gameID: this.id,
|
||||
clientID: client.clientID,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.log.info(
|
||||
`Lobby creator updated game config via WebSocket`,
|
||||
{
|
||||
creatorID: client.clientID,
|
||||
gameID: this.id,
|
||||
},
|
||||
);
|
||||
|
||||
this.updateGameConfig(stampedIntent.config);
|
||||
return;
|
||||
}
|
||||
case "toggle_game_start_timer": {
|
||||
if (client.clientID !== this.lobbyCreatorID) {
|
||||
this.log.warn(`Only lobby creator can start game`, {
|
||||
clientID: client.clientID,
|
||||
creatorID: this.lobbyCreatorID,
|
||||
gameID: this.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (this.isPublic()) {
|
||||
this.log.warn(`Cannot start public game via WebSocket`, {
|
||||
gameID: this.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (this.hasStarted()) {
|
||||
this.log.warn(`Cannot start game that has already started`, {
|
||||
gameID: this.id,
|
||||
clientID: client.clientID,
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.log.info(`Lobby creator starting game via WebSocket`, {
|
||||
creatorID: client.clientID,
|
||||
gameID: this.id,
|
||||
});
|
||||
if (this.startsAt) {
|
||||
this.startsAt = undefined;
|
||||
} else {
|
||||
this.setStartsAt(
|
||||
Date.now() + (this.gameConfig.startDelay ?? 0) * 1000,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "toggle_pause": {
|
||||
// Only lobby creator can pause/resume
|
||||
if (client.clientID !== this.lobbyCreatorID) {
|
||||
this.log.warn(`Only lobby creator can toggle pause`, {
|
||||
clientID: client.clientID,
|
||||
creatorID: this.lobbyCreatorID,
|
||||
gameID: this.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (stampedIntent.paused) {
|
||||
// Pausing: send intent and complete current turn before pause takes effect
|
||||
this.addIntent(stampedIntent);
|
||||
this.endTurn();
|
||||
this.isPaused = true;
|
||||
} else {
|
||||
// Unpausing: clear pause flag before sending intent so next turn can execute
|
||||
this.isPaused = false;
|
||||
this.addIntent(stampedIntent);
|
||||
this.endTurn();
|
||||
}
|
||||
|
||||
this.log.info(`Game ${this.isPaused ? "paused" : "resumed"}`, {
|
||||
clientID: client.clientID,
|
||||
gameID: this.id,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// Don't process intents while game is paused
|
||||
if (!this.isPaused) {
|
||||
this.addIntent(stampedIntent);
|
||||
}
|
||||
break;
|
||||
}
|
||||
isLobbyCreator: client.clientID === this.lobbyCreatorID,
|
||||
isAdmin: isAdminRole(client.role),
|
||||
isAdminBot: false,
|
||||
});
|
||||
if (outcome.status !== 200) {
|
||||
this.log.warn(`intent rejected`, {
|
||||
type: clientMsg.intent.type,
|
||||
clientID: client.clientID,
|
||||
gameID: this.id,
|
||||
reason: outcome.error,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
+23
-1
@@ -2,7 +2,7 @@ import { JWK } from "jose";
|
||||
import { z } from "zod";
|
||||
import { GameEnv, parseGameEnv } from "../core/configuration/Config";
|
||||
import { GameID } from "../core/Schemas";
|
||||
import { simpleHash } from "../core/Util";
|
||||
import { generateID, simpleHash } from "../core/Util";
|
||||
|
||||
const JwksSchema = z.object({
|
||||
keys: z
|
||||
@@ -125,6 +125,19 @@ export class ServerEnv {
|
||||
static workerPortByIndex(index: number): number {
|
||||
return 3001 + index;
|
||||
}
|
||||
// Generate a game id that hashes to `workerId`, so requests for the game route
|
||||
// back to this worker. Rejection sampling: each id lands on a uniformly-random
|
||||
// worker, so the expected number of tries is numWorkers; the cap scales with
|
||||
// the worker count to keep the failure chance negligible (~e^-100). Returns
|
||||
// null if none was found (effectively never).
|
||||
static generateGameIdForWorker(workerId: number): GameID | null {
|
||||
const maxAttempts = ServerEnv.numWorkers() * 100;
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
const id = generateID();
|
||||
if (ServerEnv.workerIndex(id) === workerId) return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Server-only env values
|
||||
static domain(): string {
|
||||
@@ -156,6 +169,15 @@ export class ServerEnv {
|
||||
static apiKey(): string {
|
||||
return process.env.API_KEY ?? "";
|
||||
}
|
||||
// Long-lived shared secret for the trusted admin bot HTTP API.
|
||||
// Undefined when unset, which disables the admin bot API entirely.
|
||||
static adminBotKey(): string | undefined {
|
||||
const v = process.env.ADMIN_BOT_API_KEY;
|
||||
return v && v.length > 0 ? v : undefined;
|
||||
}
|
||||
static adminBotHeader(): string {
|
||||
return "x-admin-bot-key";
|
||||
}
|
||||
static allowedFlares(): string[] | undefined {
|
||||
const raw = process.env.ALLOWED_FLARES;
|
||||
if (!raw) return undefined;
|
||||
|
||||
+5
-18
@@ -11,12 +11,12 @@ import { GameEnv } from "../core/configuration/Config";
|
||||
import { GameType } from "../core/game/Game";
|
||||
import {
|
||||
ClientMessageSchema,
|
||||
GameID,
|
||||
PartialGameRecordSchema,
|
||||
ServerErrorMessage,
|
||||
} from "../core/Schemas";
|
||||
import { generateID, replacer } from "../core/Util";
|
||||
import { CreateGameInputSchema } from "../core/WorkerSchemas";
|
||||
import { registerAdminBotRoutes } from "./AdminBotRoutes";
|
||||
import { archive, finalizeGameRecord } from "./Archive";
|
||||
import { Client } from "./Client";
|
||||
import { GameManager } from "./GameManager";
|
||||
@@ -171,7 +171,7 @@ export async function startWorker() {
|
||||
.json({ error: "Cannot create public games via this endpoint" });
|
||||
}
|
||||
|
||||
const id = generateGameIdForWorker();
|
||||
const id = ServerEnv.generateGameIdForWorker(workerId);
|
||||
if (id === null) {
|
||||
log.warn(`Failed to mint game id on worker ${workerId}`);
|
||||
return res.status(500).json({ error: "Could not allocate game id" });
|
||||
@@ -219,6 +219,8 @@ export async function startWorker() {
|
||||
baseDir: __dirname,
|
||||
});
|
||||
|
||||
registerAdminBotRoutes({ app, gm, workerId, log });
|
||||
|
||||
app.post("/api/archive_singleplayer_game", async (req, res) => {
|
||||
try {
|
||||
const record = req.body;
|
||||
@@ -553,7 +555,7 @@ async function startMatchmakingPolling(gm: GameManager) {
|
||||
async () => {
|
||||
try {
|
||||
const url = `${ServerEnv.jwtIssuer() + "/matchmaking/checkin"}`;
|
||||
const gameId = generateGameIdForWorker();
|
||||
const gameId = ServerEnv.generateGameIdForWorker(workerId);
|
||||
if (gameId === null) {
|
||||
log.warn(`Failed to generate game ID for worker ${workerId}`);
|
||||
return;
|
||||
@@ -611,21 +613,6 @@ async function startMatchmakingPolling(gm: GameManager) {
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: This is a hack to generate a game ID for the worker.
|
||||
// It should be replaced with a more robust solution.
|
||||
function generateGameIdForWorker(): GameID | null {
|
||||
let attempts = 1000;
|
||||
while (attempts > 0) {
|
||||
const gameId = generateID();
|
||||
if (workerId === ServerEnv.workerIndex(gameId)) {
|
||||
return gameId;
|
||||
}
|
||||
attempts--;
|
||||
}
|
||||
log.warn(`Failed to generate game ID for worker ${workerId}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
function getClientIp(req: http.IncomingMessage): string {
|
||||
const cfIp = req.headers["cf-connecting-ip"];
|
||||
if (typeof cfIp === "string" && cfIp) return cfIp;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { requireAdminBotKey } from "../../src/server/AdminBotRoutes";
|
||||
|
||||
function mockRes() {
|
||||
const res: any = {
|
||||
statusCode: 200,
|
||||
body: undefined,
|
||||
ended: false,
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(body: unknown) {
|
||||
this.body = body;
|
||||
return this;
|
||||
},
|
||||
end() {
|
||||
this.ended = true;
|
||||
return this;
|
||||
},
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
describe("requireAdminBotKey", () => {
|
||||
const KEY = "super-secret-bot-key";
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.ADMIN_BOT_API_KEY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.ADMIN_BOT_API_KEY;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns 404 when the feature is disabled (key unset)", () => {
|
||||
const res = mockRes();
|
||||
const next = vi.fn();
|
||||
requireAdminBotKey({ headers: {} } as any, res, next);
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 401 when the header is missing", () => {
|
||||
process.env.ADMIN_BOT_API_KEY = KEY;
|
||||
const res = mockRes();
|
||||
const next = vi.fn();
|
||||
requireAdminBotKey({ headers: {} } as any, res, next);
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 401 when the key is wrong", () => {
|
||||
process.env.ADMIN_BOT_API_KEY = KEY;
|
||||
const res = mockRes();
|
||||
const next = vi.fn();
|
||||
requireAdminBotKey(
|
||||
{ headers: { "x-admin-bot-key": "nope" } } as any,
|
||||
res,
|
||||
next,
|
||||
);
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls next when the key matches", () => {
|
||||
process.env.ADMIN_BOT_API_KEY = KEY;
|
||||
const res = mockRes();
|
||||
const next = vi.fn();
|
||||
requireAdminBotKey(
|
||||
{ headers: { "x-admin-bot-key": KEY } } as any,
|
||||
res,
|
||||
next,
|
||||
);
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { GameType } from "../../src/core/game/Game";
|
||||
import { registerAdminBotRoutes } from "../../src/server/AdminBotRoutes";
|
||||
|
||||
function mockRes() {
|
||||
const res: any = {
|
||||
statusCode: 200,
|
||||
body: undefined,
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
json(body: unknown) {
|
||||
this.body = body;
|
||||
return this;
|
||||
},
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
// Capture the route handlers registered on a fake Express app so we can invoke
|
||||
// the create_game handler directly. requireAdminBotKey is registered as the
|
||||
// preceding middleware (tested separately); we call the final handler.
|
||||
function captureCreateHandler() {
|
||||
const routes: Record<string, (req: any, res: any) => void> = {};
|
||||
const app: any = {
|
||||
post(path: string, ...handlers: ((req: any, res: any) => void)[]) {
|
||||
routes[path] = handlers[handlers.length - 1];
|
||||
},
|
||||
};
|
||||
const log: any = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
registerAdminBotRoutes({ app, gm: {} as any, workerId: 0, log });
|
||||
return routes["/api/adminbot/create_game"];
|
||||
}
|
||||
|
||||
describe("admin bot create_game gameType guard", () => {
|
||||
it("rejects a Singleplayer game with 400", () => {
|
||||
const handler = captureCreateHandler();
|
||||
const res = mockRes();
|
||||
handler({ body: { gameType: GameType.Singleplayer } }, res);
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toMatch(/private games/);
|
||||
});
|
||||
|
||||
it("rejects a Public game with 400", () => {
|
||||
const handler = captureCreateHandler();
|
||||
const res = mockRes();
|
||||
handler({ body: { gameType: GameType.Public } }, res);
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.body.error).toMatch(/private games/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { GameType } from "../../src/core/game/Game";
|
||||
import { ADMIN_BOT_CLIENT_ID } from "../../src/core/Schemas";
|
||||
import { GameServer } from "../../src/server/GameServer";
|
||||
|
||||
describe("GameServer.handleIntent (admin bot)", () => {
|
||||
let mockLogger: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
mockLogger = {
|
||||
child: vi.fn().mockReturnThis(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllTimers();
|
||||
});
|
||||
|
||||
function makeGame(config: Record<string, unknown> = {}) {
|
||||
return new GameServer("test-game", mockLogger, Date.now(), {
|
||||
gameType: GameType.Private,
|
||||
...config,
|
||||
} as any);
|
||||
}
|
||||
|
||||
const started = (game: GameServer) => {
|
||||
(game as any)._hasStarted = true;
|
||||
};
|
||||
|
||||
const ADMIN_ACTOR = {
|
||||
clientID: ADMIN_BOT_CLIENT_ID,
|
||||
isLobbyCreator: false,
|
||||
isAdmin: true,
|
||||
isAdminBot: true,
|
||||
};
|
||||
const apply = (game: GameServer, intent: any) =>
|
||||
game.handleIntent(intent, ADMIN_ACTOR);
|
||||
|
||||
describe("update_game_config", () => {
|
||||
it("mutates the config", () => {
|
||||
const game = makeGame({ bots: 100 });
|
||||
const result = apply(game, {
|
||||
type: "update_game_config",
|
||||
config: { bots: 42 },
|
||||
} as any);
|
||||
expect(result.status).toBe(200);
|
||||
expect((game as any).gameConfig.bots).toBe(42);
|
||||
});
|
||||
|
||||
it("rejects a public game with 403", () => {
|
||||
const game = makeGame({ gameType: GameType.Public });
|
||||
expect(
|
||||
apply(game, {
|
||||
type: "update_game_config",
|
||||
config: { bots: 1 },
|
||||
} as any).status,
|
||||
).toBe(403);
|
||||
});
|
||||
|
||||
it("rejects promoting a game to public with 400", () => {
|
||||
const game = makeGame();
|
||||
expect(
|
||||
apply(game, {
|
||||
type: "update_game_config",
|
||||
config: { gameType: GameType.Public },
|
||||
} as any).status,
|
||||
).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects updates after the game has started with 409", () => {
|
||||
const game = makeGame();
|
||||
started(game);
|
||||
expect(
|
||||
apply(game, {
|
||||
type: "update_game_config",
|
||||
config: { bots: 1 },
|
||||
} as any).status,
|
||||
).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggle_game_start_timer", () => {
|
||||
it("sets then clears startsAt", () => {
|
||||
const game = makeGame({ startDelay: 0 });
|
||||
expect((game as any).startsAt).toBeUndefined();
|
||||
|
||||
expect(
|
||||
apply(game, { type: "toggle_game_start_timer" } as any).status,
|
||||
).toBe(200);
|
||||
expect((game as any).startsAt).toBeDefined();
|
||||
|
||||
expect(
|
||||
apply(game, { type: "toggle_game_start_timer" } as any).status,
|
||||
).toBe(200);
|
||||
expect((game as any).startsAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects after the game has started with 409", () => {
|
||||
const game = makeGame();
|
||||
started(game);
|
||||
expect(
|
||||
apply(game, { type: "toggle_game_start_timer" } as any).status,
|
||||
).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
describe("kick_player", () => {
|
||||
it("routes to kickClient", () => {
|
||||
const game = makeGame();
|
||||
const spy = vi.spyOn(game, "kickClient");
|
||||
const result = apply(game, {
|
||||
type: "kick_player",
|
||||
target: "abcdABCD",
|
||||
} as any);
|
||||
expect(result.status).toBe(200);
|
||||
expect(spy).toHaveBeenCalledWith("abcdABCD", expect.any(String));
|
||||
});
|
||||
|
||||
it("rejects a public game with 403", () => {
|
||||
const game = makeGame({ gameType: GameType.Public });
|
||||
expect(
|
||||
apply(game, {
|
||||
type: "kick_player",
|
||||
target: "abcdABCD",
|
||||
} as any).status,
|
||||
).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggle_pause", () => {
|
||||
it("rejects when the game has not started with 409", () => {
|
||||
const game = makeGame();
|
||||
expect(
|
||||
apply(game, { type: "toggle_pause", paused: true } as any).status,
|
||||
).toBe(409);
|
||||
});
|
||||
|
||||
it("pauses and resumes a started game", () => {
|
||||
const game = makeGame();
|
||||
started(game);
|
||||
|
||||
expect(
|
||||
apply(game, { type: "toggle_pause", paused: true } as any).status,
|
||||
).toBe(200);
|
||||
expect((game as any).isPaused).toBe(true);
|
||||
|
||||
expect(
|
||||
apply(game, { type: "toggle_pause", paused: false } as any).status,
|
||||
).toBe(200);
|
||||
expect((game as any).isPaused).toBe(false);
|
||||
});
|
||||
|
||||
it("records the pause intent stamped with the placeholder clientID", () => {
|
||||
const game = makeGame();
|
||||
started(game);
|
||||
apply(game, { type: "toggle_pause", paused: true } as any);
|
||||
|
||||
const intents = (game as any).turns.flatMap((t: any) => t.intents);
|
||||
const pause = intents.find((i: any) => i.type === "toggle_pause");
|
||||
expect(pause).toBeDefined();
|
||||
expect(pause.clientID).toBe(ADMIN_BOT_CLIENT_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rejected intents", () => {
|
||||
it("rejects a gameplay intent with 400", () => {
|
||||
const game = makeGame();
|
||||
expect(apply(game, { type: "spawn", x: 1, y: 1 } as any).status).toBe(
|
||||
400,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects mark_disconnected with 400", () => {
|
||||
const game = makeGame();
|
||||
expect(
|
||||
apply(game, {
|
||||
type: "mark_disconnected",
|
||||
isDisconnected: true,
|
||||
} as any).status,
|
||||
).toBe(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
+8
-5
@@ -52,22 +52,25 @@ function serveProprietaryDir(
|
||||
};
|
||||
}
|
||||
|
||||
// Dev-only stand-in for the nginx random create-game routing. Forwards
|
||||
// POST /api/create_game to a randomly chosen worker port so the worker can
|
||||
// mint a self-owned id. Runs as direct middleware (before vite's /api proxy).
|
||||
// Dev-only stand-in for the nginx random-worker routing (the openfront_workers
|
||||
// upstream). Forwards these prefix-less POSTs to a randomly chosen worker port
|
||||
// so the worker can mint a self-owned id. Runs as direct middleware (before
|
||||
// vite's /api proxy).
|
||||
const RANDOM_WORKER_PATHS = ["/api/create_game", "/api/adminbot/create_game"];
|
||||
function randomWorkerCreateProxy(numWorkers: number): Plugin {
|
||||
return {
|
||||
name: "random-worker-create-proxy",
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, res, next) => {
|
||||
if (req.method !== "POST") return next();
|
||||
if ((req.url ?? "").split("?")[0] !== "/api/create_game") return next();
|
||||
const path = (req.url ?? "").split("?")[0];
|
||||
if (!RANDOM_WORKER_PATHS.includes(path)) return next();
|
||||
const port = 3001 + Math.floor(Math.random() * numWorkers);
|
||||
const proxyReq = http.request(
|
||||
{
|
||||
host: "localhost",
|
||||
port,
|
||||
path: "/api/create_game",
|
||||
path,
|
||||
method: "POST",
|
||||
headers: req.headers,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user