fix(engine): allow staff ::kick and ::ban regardless of NODE_PRODUCTION

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
max bittkerandClaude Opus 5.5 committed 2026-10-12 14:30:11 -04:00
1 parent c06f209270
commit 76391328ec
4 files changed
+88 -5

No files matched your search

+1 -1
View File
@@ -6,7 +6,7 @@
"docs:api": "bun sdk/generate-api-docs.ts",
"docs:api:check": "bun sdk/generate-api-docs.ts --check",
"fetch-collision-data": "bun sdk/fetch-collision-data.ts",
"test": "bun test sdk/test server/webclient/src/bot server/webclient/src/util server/webclient/src/lite server/webclient/src/mapview server/engine/test/market.test.ts server/engine/test/market-web.test.ts server/engine/test/item-search.test.ts server/engine/test/file-stream.test.ts server/engine/test/get-ip.test.ts server/engine/test/bank-hiscores.test.ts server/engine/test/reconnect.test.ts server/engine/test/worldfeed.test.ts server/gateway/state-fanout.test.ts server/engine/test/hiscores-performance.test.ts server/engine/test/miscellania-coffers.test.ts",
"test": "bun test sdk/test server/webclient/src/bot server/webclient/src/util server/webclient/src/lite server/webclient/src/mapview server/engine/test/market.test.ts server/engine/test/market-web.test.ts server/engine/test/item-search.test.ts server/engine/test/file-stream.test.ts server/engine/test/get-ip.test.ts server/engine/test/bank-hiscores.test.ts server/engine/test/reconnect.test.ts server/engine/test/worldfeed.test.ts server/gateway/state-fanout.test.ts server/engine/test/hiscores-performance.test.ts server/engine/test/miscellania-coffers.test.ts server/engine/test/live-moderation.test.ts",
"typecheck": "tsc --noEmit",
"typecheck:webclient": "tsc --noEmit -p server/webclient/tsconfig.json",
"test:market:integration": "cd server/engine && bun test/market-integration.ts && bun test/market-recovery.ts && bun test/market-protocol.ts && bun test/market-disabled.ts"
@@ -633,8 +633,8 @@ export default class ClientCheatHandler extends ClientGameMessageHandler<ClientC
default:
return false;
}
} else if (cmd === 'ban' && Environment.node.production) {
// custom
} else if (cmd === 'ban') {
// Moderation is staff-only, regardless of the world's production setting.
if (args.length < 2) {
// ::ban <username> <minutes>
player.messageGame('Usage: ::ban <username> <minutes>');
@@ -659,8 +659,8 @@ export default class ClientCheatHandler extends ClientGameMessageHandler<ClientC
World.notifyPlayerMute(player.username, username, Date.now() + minutes * 60 * 1000);
player.messageGame(`Player '${args[0]}' has been muted for ${minutes} minutes.`);
} else if (cmd === 'kick' && Environment.node.production) {
// custom
} else if (cmd === 'kick') {
// Moderation is staff-only, regardless of the world's production setting.
if (args.length < 1) {
// ::kick <username>
player.messageGame('Usage: ::kick <username>');
+63
View File
@@ -0,0 +1,63 @@
import { afterEach, expect, mock, spyOn, test } from 'bun:test';
import { EventEmitter } from 'node:events';
class TestWorker extends EventEmitter {
postMessage() {}
}
mock.module('worker_threads', () => ({ Worker: TestWorker }));
const { default: World } = await import('../../src/engine/World.js');
const { default: Environment } = await import('../../src/util/Environment.js');
const { default: ClientCheatHandler } = await import('../../src/network/game/client/handler/ClientCheatHandler.js');
const { default: ClientCheat } = await import('../../src/network/game/client/model/ClientCheat.js');
const { NetworkPlayer } = await import('../../src/engine/entity/NetworkPlayer.js');
const { default: WSClientSocket } = await import('../../src/server/ws/WSClientSocket.js');
const originalProduction = Environment.node.production;
afterEach(() => {
mock.restore();
Environment.node.production = originalProduction;
});
function staff(level: number) {
return { staffModLevel: level, username: 'moderator', addSessionLog: mock(() => {}), messageGame: mock(() => {}) } as any;
}
for (const production of [false, true]) {
test(`staff can kick through the normal logout path, production=${production}`, () => {
Environment.node.production = production;
const client = new WSClientSocket();
const target = new NetworkPlayer('target', 1n, 1n, client);
const logout = spyOn(target, 'logout').mockImplementation(() => {});
const close = spyOn(client, 'close').mockImplementation(() => {});
spyOn(World, 'getPlayerByUsername').mockReturnValue(target);
new ClientCheatHandler().handle(new ClientCheat('kick target'), staff(2));
expect(target.loggingOut).toBe(true);
expect(logout).toHaveBeenCalledTimes(1);
expect(close).toHaveBeenCalledTimes(1);
});
test(`staff can ban immediately, production=${production}`, () => {
Environment.node.production = production;
const ban = spyOn(World, 'notifyPlayerBan').mockImplementation(() => {});
const before = Date.now();
new ClientCheatHandler().handle(new ClientCheat('ban target 60'), staff(2));
expect(ban).toHaveBeenCalledTimes(1);
expect(ban.mock.calls[0][0]).toBe('moderator');
expect(ban.mock.calls[0][1]).toBe('target');
expect(ban.mock.calls[0][2]).toBeGreaterThanOrEqual(before + 60 * 60_000);
});
test(`players below moderator level cannot kick or ban, production=${production}`, () => {
Environment.node.production = production;
const lookup = spyOn(World, 'getPlayerByUsername');
const ban = spyOn(World, 'notifyPlayerBan');
const handler = new ClientCheatHandler();
for (const level of [0, 1]) {
handler.handle(new ClientCheat('kick target'), staff(level));
handler.handle(new ClientCheat('ban target 60'), staff(level));
}
expect(lookup).not.toHaveBeenCalled();
expect(ban).not.toHaveBeenCalled();
});
}
@@ -0,0 +1,20 @@
import { expect, test } from 'bun:test';
import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
test('live moderation preserves staff authorization in either production mode', () => {
const dir = mkdtempSync(join(tmpdir(), 'live-moderation-'));
try {
mkdirSync(join(dir, 'data/config'), { recursive: true });
copyFileSync(join(import.meta.dir, '../data/config/private.pem'), join(dir, 'data/config/private.pem'));
const result = Bun.spawnSync([process.execPath, 'test', join(import.meta.dir, 'fixtures/live-moderation.ts')], {
cwd: dir,
env: { ...process.env, GE_DATABASE: join(dir, 'market.sqlite') }
});
if (result.exitCode !== 0) throw new Error(result.stdout.toString() + result.stderr.toString());
expect(result.exitCode).toBe(0);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}, 15000);