feat(cli+broker): kick, ban, unban, bans commands
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

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:
Alejandro Gutiérrez
2026-04-17 08:37:38 +01:00
parent 5ddb11b2d5
commit 3ceac68e67
6 changed files with 328 additions and 2 deletions

View File

@@ -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<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 {
this.closed = true;
this.stopStatsReporting();
@@ -1627,6 +1649,13 @@ export class BrokerClient {
private handleServerMessage(msg: Record<string, unknown>): 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) {