fix(guest): tear down guest WS on guest-mode config change

An open guest WebSocket stamped isGuest/botScope once at upgrade and
never rechecked them, so it kept streaming bot state after an admin
disabled guest mode or narrowed guestMode.bots. setupWebSocket now
returns { cleanup, refreshGuestPolicy }; POST /api/bot/settings invokes
refreshGuestPolicy after saving a guestMode change, force-closing guest
sockets when disabled and live re-scoping them otherwise.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 4.8 committed 2026-06-25 16:05:14 +08:00
1 parent c1d73b6ba8
commit 43c0175334
4 files changed
+120 -8

No files matched your search

+11 -2
View File
@@ -1,6 +1,6 @@
import { Router } from "express"; import { Router } from "express";
import type { BotManager } from "../../bot/manager.js"; import type { BotManager } from "../../bot/manager.js";
import type { BotConfig } from "../../data/config.js"; import type { BotConfig, GuestModeConfig } from "../../data/config.js";
import { saveConfig } from "../../data/config.js"; import { saveConfig } from "../../data/config.js";
import type { Logger } from "../../logger.js"; import type { Logger } from "../../logger.js";
import type { BotDatabase } from "../../data/database.js"; import type { BotDatabase } from "../../data/database.js";
@@ -16,6 +16,7 @@ export function createBotRouter(
logger: Logger, logger: Logger,
botDb: BotDatabase, botDb: BotDatabase,
avatarStore: AvatarStore, avatarStore: AvatarStore,
onGuestPolicyChanged?: (cfg: GuestModeConfig) => void,
): Router { ): Router {
const router = Router(); const router = Router();
@@ -55,7 +56,8 @@ export function createBotRouter(
if (hasIdle) config.idleTimeoutMinutes = idleTimeoutMinutes; if (hasIdle) config.idleTimeoutMinutes = idleTimeoutMinutes;
if (hasAutoPause) config.autoPauseOnEmpty = autoPauseOnEmpty; if (hasAutoPause) config.autoPauseOnEmpty = autoPauseOnEmpty;
if (guestMode !== undefined && guestMode !== null && typeof guestMode === "object") { const hasGuestMode = guestMode !== undefined && guestMode !== null && typeof guestMode === "object";
if (hasGuestMode) {
const gm = config.guestMode; const gm = config.guestMode;
if (typeof guestMode.enabled === "boolean") gm.enabled = guestMode.enabled; if (typeof guestMode.enabled === "boolean") gm.enabled = guestMode.enabled;
if (guestMode.bots === "all") { if (guestMode.bots === "all") {
@@ -74,6 +76,13 @@ export function createBotRouter(
saveConfig(configPath, config); saveConfig(configPath, config);
// Guest-mode changed: tear down / re-scope in-flight guest WS sockets so a
// disabled or narrowed scope takes effect immediately (matches requireAuth's
// "disabling immediately invalidates in-flight guest sessions" invariant).
if (hasGuestMode) {
onGuestPolicyChanged?.(config.guestMode);
}
// 通知所有 bot 实例更新 // 通知所有 bot 实例更新
for (const bot of botManager.getAllBots()) { for (const bot of botManager.getAllBots()) {
if (hasIdle) bot.updateIdleTimeout(config.idleTimeoutMinutes); if (hasIdle) bot.updateIdleTimeout(config.idleTimeoutMinutes);
+10 -3
View File
@@ -6,7 +6,7 @@ import { WebSocketServer } from "ws";
import type { BotManager } from "../bot/manager.js"; import type { BotManager } from "../bot/manager.js";
import type { MusicProvider } from "../music/provider.js"; import type { MusicProvider } from "../music/provider.js";
import type { BotDatabase } from "../data/database.js"; import type { BotDatabase } from "../data/database.js";
import type { BotConfig } from "../data/config.js"; import type { BotConfig, GuestModeConfig } from "../data/config.js";
import type { Logger } from "../logger.js"; import type { Logger } from "../logger.js";
import type { CookieStore } from "../music/auth.js"; import type { CookieStore } from "../music/auth.js";
import type { AvatarStore } from "../data/avatars.js"; import type { AvatarStore } from "../data/avatars.js";
@@ -104,6 +104,11 @@ export function createWebServer(options: WebServerOptions): WebServer {
app.use("/api", requireAuth); app.use("/api", requireAuth);
// ─── Protected routes ─────────────────────────────────────────────────── // ─── Protected routes ───────────────────────────────────────────────────
// The bot router is mounted BEFORE setupWebSocket runs, but its /settings
// handler needs to trigger a guest-policy refresh on the (later-created) WS
// controller. Bridge the two with a mutable indirection that starts as a
// no-op and is wired to the real refreshGuestPolicy once the WS is set up.
let onGuestPolicyChanged: (cfg: GuestModeConfig) => void = () => {};
app.use( app.use(
"/api/bot", "/api/bot",
createBotRouter( createBotRouter(
@@ -113,6 +118,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
logger, logger,
options.database, options.database,
options.avatarStore, options.avatarStore,
(cfg) => onGuestPolicyChanged(cfg),
) )
); );
app.use( app.use(
@@ -195,7 +201,8 @@ export function createWebServer(options: WebServerOptions): WebServer {
wss.emit("connection", ws, req); wss.emit("connection", ws, req);
}); });
}); });
const cleanupWs = setupWebSocket(wss, options.botManager, logger); const controller = setupWebSocket(wss, options.botManager, logger);
onGuestPolicyChanged = controller.refreshGuestPolicy;
// ─── Session cleanup interval ────────────────────────────────────────── // ─── Session cleanup interval ──────────────────────────────────────────
let cleanupTimer: ReturnType<typeof setInterval> | null = null; let cleanupTimer: ReturnType<typeof setInterval> | null = null;
@@ -221,7 +228,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
clearInterval(cleanupTimer); clearInterval(cleanupTimer);
cleanupTimer = null; cleanupTimer = null;
} }
cleanupWs(); controller.cleanup();
wss.close(); wss.close();
server.close(); server.close();
}, },
+66 -1
View File
@@ -102,7 +102,7 @@ describe("WebSocket guest bot scope", () => {
off: () => {}, off: () => {},
removeListener: () => {}, removeListener: () => {},
}; };
const cleanup = setupWebSocket(fakeWss, botManager, { const { cleanup } = setupWebSocket(fakeWss, botManager, {
debug() {}, debug() {},
error() {}, error() {},
info() {}, info() {},
@@ -114,3 +114,68 @@ describe("WebSocket guest bot scope", () => {
cleanup(); cleanup();
}); });
}); });
describe("WebSocket refreshGuestPolicy", () => {
function makeHarness() {
const clients: any[] = [];
const fakeWss: any = {
on: (ev: string, cb: any) => {
if (ev === "connection") fakeWss._conn = cb;
},
};
const botManager: any = {
getAllBots: () => [],
on: () => {},
off: () => {},
removeListener: () => {},
};
const logger = { debug() {}, error() {}, info() {}, warn() {} } as any;
const controller = setupWebSocket(fakeWss, botManager, logger);
// Connect fake sockets via the connection handler so they land in `clients`.
const connect = (ws: any) => {
clients.push(ws);
fakeWss._conn(ws);
};
return { controller, connect };
}
function makeFakeWs(opts: { isGuest: boolean; botScope?: "all" | Set<string> }) {
const closeCalls: Array<{ code?: number; reason?: string }> = [];
const ws: any = {
readyState: 1,
isGuest: opts.isGuest,
botScope: opts.botScope,
send: () => {},
on: () => {},
close: (code?: number, reason?: string) => closeCalls.push({ code, reason }),
};
return { ws, closeCalls };
}
it("disabling guest mode closes guest sockets but leaves non-guest sockets open", () => {
const { controller, connect } = makeHarness();
const guest = makeFakeWs({ isGuest: true, botScope: new Set(["bot1"]) });
const member = makeFakeWs({ isGuest: false, botScope: "all" });
connect(guest.ws);
connect(member.ws);
controller.refreshGuestPolicy({ enabled: false, bots: "all" });
expect(guest.closeCalls.length).toBe(1);
expect(guest.closeCalls[0].code).toBe(1008);
expect(member.closeCalls.length).toBe(0);
});
it("narrowing the guest scope live re-scopes open guest sockets", () => {
const { controller, connect } = makeHarness();
const guest = makeFakeWs({ isGuest: true, botScope: new Set(["bot1"]) });
connect(guest.ws);
controller.refreshGuestPolicy({ enabled: true, bots: ["bot2"] });
expect(guest.closeCalls.length).toBe(0);
expect(guest.ws.botScope instanceof Set).toBe(true);
expect(guest.ws.botScope.has("bot2")).toBe(true);
expect(guest.ws.botScope.has("bot1")).toBe(false);
});
});
+33 -2
View File
@@ -3,11 +3,21 @@ import type { BotManager } from "../bot/manager.js";
import type { BotInstance } from "../bot/instance.js"; import type { BotInstance } from "../bot/instance.js";
import type { Logger } from "../logger.js"; import type { Logger } from "../logger.js";
export interface WebSocketController {
cleanup: () => void;
/**
* Re-apply the current guest-mode policy to every already-open guest socket.
* If guest mode is disabled, in-flight guest sockets are force-closed; otherwise
* each guest socket is live re-scoped so out-of-scope bots stop streaming.
*/
refreshGuestPolicy: (cfg: { enabled: boolean; bots: "all" | string[] }) => void;
}
export function setupWebSocket( export function setupWebSocket(
wss: WebSocketServer, wss: WebSocketServer,
botManager: BotManager, botManager: BotManager,
logger: Logger logger: Logger
): () => void { ): WebSocketController {
const clients = new Set<WebSocket>(); const clients = new Set<WebSocket>();
/** /**
@@ -150,7 +160,7 @@ export function setupWebSocket(
}, 5000); }, 5000);
ensureAllBotsAttached(); ensureAllBotsAttached();
return () => { const cleanup = () => {
clearInterval(intervalId); clearInterval(intervalId);
botManager.removeListener("botInstance", onBotInstance); botManager.removeListener("botInstance", onBotInstance);
botManager.removeListener("botInstanceRemoved", onBotInstanceRemoved); botManager.removeListener("botInstanceRemoved", onBotInstanceRemoved);
@@ -159,4 +169,25 @@ export function setupWebSocket(
detachBotListener(id); detachBotListener(id);
} }
}; };
// When the admin changes guestMode (disable / narrow scope), already-open guest
// sockets must stop streaming immediately — their isGuest/botScope were stamped
// once at upgrade and would otherwise keep receiving bot state.
const refreshGuestPolicy = (cfg: { enabled: boolean; bots: "all" | string[] }) => {
for (const ws of clients) {
const w = ws as unknown as { isGuest?: boolean; botScope?: "all" | Set<string> };
if (!w.isGuest) continue;
if (!cfg.enabled) {
try {
ws.close(1008, "guest mode disabled");
} catch {
// socket may already be closing; ignore
}
} else {
w.botScope = cfg.bots === "all" ? "all" : new Set(cfg.bots);
}
}
};
return { cleanup, refreshGuestPolicy };
} }