Add allowlist for private lobbies (OFM) (#4351)

**Add approved & assigned issue number here:**

Resolves #4349

## Description:

1. **Private-lobby allowlist.** `create_game` accepts an optional
`allowedPublicIds`. It's set by whoever creates the lobby (admin-token
gated, no client UI), the game server pulls it out of the config so it's
never broadcast to clients or written to the game record, and it rejects
any joiner whose OF publicId isn't on the list before they take a slot
(stickily, so they can't retry on reconnect). Lobbies created without it
behave exactly as before.

It is off by default
Previews:
<img width="241" height="140" alt="image"
src="https://github.com/user-attachments/assets/30c4e47b-399d-4720-b25b-a04c63668577"
/>
<img width="982" height="456" alt="image"
src="https://github.com/user-attachments/assets/1b5c68b7-9b99-4ccc-b987-e70c8ec25dce"
/>
<img width="547" height="369" alt="image"
src="https://github.com/user-attachments/assets/1623090b-ea2b-4657-9cd8-903fbabca51b"
/>


I am not able to manually test all of it since it needs to also run the
auth API (infra) and actually be connected to disc and whatnot (but
still tested the refused flow)..
Also, we would need to place some guards and visual error feedback, but
since this only would affect casual of players and is more of a
improvement to the feature, I will consider it out of scope for now.

## Please complete the following:

- [x] I have added screenshots for all UI updates (no UI changes in this
PR)
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file (no new user-facing text)
- [x] I have added relevant tests to the test directory

## Please put your Discord username so you can be contacted if a bug or
regression is found:

zixer._
This commit is contained in:
Zixer1
2026-06-21 18:10:48 -04:00
committed by evanpelle
parent 88aad8501e
commit 18ef15d3ae
7 changed files with 188 additions and 2 deletions
+2
View File
@@ -717,6 +717,8 @@
"options_title": "Options",
"player": "Player",
"player_immunity_duration": "PVP immunity duration (minutes)",
"player_whitelist": "Player Whitelist (public IDs)",
"player_whitelist_placeholder": "comma separated",
"players": "Players",
"random_spawn": "Random spawn",
"remove_player": "Remove {username}",
+42
View File
@@ -80,6 +80,8 @@ export class HostLobbyModal extends BaseModal {
@state() private startingGold: boolean = false;
@state() private startingGoldValue: number | undefined = undefined;
@state() private disableAlliances: boolean = false;
@state() private whitelistEnabled: boolean = false;
@state() private allowedPublicIds: string = "";
@state() private waterNukes: boolean = false;
@state() private lobbyId = "";
@state() private lobbyUrlSuffix = "";
@@ -288,6 +290,19 @@ export class HostLobbyModal extends BaseModal {
.onChange=${this.handleStartingGoldValueChanges}
.onKeyDown=${this.handleStartingGoldValueKeyDown}
></toggle-input-card>`,
html`<toggle-input-card
.labelKey=${"host_modal.player_whitelist"}
.checked=${this.whitelistEnabled}
.inputType=${"text"}
.inputId=${"allowed-public-ids"}
.inputValue=${this.allowedPublicIds}
.inputAriaLabel=${translateText("host_modal.player_whitelist")}
.inputPlaceholder=${translateText(
"host_modal.player_whitelist_placeholder",
)}
.onToggle=${this.handleWhitelistToggle}
.onChange=${this.handleAllowedPublicIdsChange}
></toggle-input-card>`,
];
const hostCheatInputCards = [
@@ -583,6 +598,8 @@ export class HostLobbyModal extends BaseModal {
this.startingGold = false;
this.startingGoldValue = undefined;
this.disableAlliances = false;
this.whitelistEnabled = false;
this.allowedPublicIds = "";
this.waterNukes = false;
this.hostCheatsEnabled = false;
this.hostCheatInfiniteGold = false;
@@ -995,6 +1012,28 @@ export class HostLobbyModal extends BaseModal {
this.putGameConfig();
}
private handleWhitelistToggle = (checked: boolean) => {
this.whitelistEnabled = checked;
this.putGameConfig();
};
private handleAllowedPublicIdsChange = (e: Event) => {
this.allowedPublicIds = (e.target as HTMLInputElement).value;
this.putGameConfig();
};
// Comma/space/newline-separated publicIds, capped at the 200 the schema
// allows so a large paste can't make the config update fail validation.
// Undefined when empty (no allowlist).
private parseAllowedPublicIds(): string[] | undefined {
const ids = this.allowedPublicIds
.split(/[\s,]+/)
.map((s) => s.trim())
.filter((s) => s.length > 0)
.slice(0, 200);
return ids.length > 0 ? ids : undefined;
}
private async putGameConfig() {
const spawnImmunityTicks = this.spawnImmunityDurationMinutes
? this.spawnImmunityDurationMinutes * 60 * 10
@@ -1036,6 +1075,9 @@ export class HostLobbyModal extends BaseModal {
? Math.round(this.startingGoldValue * 1_000_000)
: null,
disableAlliances: this.disableAlliances || null,
allowedPublicIds: this.whitelistEnabled
? (this.parseAllowedPublicIds() ?? [])
: [],
waterNukes: this.waterNukes ? true : null,
hostCheats: this.hostCheatsEnabled
? {
+2
View File
@@ -282,6 +282,8 @@ export const GameConfigSchema = z.object({
waterNukes: z.boolean().nullable().optional(),
randomSpawn: z.boolean(),
maxPlayers: z.number().optional(),
// OFM: allowlist of publicIds allowed to join (admin-only, see create_game).
allowedPublicIds: z.array(z.string()).max(200).optional(),
maxTimerValue: z.number().int().min(1).max(120).nullable().optional(), // In minutes
startDelay: z.number().int().min(0).max(600).nullable().optional(), // In seconds
spawnImmunityDuration: z.number().int().min(0).nullable().optional(), // In ticks
+1 -1
View File
@@ -31,7 +31,7 @@ export class GameManager {
joinClient(
client: Client,
gameID: GameID,
): "joined" | "kicked" | "rejected" | "not_found" {
): "joined" | "kicked" | "rejected" | "not_allowlisted" | "not_found" {
const game = this.games.get(gameID);
if (!game) return "not_found";
return game.joinClient(client);
+21 -1
View File
@@ -181,6 +181,9 @@ export class GameServer {
this.gameConfig.disableAlliances =
gameConfig.disableAlliances ?? undefined;
}
if (gameConfig.allowedPublicIds !== undefined) {
this.gameConfig.allowedPublicIds = gameConfig.allowedPublicIds;
}
if (gameConfig.waterNukes !== undefined) {
this.gameConfig.waterNukes = gameConfig.waterNukes ?? undefined;
}
@@ -202,11 +205,28 @@ export class GameServer {
return clientID;
}
public joinClient(client: Client): "joined" | "kicked" | "rejected" {
public joinClient(
client: Client,
): "joined" | "kicked" | "rejected" | "not_allowlisted" {
if (this.kickedPersistentIds.has(client.persistentID)) {
return "kicked";
}
// OFM: if an allowlist is set, only those publicIds may join. Re-checked on
// every join attempt
const allowedPublicIds = this.gameConfig.allowedPublicIds;
if (
allowedPublicIds !== undefined &&
allowedPublicIds.length > 0 &&
(client.publicId === undefined ||
!allowedPublicIds.includes(client.publicId))
) {
this.log.warn("client not on allowlist, rejecting", {
clientID: client.clientID,
});
return "not_allowlisted";
}
if (
this.gameConfig.maxPlayers &&
this.activeClients.length >= this.gameConfig.maxPlayers
+6
View File
@@ -499,6 +499,12 @@ export async function startWorker() {
workerId,
});
ws.close(1002, "Cannot join game");
} else if (joinResult === "not_allowlisted") {
log.info(`client not whitelisted for game ${clientMsg.gameID}`, {
gameID: clientMsg.gameID,
workerId,
});
ws.close(1002, "You are not whitelisted");
} else if (joinResult === "rejected") {
log.info(`client rejected from game ${clientMsg.gameID}`, {
gameID: clientMsg.gameID,
+114
View File
@@ -0,0 +1,114 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../src/core/Schemas", async () => {
const actual = (await vi.importActual("../../src/core/Schemas")) as any;
return {
...actual,
GameStartInfoSchema: {
safeParse: (data: any) => ({ success: true, data }),
},
ServerPrestartMessageSchema: {
safeParse: (data: any) => ({ success: true, data }),
},
ClientMessageSchema: {
safeParse: (data: any) => ({ success: true, data }),
},
};
});
import { GameType } from "../../src/core/game/Game";
import { Client } from "../../src/server/Client";
import { GameServer } from "../../src/server/GameServer";
function makeMockWs() {
return {
on: () => {},
removeAllListeners: () => {},
send: vi.fn(),
close: vi.fn(),
readyState: 1,
};
}
function makeClient(
clientID: string,
persistentID: string,
publicId: string | undefined,
): Client {
return new Client(
clientID,
persistentID,
null,
null,
undefined,
"127.0.0.1",
"TestUser",
null,
makeMockWs() as any,
undefined,
publicId,
[],
);
}
describe("GameServer - allowlist (allowedPublicIds)", () => {
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();
vi.useRealTimers();
});
function makeGame(allowedPublicIds?: string[]) {
return new GameServer("test-game", mockLogger, Date.now(), {
gameType: GameType.Private,
...(allowedPublicIds ? { allowedPublicIds } : {}),
} as any);
}
it("admits only listed publicIds and rejects others", () => {
const game = makeGame(["pub-ok"]);
expect(game.joinClient(makeClient("c1", "p1", "pub-ok"))).toBe("joined");
expect(game.joinClient(makeClient("c2", "p2", "pub-no"))).toBe(
"not_allowlisted",
);
expect(game.joinClient(makeClient("c3", "p3", undefined))).toBe(
"not_allowlisted",
);
});
it("does not restrict joins when no allowlist is set", () => {
const game = makeGame();
expect(game.joinClient(makeClient("c1", "p1", "anything"))).toBe("joined");
});
it("treats an empty allowlist as no restriction", () => {
const game = makeGame([]);
expect(game.joinClient(makeClient("c1", "p1", "anything"))).toBe("joined");
});
it("lets a previously-rejected player in once the allowlist is cleared", () => {
const game = makeGame(["pub-ok"]);
expect(game.joinClient(makeClient("c2", "p2", "pub-no"))).toBe(
"not_allowlisted",
);
game.updateGameConfig({ allowedPublicIds: [] });
expect(game.joinClient(makeClient("c2", "p2", "pub-no"))).toBe("joined");
});
it("keeps allowedPublicIds on the stored config (read like other settings)", () => {
const game = makeGame(["pub-ok"]);
expect((game.gameConfig as any).allowedPublicIds).toEqual(["pub-ok"]);
});
});