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:
Evan
2026-06-23 19:09:14 -07:00
committed by GitHub
parent d01be405c7
commit 67f7d09fe5
15 changed files with 662 additions and 203 deletions
+131
View File
@@ -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());
});
}
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;