feat(cli+broker): kick, ban, unban, bans commands
Broker WS handlers: - kick: disconnect peer(s) by name, --stale duration, or --all. Authz: owner or admin only. Closes WS + marks presence disconnected. - ban: kick + set revokedAt on mesh.member. Hello already rejects revoked members, so ban is instant and permanent until unban. - unban: clear revokedAt. Peer can rejoin with their existing keypair. - list_bans: return all revoked members for a mesh. Session-id dedup (previous commit): handleHello disconnects ghost presences with matching (meshId, sessionId) before inserting the new one. Eliminates duplicate entries after broker restarts. CLI (alpha.37): - claudemesh kick <peer|--stale 30m|--all> - claudemesh ban/unban <peer> - claudemesh bans [--json] - Uses new sendAndWait() on ws-client for request-response pattern over WS (generic _reqId resolver). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,7 +18,7 @@ import { WebSocketServer, type WebSocket } from "ws";
|
|||||||
import { and, eq, inArray, isNull, lt, sql } from "drizzle-orm";
|
import { and, eq, inArray, isNull, lt, sql } from "drizzle-orm";
|
||||||
import { env } from "./env";
|
import { env } from "./env";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import { invite as inviteTable, mesh, meshMember, messageQueue, scheduledMessage as scheduledMessageTable, meshWebhook, peerState } from "@turbostarter/db/schema/mesh";
|
import { invite as inviteTable, mesh, meshMember, messageQueue, presence, scheduledMessage as scheduledMessageTable, meshWebhook, peerState } from "@turbostarter/db/schema/mesh";
|
||||||
import { user } from "@turbostarter/db/schema/auth";
|
import { user } from "@turbostarter/db/schema/auth";
|
||||||
import { handleCliSync, type CliSyncRequest } from "./cli-sync";
|
import { handleCliSync, type CliSyncRequest } from "./cli-sync";
|
||||||
import { generateId } from "@turbostarter/shared/utils";
|
import { generateId } from "@turbostarter/shared/utils";
|
||||||
@@ -3898,6 +3898,154 @@ function handleConnection(ws: WebSocket): void {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Kick / Ban / Unban ---
|
||||||
|
|
||||||
|
case "kick": {
|
||||||
|
const km = msg as { type: "kick"; target?: string; stale?: number; all?: boolean; _reqId?: string };
|
||||||
|
// Authz: only owner or admin can kick.
|
||||||
|
const [kickMesh] = await db.select({ ownerUserId: mesh.ownerUserId }).from(mesh).where(eq(mesh.id, conn.meshId)).limit(1);
|
||||||
|
const [kickMember] = await db.select({ role: meshMember.role, userId: meshMember.userId }).from(meshMember).where(eq(meshMember.id, conn.memberId)).limit(1);
|
||||||
|
if (!kickMesh || (kickMesh.ownerUserId !== kickMember?.userId && kickMember?.role !== "admin")) {
|
||||||
|
sendError(ws, "forbidden", "only owner or admin can kick", undefined, km._reqId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kicked: string[] = [];
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (km.all) {
|
||||||
|
// Kick everyone except caller
|
||||||
|
for (const [pid, peer] of connections) {
|
||||||
|
if (peer.meshId !== conn.meshId || pid === presenceId) continue;
|
||||||
|
try { peer.ws.close(1000, "kicked"); } catch {}
|
||||||
|
connections.delete(pid);
|
||||||
|
void disconnectPresence(pid);
|
||||||
|
kicked.push(peer.displayName || pid);
|
||||||
|
}
|
||||||
|
} else if (km.stale && typeof km.stale === "number") {
|
||||||
|
// Kick peers idle longer than stale ms
|
||||||
|
const cutoff = now - km.stale;
|
||||||
|
for (const [pid, peer] of connections) {
|
||||||
|
if (peer.meshId !== conn.meshId || pid === presenceId) continue;
|
||||||
|
// Check last_ping_at from DB for accurate staleness
|
||||||
|
const [pres] = await db.select({ lastPingAt: presence.lastPingAt }).from(presence).where(eq(presence.id, pid)).limit(1);
|
||||||
|
if (pres && pres.lastPingAt && pres.lastPingAt.getTime() < cutoff) {
|
||||||
|
try { peer.ws.close(1000, "kicked_stale"); } catch {}
|
||||||
|
connections.delete(pid);
|
||||||
|
void disconnectPresence(pid);
|
||||||
|
kicked.push(peer.displayName || pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (km.target) {
|
||||||
|
// Kick specific peer by name or pubkey
|
||||||
|
for (const [pid, peer] of connections) {
|
||||||
|
if (peer.meshId !== conn.meshId) continue;
|
||||||
|
if (peer.displayName === km.target || peer.memberPubkey === km.target || peer.memberPubkey.startsWith(km.target)) {
|
||||||
|
try { peer.ws.close(1000, "kicked"); } catch {}
|
||||||
|
connections.delete(pid);
|
||||||
|
void disconnectPresence(pid);
|
||||||
|
kicked.push(peer.displayName || pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.ws.send(JSON.stringify({ type: "kick_ack", kicked, _reqId: km._reqId }));
|
||||||
|
log.info("ws kick", { presence_id: presenceId, kicked_count: kicked.length, target: km.target ?? km.stale ?? "all" });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "ban": {
|
||||||
|
const bm = msg as { type: "ban"; target: string; _reqId?: string };
|
||||||
|
if (!bm.target) { sendError(ws, "invalid", "target required", undefined, bm._reqId); break; }
|
||||||
|
|
||||||
|
// Authz: only owner or admin
|
||||||
|
const [banMesh] = await db.select({ ownerUserId: mesh.ownerUserId }).from(mesh).where(eq(mesh.id, conn.meshId)).limit(1);
|
||||||
|
const [banMember] = await db.select({ role: meshMember.role, userId: meshMember.userId }).from(meshMember).where(eq(meshMember.id, conn.memberId)).limit(1);
|
||||||
|
if (!banMesh || (banMesh.ownerUserId !== banMember?.userId && banMember?.role !== "admin")) {
|
||||||
|
sendError(ws, "forbidden", "only owner or admin can ban", undefined, bm._reqId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find member by name or pubkey
|
||||||
|
const [targetMember] = await db.select({ id: meshMember.id, displayName: meshMember.displayName, peerPubkey: meshMember.peerPubkey })
|
||||||
|
.from(meshMember)
|
||||||
|
.where(and(
|
||||||
|
eq(meshMember.meshId, conn.meshId),
|
||||||
|
isNull(meshMember.revokedAt),
|
||||||
|
sql`(${meshMember.displayName} = ${bm.target} OR ${meshMember.peerPubkey} = ${bm.target} OR LEFT(${meshMember.peerPubkey}, ${bm.target.length}) = ${bm.target})`,
|
||||||
|
))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!targetMember) { sendError(ws, "not_found", `peer "${bm.target}" not found`, undefined, bm._reqId); break; }
|
||||||
|
if (targetMember.id === conn.memberId) { sendError(ws, "invalid", "cannot ban yourself", undefined, bm._reqId); break; }
|
||||||
|
|
||||||
|
// Revoke member
|
||||||
|
await db.update(meshMember).set({ revokedAt: new Date() }).where(eq(meshMember.id, targetMember.id));
|
||||||
|
|
||||||
|
// Kick all their connections
|
||||||
|
for (const [pid, peer] of connections) {
|
||||||
|
if (peer.meshId === conn.meshId && peer.memberPubkey === targetMember.peerPubkey) {
|
||||||
|
try { peer.ws.close(1000, "banned"); } catch {}
|
||||||
|
connections.delete(pid);
|
||||||
|
void disconnectPresence(pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void audit(conn.meshId, "member_banned", conn.memberId, conn.displayName, { target: targetMember.displayName, targetPubkey: targetMember.peerPubkey });
|
||||||
|
conn.ws.send(JSON.stringify({ type: "ban_ack", banned: targetMember.displayName, _reqId: bm._reqId }));
|
||||||
|
log.info("ws ban", { presence_id: presenceId, banned: targetMember.displayName, banned_member_id: targetMember.id });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "unban": {
|
||||||
|
const ubm = msg as { type: "unban"; target: string; _reqId?: string };
|
||||||
|
if (!ubm.target) { sendError(ws, "invalid", "target required", undefined, ubm._reqId); break; }
|
||||||
|
|
||||||
|
// Authz
|
||||||
|
const [unbanMesh] = await db.select({ ownerUserId: mesh.ownerUserId }).from(mesh).where(eq(mesh.id, conn.meshId)).limit(1);
|
||||||
|
const [unbanMember] = await db.select({ role: meshMember.role, userId: meshMember.userId }).from(meshMember).where(eq(meshMember.id, conn.memberId)).limit(1);
|
||||||
|
if (!unbanMesh || (unbanMesh.ownerUserId !== unbanMember?.userId && unbanMember?.role !== "admin")) {
|
||||||
|
sendError(ws, "forbidden", "only owner or admin can unban", undefined, ubm._reqId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find revoked member
|
||||||
|
const [revokedMember] = await db.select({ id: meshMember.id, displayName: meshMember.displayName })
|
||||||
|
.from(meshMember)
|
||||||
|
.where(and(
|
||||||
|
eq(meshMember.meshId, conn.meshId),
|
||||||
|
sql`${meshMember.revokedAt} IS NOT NULL`,
|
||||||
|
sql`(${meshMember.displayName} = ${ubm.target} OR ${meshMember.peerPubkey} = ${ubm.target})`,
|
||||||
|
))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!revokedMember) { sendError(ws, "not_found", `no banned peer "${ubm.target}"`, undefined, ubm._reqId); break; }
|
||||||
|
|
||||||
|
await db.update(meshMember).set({ revokedAt: null }).where(eq(meshMember.id, revokedMember.id));
|
||||||
|
void audit(conn.meshId, "member_unbanned", conn.memberId, conn.displayName, { target: revokedMember.displayName });
|
||||||
|
conn.ws.send(JSON.stringify({ type: "unban_ack", unbanned: revokedMember.displayName, _reqId: ubm._reqId }));
|
||||||
|
log.info("ws unban", { presence_id: presenceId, unbanned: revokedMember.displayName });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "list_bans": {
|
||||||
|
const lbm = msg as { type: "list_bans"; _reqId?: string };
|
||||||
|
const banned = await db.select({
|
||||||
|
name: meshMember.displayName,
|
||||||
|
pubkey: meshMember.peerPubkey,
|
||||||
|
revokedAt: meshMember.revokedAt,
|
||||||
|
}).from(meshMember).where(and(
|
||||||
|
eq(meshMember.meshId, conn.meshId),
|
||||||
|
sql`${meshMember.revokedAt} IS NOT NULL`,
|
||||||
|
));
|
||||||
|
conn.ws.send(JSON.stringify({
|
||||||
|
type: "list_bans_result",
|
||||||
|
bans: banned.map((b) => ({ name: b.name, pubkey: b.pubkey, revokedAt: b.revokedAt?.toISOString() })),
|
||||||
|
_reqId: lbm._reqId,
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Webhook CRUD ---
|
// --- Webhook CRUD ---
|
||||||
case "create_webhook": {
|
case "create_webhook": {
|
||||||
const cw = msg as Extract<WSClientMessage, { type: "create_webhook" }>;
|
const cw = msg as Extract<WSClientMessage, { type: "create_webhook" }>;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claudemesh-cli",
|
"name": "claudemesh-cli",
|
||||||
"version": "1.0.0-alpha.36",
|
"version": "1.0.0-alpha.37",
|
||||||
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
|
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"claude-code",
|
"claude-code",
|
||||||
|
|||||||
80
apps/cli/src/commands/ban.ts
Normal file
80
apps/cli/src/commands/ban.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* `claudemesh ban <peer>` — kick + permanently revoke member (can't reconnect)
|
||||||
|
* `claudemesh unban <peer>` — clear revocation, peer can rejoin
|
||||||
|
* `claudemesh bans` — list banned members
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { withMesh } from "./connect.js";
|
||||||
|
import { readConfig } from "~/services/config/facade.js";
|
||||||
|
import { render } from "~/ui/render.js";
|
||||||
|
import { EXIT } from "~/constants/exit-codes.js";
|
||||||
|
|
||||||
|
export async function runBan(
|
||||||
|
target: string | undefined,
|
||||||
|
opts: { mesh?: string } = {},
|
||||||
|
): Promise<number> {
|
||||||
|
if (!target) { render.err("Usage: claudemesh ban <peer-name-or-pubkey>"); return EXIT.INVALID_ARGS; }
|
||||||
|
const config = readConfig();
|
||||||
|
const meshSlug = opts.mesh ?? config.meshes[0]?.slug;
|
||||||
|
if (!meshSlug) { render.err("No mesh joined."); return EXIT.NOT_FOUND; }
|
||||||
|
|
||||||
|
return await withMesh({ meshSlug }, async (client) => {
|
||||||
|
const result = await client.sendAndWait({ type: "ban", target }) as { banned?: string; error?: string };
|
||||||
|
if (result?.banned) {
|
||||||
|
render.ok(`Banned ${result.banned} from ${meshSlug}. They cannot reconnect until unbanned.`);
|
||||||
|
render.hint(`Undo: claudemesh unban ${result.banned} --mesh ${meshSlug}`);
|
||||||
|
} else {
|
||||||
|
render.err(result?.error ?? "ban failed");
|
||||||
|
}
|
||||||
|
return result?.banned ? EXIT.SUCCESS : EXIT.INTERNAL_ERROR;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runUnban(
|
||||||
|
target: string | undefined,
|
||||||
|
opts: { mesh?: string } = {},
|
||||||
|
): Promise<number> {
|
||||||
|
if (!target) { render.err("Usage: claudemesh unban <peer-name-or-pubkey>"); return EXIT.INVALID_ARGS; }
|
||||||
|
const config = readConfig();
|
||||||
|
const meshSlug = opts.mesh ?? config.meshes[0]?.slug;
|
||||||
|
if (!meshSlug) { render.err("No mesh joined."); return EXIT.NOT_FOUND; }
|
||||||
|
|
||||||
|
return await withMesh({ meshSlug }, async (client) => {
|
||||||
|
const result = await client.sendAndWait({ type: "unban", target }) as { unbanned?: string; error?: string };
|
||||||
|
if (result?.unbanned) {
|
||||||
|
render.ok(`Unbanned ${result.unbanned} from ${meshSlug}. They can rejoin.`);
|
||||||
|
} else {
|
||||||
|
render.err(result?.error ?? "unban failed");
|
||||||
|
}
|
||||||
|
return result?.unbanned ? EXIT.SUCCESS : EXIT.INTERNAL_ERROR;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runBans(
|
||||||
|
opts: { mesh?: string; json?: boolean } = {},
|
||||||
|
): Promise<number> {
|
||||||
|
const config = readConfig();
|
||||||
|
const meshSlug = opts.mesh ?? config.meshes[0]?.slug;
|
||||||
|
if (!meshSlug) { render.err("No mesh joined."); return EXIT.NOT_FOUND; }
|
||||||
|
|
||||||
|
return await withMesh({ meshSlug }, async (client) => {
|
||||||
|
const result = await client.sendAndWait({ type: "list_bans" }) as { bans?: Array<{ name: string; pubkey: string; revokedAt: string }> };
|
||||||
|
const bans = result?.bans ?? [];
|
||||||
|
|
||||||
|
if (opts.json) {
|
||||||
|
process.stdout.write(JSON.stringify(bans, null, 2) + "\n");
|
||||||
|
return EXIT.SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bans.length === 0) {
|
||||||
|
render.info("No banned members.");
|
||||||
|
return EXIT.SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
render.section(`banned members on ${meshSlug}`);
|
||||||
|
for (const b of bans) {
|
||||||
|
render.kv([[b.name, `${b.pubkey.slice(0, 16)}… · banned ${new Date(b.revokedAt).toLocaleDateString()}`]]);
|
||||||
|
}
|
||||||
|
return EXIT.SUCCESS;
|
||||||
|
});
|
||||||
|
}
|
||||||
59
apps/cli/src/commands/kick.ts
Normal file
59
apps/cli/src/commands/kick.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* `claudemesh kick` — disconnect peers from the mesh.
|
||||||
|
*
|
||||||
|
* claudemesh kick <name> kick one peer (can reconnect)
|
||||||
|
* claudemesh kick --stale 30m kick idle peers (> 30 min no activity)
|
||||||
|
* claudemesh kick --all kick everyone except yourself
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { withMesh } from "./connect.js";
|
||||||
|
import { readConfig } from "~/services/config/facade.js";
|
||||||
|
import { render } from "~/ui/render.js";
|
||||||
|
import { EXIT } from "~/constants/exit-codes.js";
|
||||||
|
|
||||||
|
function parseStaleMs(input: string): number | null {
|
||||||
|
const m = input.match(/^(\d+)(s|m|h)$/);
|
||||||
|
if (!m) return null;
|
||||||
|
const val = parseInt(m[1]!, 10);
|
||||||
|
const unit = m[2]!;
|
||||||
|
if (unit === "s") return val * 1000;
|
||||||
|
if (unit === "m") return val * 60_000;
|
||||||
|
if (unit === "h") return val * 3600_000;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runKick(
|
||||||
|
target: string | undefined,
|
||||||
|
opts: { mesh?: string; stale?: string; all?: boolean } = {},
|
||||||
|
): Promise<number> {
|
||||||
|
const config = readConfig();
|
||||||
|
const meshSlug = opts.mesh ?? config.meshes[0]?.slug;
|
||||||
|
if (!meshSlug) { render.err("No mesh joined."); return EXIT.NOT_FOUND; }
|
||||||
|
|
||||||
|
return await withMesh({ meshSlug }, async (client) => {
|
||||||
|
let payload: Record<string, unknown>;
|
||||||
|
|
||||||
|
if (opts.all) {
|
||||||
|
payload = { type: "kick", all: true };
|
||||||
|
} else if (opts.stale) {
|
||||||
|
const ms = parseStaleMs(opts.stale);
|
||||||
|
if (!ms) { render.err(`Invalid stale duration: "${opts.stale}". Use e.g. 30m, 1h, 300s.`); return EXIT.INVALID_ARGS; }
|
||||||
|
payload = { type: "kick", stale: ms };
|
||||||
|
} else if (target) {
|
||||||
|
payload = { type: "kick", target };
|
||||||
|
} else {
|
||||||
|
render.err("Usage: claudemesh kick <peer> | --stale 30m | --all");
|
||||||
|
return EXIT.INVALID_ARGS;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await client.sendAndWait(payload) as { kicked?: string[] };
|
||||||
|
const kicked = result?.kicked ?? [];
|
||||||
|
|
||||||
|
if (kicked.length === 0) {
|
||||||
|
render.info("No peers matched.");
|
||||||
|
} else {
|
||||||
|
render.ok(`Kicked ${kicked.length} peer(s): ${kicked.join(", ")}`);
|
||||||
|
}
|
||||||
|
return EXIT.SUCCESS;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -30,6 +30,12 @@ Mesh
|
|||||||
claudemesh delete [slug] delete a mesh (alias: rm)
|
claudemesh delete [slug] delete a mesh (alias: rm)
|
||||||
claudemesh rename <slug> <name> rename a mesh
|
claudemesh rename <slug> <name> rename a mesh
|
||||||
claudemesh share [email] share mesh (invite link / send email)
|
claudemesh share [email] share mesh (invite link / send email)
|
||||||
|
claudemesh kick <peer> disconnect a peer (can reconnect)
|
||||||
|
claudemesh kick --stale 30m disconnect idle peers (> duration)
|
||||||
|
claudemesh kick --all disconnect everyone except you
|
||||||
|
claudemesh ban <peer> kick + permanently revoke (can't rejoin)
|
||||||
|
claudemesh unban <peer> lift a ban
|
||||||
|
claudemesh bans list banned members
|
||||||
|
|
||||||
Messaging
|
Messaging
|
||||||
claudemesh peers see who's online
|
claudemesh peers see who's online
|
||||||
@@ -133,6 +139,10 @@ async function main(): Promise<void> {
|
|||||||
case "delete": case "rm": { const { deleteMesh } = await import("~/commands/delete-mesh.js"); process.exit(await deleteMesh(positionals[0] ?? "", { yes: !!flags.y || !!flags.yes })); break; }
|
case "delete": case "rm": { const { deleteMesh } = await import("~/commands/delete-mesh.js"); process.exit(await deleteMesh(positionals[0] ?? "", { yes: !!flags.y || !!flags.yes })); break; }
|
||||||
case "rename": { const { rename } = await import("~/commands/rename.js"); process.exit(await rename(positionals[0] ?? "", positionals[1] ?? "")); break; }
|
case "rename": { const { rename } = await import("~/commands/rename.js"); process.exit(await rename(positionals[0] ?? "", positionals[1] ?? "")); break; }
|
||||||
case "share": case "invite": { const { invite } = await import("~/commands/invite.js"); process.exit(await invite(positionals[0], { mesh: flags.mesh as string, json: !!flags.json })); break; }
|
case "share": case "invite": { const { invite } = await import("~/commands/invite.js"); process.exit(await invite(positionals[0], { mesh: flags.mesh as string, json: !!flags.json })); break; }
|
||||||
|
case "kick": { const { runKick } = await import("~/commands/kick.js"); process.exit(await runKick(positionals[0], { mesh: flags.mesh as string, stale: flags.stale as string, all: !!flags.all })); break; }
|
||||||
|
case "ban": { const { runBan } = await import("~/commands/ban.js"); process.exit(await runBan(positionals[0], { mesh: flags.mesh as string })); break; }
|
||||||
|
case "unban": { const { runUnban } = await import("~/commands/ban.js"); process.exit(await runUnban(positionals[0], { mesh: flags.mesh as string })); break; }
|
||||||
|
case "bans": { const { runBans } = await import("~/commands/ban.js"); process.exit(await runBans({ mesh: flags.mesh as string, json: !!flags.json })); break; }
|
||||||
|
|
||||||
// Messaging
|
// Messaging
|
||||||
case "peers": { const { runPeers } = await import("~/commands/peers.js"); await runPeers({ mesh: flags.mesh as string, json: !!flags.json }); break; }
|
case "peers": { const { runPeers } = await import("~/commands/peers.js"); await runPeers({ mesh: flags.mesh as string, json: !!flags.json }); break; }
|
||||||
|
|||||||
@@ -1408,6 +1408,28 @@ export class BrokerClient {
|
|||||||
this.ws.send(JSON.stringify(payload));
|
this.ws.send(JSON.stringify(payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public request-response: sends a raw message with a generated _reqId
|
||||||
|
* and resolves when the broker responds with any message containing the
|
||||||
|
* same _reqId. Used by kick/ban/unban/bans CLI commands.
|
||||||
|
*/
|
||||||
|
async sendAndWait<T = Record<string, unknown>>(payload: Record<string, unknown>, timeoutMs = 10_000): Promise<T> {
|
||||||
|
const reqId = `rw-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.genericResolvers.delete(reqId);
|
||||||
|
reject(new Error("sendAndWait timeout"));
|
||||||
|
}, timeoutMs);
|
||||||
|
this.genericResolvers.set(reqId, (msg) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
this.genericResolvers.delete(reqId);
|
||||||
|
resolve(msg as T);
|
||||||
|
});
|
||||||
|
this.sendRaw({ ...payload, _reqId: reqId });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
private genericResolvers = new Map<string, (msg: Record<string, unknown>) => void>();
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
this.closed = true;
|
this.closed = true;
|
||||||
this.stopStatsReporting();
|
this.stopStatsReporting();
|
||||||
@@ -1627,6 +1649,13 @@ export class BrokerClient {
|
|||||||
private handleServerMessage(msg: Record<string, unknown>): void {
|
private handleServerMessage(msg: Record<string, unknown>): void {
|
||||||
const msgReqId = msg._reqId as string | undefined;
|
const msgReqId = msg._reqId as string | undefined;
|
||||||
|
|
||||||
|
// Generic request-response resolver (kick_ack, ban_ack, unban_ack, etc.)
|
||||||
|
if (msgReqId && this.genericResolvers.has(msgReqId)) {
|
||||||
|
const resolve = this.genericResolvers.get(msgReqId)!;
|
||||||
|
resolve(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (msg.type === "ack") {
|
if (msg.type === "ack") {
|
||||||
const pending = this.pendingSends.get(String(msg.id ?? ""));
|
const pending = this.pendingSends.get(String(msg.id ?? ""));
|
||||||
if (pending) {
|
if (pending) {
|
||||||
|
|||||||
Reference in New Issue
Block a user