Files
OpenFrontIO/tests/server/AdminBotIntent.test.ts
T
Evan 67f7d09fe5 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>
2026-06-23 19:09:14 -07:00

189 lines
5.2 KiB
TypeScript

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);
});
});
});