mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-25 18:44:37 +00:00
67f7d09fe5
## 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>
80 lines
1.9 KiB
TypeScript
80 lines
1.9 KiB
TypeScript
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);
|
|
});
|
|
});
|