mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-02 05:08:19 +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:
+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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user