From 3ceac68e6794c74aa271f74eeca7cc70d125179b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Fri, 17 Apr 2026 08:37:38 +0100 Subject: [PATCH] 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 - claudemesh ban/unban - 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) --- apps/broker/src/index.ts | 150 +++++++++++++++++++++- apps/cli/package.json | 2 +- apps/cli/src/commands/ban.ts | 80 ++++++++++++ apps/cli/src/commands/kick.ts | 59 +++++++++ apps/cli/src/entrypoints/cli.ts | 10 ++ apps/cli/src/services/broker/ws-client.ts | 29 +++++ 6 files changed, 328 insertions(+), 2 deletions(-) create mode 100644 apps/cli/src/commands/ban.ts create mode 100644 apps/cli/src/commands/kick.ts diff --git a/apps/broker/src/index.ts b/apps/broker/src/index.ts index 5d019f6..e6052c6 100644 --- a/apps/broker/src/index.ts +++ b/apps/broker/src/index.ts @@ -18,7 +18,7 @@ import { WebSocketServer, type WebSocket } from "ws"; import { and, eq, inArray, isNull, lt, sql } from "drizzle-orm"; import { env } from "./env"; 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 { handleCliSync, type CliSyncRequest } from "./cli-sync"; import { generateId } from "@turbostarter/shared/utils"; @@ -3898,6 +3898,154 @@ function handleConnection(ws: WebSocket): void { 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 --- case "create_webhook": { const cw = msg as Extract; diff --git a/apps/cli/package.json b/apps/cli/package.json index d6c837a..63003b3 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "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.", "keywords": [ "claude-code", diff --git a/apps/cli/src/commands/ban.ts b/apps/cli/src/commands/ban.ts new file mode 100644 index 0000000..12f5dce --- /dev/null +++ b/apps/cli/src/commands/ban.ts @@ -0,0 +1,80 @@ +/** + * `claudemesh ban ` — kick + permanently revoke member (can't reconnect) + * `claudemesh unban ` — 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 { + if (!target) { render.err("Usage: claudemesh ban "); 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 { + if (!target) { render.err("Usage: claudemesh unban "); 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 { + 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; + }); +} diff --git a/apps/cli/src/commands/kick.ts b/apps/cli/src/commands/kick.ts new file mode 100644 index 0000000..715f9cf --- /dev/null +++ b/apps/cli/src/commands/kick.ts @@ -0,0 +1,59 @@ +/** + * `claudemesh kick` — disconnect peers from the mesh. + * + * claudemesh kick 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 { + 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; + + 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 | --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; + }); +} diff --git a/apps/cli/src/entrypoints/cli.ts b/apps/cli/src/entrypoints/cli.ts index 1d295c0..4e2b4f5 100644 --- a/apps/cli/src/entrypoints/cli.ts +++ b/apps/cli/src/entrypoints/cli.ts @@ -30,6 +30,12 @@ Mesh claudemesh delete [slug] delete a mesh (alias: rm) claudemesh rename rename a mesh claudemesh share [email] share mesh (invite link / send email) + claudemesh kick disconnect a peer (can reconnect) + claudemesh kick --stale 30m disconnect idle peers (> duration) + claudemesh kick --all disconnect everyone except you + claudemesh ban kick + permanently revoke (can't rejoin) + claudemesh unban lift a ban + claudemesh bans list banned members Messaging claudemesh peers see who's online @@ -133,6 +139,10 @@ async function main(): Promise { 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 "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 case "peers": { const { runPeers } = await import("~/commands/peers.js"); await runPeers({ mesh: flags.mesh as string, json: !!flags.json }); break; } diff --git a/apps/cli/src/services/broker/ws-client.ts b/apps/cli/src/services/broker/ws-client.ts index 9b6b4c2..22fdc44 100644 --- a/apps/cli/src/services/broker/ws-client.ts +++ b/apps/cli/src/services/broker/ws-client.ts @@ -1408,6 +1408,28 @@ export class BrokerClient { 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>(payload: Record, timeoutMs = 10_000): Promise { + const reqId = `rw-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + return new Promise((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) => void>(); + close(): void { this.closed = true; this.stopStatsReporting(); @@ -1627,6 +1649,13 @@ export class BrokerClient { private handleServerMessage(msg: Record): void { 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") { const pending = this.pendingSends.get(String(msg.id ?? "")); if (pending) {