@sleep2agi/commhub-server 0.8.1-preview.2 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.8.1-preview.2",
3
+ "version": "0.8.1",
4
4
  "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 17 MCP tools.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/db.ts CHANGED
@@ -19,6 +19,11 @@ db.exec(`
19
19
  output TEXT,
20
20
  progress INTEGER DEFAULT 0,
21
21
  score REAL,
22
+ cpu_load_1min REAL,
23
+ cpu_cores INTEGER,
24
+ mem_total_gb REAL,
25
+ mem_used_gb REAL,
26
+ mem_avail_gb REAL,
22
27
  network_id TEXT NOT NULL DEFAULT 'default',
23
28
  registered_at TEXT NOT NULL DEFAULT (datetime('now')),
24
29
  updated_at TEXT NOT NULL DEFAULT (datetime('now')),
@@ -55,7 +60,7 @@ db.exec(`
55
60
 
56
61
  // ── V2 schema migration (ALTER TABLE, safe to re-run) ──
57
62
 
58
- // sessions: add node_id, session_id, config_path, channels, last_seen_at, model
63
+ // sessions: add node_id, session_id, config_path, channels, last_seen_at, model, host telemetry
59
64
  for (const col of [
60
65
  { name: "node_id", def: "TEXT" },
61
66
  { name: "session_id", def: "TEXT" },
@@ -63,6 +68,11 @@ for (const col of [
63
68
  { name: "channels", def: "TEXT" },
64
69
  { name: "last_seen_at", def: "TEXT" },
65
70
  { name: "model", def: "TEXT" },
71
+ { name: "cpu_load_1min", def: "REAL" },
72
+ { name: "cpu_cores", def: "INTEGER" },
73
+ { name: "mem_total_gb", def: "REAL" },
74
+ { name: "mem_used_gb", def: "REAL" },
75
+ { name: "mem_avail_gb", def: "REAL" },
66
76
  ]) {
67
77
  try { db.exec(`ALTER TABLE sessions ADD COLUMN ${col.name} ${col.def}`); } catch {}
68
78
  }
@@ -376,6 +386,12 @@ function migrateSessionsNetworkAliasUnique() {
376
386
  config_path TEXT,
377
387
  channels TEXT,
378
388
  last_seen_at TEXT,
389
+ model TEXT,
390
+ cpu_load_1min REAL,
391
+ cpu_cores INTEGER,
392
+ mem_total_gb REAL,
393
+ mem_used_gb REAL,
394
+ mem_avail_gb REAL,
379
395
  network_id TEXT NOT NULL DEFAULT 'default',
380
396
  UNIQUE (network_id, alias)
381
397
  )
@@ -384,12 +400,14 @@ function migrateSessionsNetworkAliasUnique() {
384
400
  INSERT OR REPLACE INTO sessions_migrated (
385
401
  resume_id, alias, tmux_name, server, ip, hostname, agent, project_dir, version,
386
402
  status, task, output, progress, score, registered_at, updated_at, node_id,
387
- session_id, config_path, channels, last_seen_at, network_id
403
+ session_id, config_path, channels, last_seen_at, model, cpu_load_1min,
404
+ cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb, network_id
388
405
  )
389
406
  SELECT
390
407
  resume_id, alias, tmux_name, server, ip, hostname, agent, project_dir, version,
391
408
  status, task, output, progress, score, registered_at, updated_at, node_id,
392
- session_id, config_path, channels, last_seen_at,
409
+ session_id, config_path, channels, last_seen_at, model, cpu_load_1min,
410
+ cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb,
393
411
  COALESCE(NULLIF(network_id, ''), 'default')
394
412
  FROM sessions
395
413
  ORDER BY updated_at
package/src/index.ts CHANGED
@@ -266,7 +266,7 @@ function corsHeaders(req: Request): Record<string, string> {
266
266
  const allowed = CORS_ORIGINS.includes(origin) ? origin : "";
267
267
  return {
268
268
  "Access-Control-Allow-Origin": allowed,
269
- "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
269
+ "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
270
270
  "Access-Control-Allow-Headers": "Content-Type, Authorization",
271
271
  "Access-Control-Max-Age": "86400",
272
272
  };
@@ -841,6 +841,59 @@ Bun.serve({
841
841
  return withCors(req, Response.json({ ok: true, sessions, summary }));
842
842
  }
843
843
 
844
+ // ── REST: aggregate agents by physical server ──
845
+ if (url.pathname === "/api/servers") {
846
+ const cutoff = new Date(Date.now() - 10 * 60 * 1000).toISOString().replace("T", " ").slice(0, 19);
847
+ const staleParams: any[] = [cutoff];
848
+ let staleSql = "UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'";
849
+ staleSql = addNetworkScope(staleSql, staleParams, restScope);
850
+ db.run(staleSql, staleParams);
851
+
852
+ const params: any[] = [];
853
+ let sql = `
854
+ SELECT hostname, ip, cpu_load_1min, cpu_cores, mem_avail_gb, mem_used_gb,
855
+ COALESCE(last_seen_at, updated_at) AS last_seen
856
+ FROM sessions
857
+ WHERE 1=1
858
+ `;
859
+ sql = addNetworkScope(sql, params, restScope);
860
+ sql += " ORDER BY COALESCE(last_seen_at, updated_at) DESC";
861
+
862
+ const grouped = new Map<string, {
863
+ hostname: string;
864
+ ip: string;
865
+ agent_count: number;
866
+ cpu_load_1min: number | null;
867
+ cpu_cores: number | null;
868
+ mem_avail_gb: number | null;
869
+ mem_used_gb: number | null;
870
+ last_seen: string | null;
871
+ }>();
872
+
873
+ for (const row of db.all<any>(sql, ...params)) {
874
+ const hostname = row.hostname || "unknown";
875
+ const ip = row.ip || "unknown";
876
+ const key = `${hostname}\u0000${ip}`;
877
+ const existing = grouped.get(key);
878
+ if (existing) {
879
+ existing.agent_count += 1;
880
+ continue;
881
+ }
882
+ grouped.set(key, {
883
+ hostname,
884
+ ip,
885
+ agent_count: 1,
886
+ cpu_load_1min: row.cpu_load_1min ?? null,
887
+ cpu_cores: row.cpu_cores ?? null,
888
+ mem_avail_gb: row.mem_avail_gb ?? null,
889
+ mem_used_gb: row.mem_used_gb ?? null,
890
+ last_seen: row.last_seen ?? null,
891
+ });
892
+ }
893
+
894
+ return withCors(req, Response.json(Array.from(grouped.values())));
895
+ }
896
+
844
897
  // ── REST: send task ──
845
898
  if (url.pathname === "/api/task" && req.method === "POST") {
846
899
  let raw: unknown;
@@ -1109,6 +1162,52 @@ Bun.serve({
1109
1162
  return withCors(req, Response.json({ ok: true, events: rows, count: rows.length }));
1110
1163
  }
1111
1164
 
1165
+ // ── REST: delete node (Dashboard/CLI remote cleanup) ──
1166
+ const nodeDeleteMatch = url.pathname.match(/^\/api\/nodes\/([^/]+)$/);
1167
+ if (nodeDeleteMatch && req.method === "DELETE") {
1168
+ const ref = decodeURIComponent(nodeDeleteMatch[1]);
1169
+ const params: any[] = [ref, ref, ref];
1170
+ let sql = "SELECT * FROM nodes WHERE (node_id = ?1 OR node_name = ?2 OR alias = ?3)";
1171
+ sql = addNetworkScope(sql, params, restScope);
1172
+ sql += " ORDER BY updated_at DESC LIMIT 1";
1173
+ const node = db.get<any>(sql, ...params);
1174
+ if (!node) return withCors(req, Response.json({ ok: false, error: "node not found" }, { status: 404 }));
1175
+
1176
+ const nodeNetId = node.network_id ?? singleNetworkId(restScope);
1177
+ if (!canRestWriteNetwork(restAuth, nodeNetId, isAdmin)) {
1178
+ return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
1179
+ }
1180
+
1181
+ db.transaction(() => {
1182
+ db.run("DELETE FROM nodes WHERE node_id = ?1", [node.node_id]);
1183
+ if (node.alias) {
1184
+ db.run(
1185
+ "DELETE FROM sessions WHERE alias = ?1 AND (network_id = ?2 OR (?2 IS NULL AND network_id IS NULL))",
1186
+ [node.alias, node.network_id ?? null]
1187
+ );
1188
+ }
1189
+ });
1190
+
1191
+ if (node.alias) {
1192
+ pushEvent(node.alias, {
1193
+ type: "node_deleted",
1194
+ node_id: node.node_id,
1195
+ node_name: node.node_name,
1196
+ alias: node.alias,
1197
+ network_id: node.network_id ?? null,
1198
+ }, node.network_id ?? null);
1199
+ }
1200
+
1201
+ return withCors(req, Response.json({
1202
+ ok: true,
1203
+ deleted: true,
1204
+ node_id: node.node_id,
1205
+ node_name: node.node_name,
1206
+ alias: node.alias,
1207
+ network_id: node.network_id ?? null,
1208
+ }));
1209
+ }
1210
+
1112
1211
  // ── REST: nodes table (V2 Sprint 2) ──
1113
1212
  if (url.pathname === "/api/nodes") {
1114
1213
  const nodeId = url.searchParams.get("node_id");
package/src/tools.ts CHANGED
@@ -29,6 +29,16 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
29
29
  return !!role && role !== "viewer"; // owner/admin/member can write
30
30
  };
31
31
 
32
+ const writeDeniedReply = (effectiveNetworkId?: string | null, action = "write") => {
33
+ const netId = enforceNetworkId ?? effectiveNetworkId ?? null;
34
+ const message = !netId
35
+ ? "network_id required (utok current_network is null; pass explicit network_id from /api/auth/me networks[0].network_id)"
36
+ : action === "send_task"
37
+ ? "Viewer role cannot send tasks"
38
+ : "Viewer role cannot write to this network";
39
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied", message }) }] };
40
+ };
41
+
32
42
  const addScope = (sql: string, params: any[], networkId?: string | null, column = "network_id"): string => {
33
43
  if (!networkId) return sql;
34
44
  sql += ` AND ${column} = ?${params.length + 1}`;
@@ -109,25 +119,41 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
109
119
  model: z.string().max(200).optional().describe("AI model name"),
110
120
  node_name: z.string().max(200).optional().describe("Stable node display name (may differ from alias)"),
111
121
  network_id: z.string().max(200).optional().describe("Network this agent belongs to"),
122
+ host: z.object({
123
+ hostname: z.string().max(200).optional(),
124
+ ip: z.string().max(200).optional(),
125
+ cpu_load_1min: z.number().nullable().optional(),
126
+ cpu_cores: z.number().nullable().optional(),
127
+ mem_total_gb: z.number().nullable().optional(),
128
+ mem_used_gb: z.number().nullable().optional(),
129
+ mem_avail_gb: z.number().nullable().optional(),
130
+ }).optional().describe("Host telemetry reported by agent-node"),
112
131
  },
113
- async ({ resume_id, alias, status, task, output, score, progress, server: srv, hostname: hn, agent: ag, project_dir: pd, version: ver, tmux_name: tmux, node_id, session_id, config_path, channels, model: mdl, node_name: nn, network_id: netId }) => {
132
+ async ({ resume_id, alias, status, task, output, score, progress, server: srv, hostname: hn, agent: ag, project_dir: pd, version: ver, tmux_name: tmux, node_id, session_id, config_path, channels, model: mdl, node_name: nn, network_id: netId, host }) => {
114
133
  const effectiveNetId = getNetworkId(netId);
115
134
  const sessionNetId = effectiveNetId ?? "default";
116
135
  if (!callerTokenIsNetwork || !enforceNetworkId) {
117
136
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "network_token_required" }) }] };
118
137
  }
119
138
  if (!canWrite(effectiveNetId)) {
120
- return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
139
+ return writeDeniedReply(effectiveNetId);
121
140
  }
122
141
  console.log(`[${ts()}] ${alias} (${resume_id.slice(0, 8)}) → report_status: ${status}${task ? " | " + task.slice(0, 60) : ""}${effectiveNetId ? " [net]" : ""}`);
123
142
  const trimmedOutput = output?.slice(0, 4000);
143
+ const hostHostname = host?.hostname || hn || null;
144
+ const hostIp = host?.ip || clientIP || null;
145
+ const cpuLoad1m = typeof host?.cpu_load_1min === "number" ? host.cpu_load_1min : null;
146
+ const cpuCores = typeof host?.cpu_cores === "number" ? host.cpu_cores : null;
147
+ const memTotalGb = typeof host?.mem_total_gb === "number" ? host.mem_total_gb : null;
148
+ const memUsedGb = typeof host?.mem_used_gb === "number" ? host.mem_used_gb : null;
149
+ const memAvailGb = typeof host?.mem_avail_gb === "number" ? host.mem_avail_gb : null;
124
150
 
125
151
  db.transaction(() => {
126
152
  // Only delete same-alias sessions within the same network
127
153
  db.run("DELETE FROM sessions WHERE alias = ?1 AND resume_id != ?2 AND network_id = ?3", [alias, resume_id, sessionNetId]);
128
154
  db.run(
129
- `INSERT INTO sessions (resume_id, alias, tmux_name, server, ip, hostname, agent, project_dir, version, status, task, output, progress, score, node_id, session_id, config_path, channels, network_id, model, last_seen_at, updated_at)
130
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, datetime('now'), datetime('now'))
155
+ `INSERT INTO sessions (resume_id, alias, tmux_name, server, ip, hostname, agent, project_dir, version, status, task, output, progress, score, node_id, session_id, config_path, channels, network_id, model, cpu_load_1min, cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb, last_seen_at, updated_at)
156
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, datetime('now'), datetime('now'))
131
157
  ON CONFLICT(resume_id) DO UPDATE SET
132
158
  alias = COALESCE(?2, sessions.alias), tmux_name = COALESCE(?3, sessions.tmux_name),
133
159
  server = COALESCE(?4, sessions.server), ip = COALESCE(?5, sessions.ip),
@@ -139,8 +165,13 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
139
165
  session_id = COALESCE(?16, sessions.session_id), config_path = COALESCE(?17, sessions.config_path),
140
166
  channels = COALESCE(?18, sessions.channels), network_id = COALESCE(?19, sessions.network_id),
141
167
  model = COALESCE(?20, sessions.model),
168
+ cpu_load_1min = COALESCE(?21, sessions.cpu_load_1min),
169
+ cpu_cores = COALESCE(?22, sessions.cpu_cores),
170
+ mem_total_gb = COALESCE(?23, sessions.mem_total_gb),
171
+ mem_used_gb = COALESCE(?24, sessions.mem_used_gb),
172
+ mem_avail_gb = COALESCE(?25, sessions.mem_avail_gb),
142
173
  last_seen_at = datetime('now'), updated_at = datetime('now')`,
143
- [resume_id, alias, tmux ?? null, srv ?? null, clientIP ?? null, hn ?? null, ag ?? null, pd ?? null, ver ?? null, status, task ?? null, trimmedOutput ?? null, progress ?? null, score ?? null, node_id ?? null, session_id ?? null, config_path ?? null, channels ?? null, sessionNetId, mdl ?? null]
174
+ [resume_id, alias, tmux ?? null, srv ?? null, hostIp, hostHostname, ag ?? null, pd ?? null, ver ?? null, status, task ?? null, trimmedOutput ?? null, progress ?? null, score ?? null, node_id ?? null, session_id ?? null, config_path ?? null, channels ?? null, sessionNetId, mdl ?? null, cpuLoad1m, cpuCores, memTotalGb, memUsedGb, memAvailGb]
144
175
  );
145
176
  });
146
177
 
@@ -225,7 +256,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
225
256
  async ({ alias, task, result, artifacts, score, duration_minutes, network_id: netId }) => {
226
257
  const effectiveNetId = getNetworkId(netId);
227
258
  if (!canWrite(effectiveNetId)) {
228
- return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
259
+ return writeDeniedReply(effectiveNetId);
229
260
  }
230
261
  console.log(`[${ts()}] ${alias} → report_completion: ${task.slice(0, 60)}${effectiveNetId ? " [net]" : ""}`);
231
262
  const id = uuidv4();
@@ -337,7 +368,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
337
368
  },
338
369
  async ({ alias, message_id, response }) => {
339
370
  const effectiveNetId = getNetworkId(null);
340
- if (!canWrite(effectiveNetId)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
371
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
341
372
  console.log(`[${ts()}] ${alias} → ack_inbox: ${message_id.slice(0, 8)}`);
342
373
  const ackParams: any[] = [message_id, alias];
343
374
  let ackSql = "UPDATE inbox SET acked = 1 WHERE id = ?1 AND session_name = ?2";
@@ -478,7 +509,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
478
509
 
479
510
  // Role check: viewer cannot send tasks
480
511
  if (!canWrite(effectiveNetId)) {
481
- return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied", message: "Viewer role cannot send tasks" }) }] };
512
+ return writeDeniedReply(effectiveNetId, "send_task");
482
513
  }
483
514
 
484
515
  // License check
@@ -557,7 +588,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
557
588
  },
558
589
  async ({ alias, message, from_session: _fromIn }) => { const from_session = defaultFrom(_fromIn);
559
590
  const effectiveNetId = getNetworkId(null);
560
- if (!canWrite(effectiveNetId)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
591
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
561
592
  console.log(`[${ts()}] ${from_session} → send_message → ${alias}: ${message.slice(0, 60)}`);
562
593
  const id = uuidv4();
563
594
  db.run(
@@ -598,7 +629,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
598
629
  },
599
630
  async ({ alias, text, in_reply_to, status: replyStatus, from_session: _fromIn }) => { const from_session = defaultFrom(_fromIn);
600
631
  const effectiveNetId = getNetworkId(null);
601
- if (!canWrite(effectiveNetId)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
632
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
602
633
  console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${alias}: ${text.slice(0, 60)}`);
603
634
  const id = uuidv4();
604
635
  const replyLogged = db.transaction(() => {
@@ -673,7 +704,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
673
704
  },
674
705
  async ({ task_id, from_session: _fromIn }) => { const from_session = defaultFrom(_fromIn);
675
706
  const effectiveNetId = getNetworkId(null);
676
- if (!canWrite(effectiveNetId)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
707
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
677
708
  console.log(`[${ts()}] ${from_session} → send_ack → task ${task_id.slice(0, 8)}`);
678
709
  const updateParams: any[] = [task_id];
679
710
  let updateSql = "UPDATE tasks SET status = 'acked' WHERE task_id = ?1 AND status IN ('created', 'delivered')";
@@ -699,7 +730,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
699
730
  },
700
731
  async ({ task_id, from_session: _fromIn }) => { const from_session = defaultFrom(_fromIn);
701
732
  const effectiveNetId = getNetworkId(null);
702
- if (!canWrite(effectiveNetId)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
733
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
703
734
  console.log(`[${ts()}] ${from_session} → retry_task → ${task_id.slice(0, 8)}`);
704
735
  // Find the original task
705
736
  const taskParams: any[] = [task_id];
@@ -810,7 +841,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
810
841
  },
811
842
  async ({ task_id, reason, from_session: _fromIn }) => { const from_session = defaultFrom(_fromIn);
812
843
  const effectiveNetId = getNetworkId(null);
813
- if (!canWrite(effectiveNetId)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
844
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
814
845
  console.log(`[${ts()}] ${from_session} → cancel_task → ${task_id.slice(0, 8)}`);
815
846
  const updateParams: any[] = [reason || "cancelled by " + from_session, task_id];
816
847
  let updateSql = `UPDATE tasks SET status = 'cancelled', result = ?1, completed_at = datetime('now')
@@ -842,7 +873,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
842
873
  },
843
874
  async ({ task_id, new_alias, from_session: _fromIn }) => { const from_session = defaultFrom(_fromIn);
844
875
  const effectiveNetId = getNetworkId(null);
845
- if (!canWrite(effectiveNetId)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
876
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
846
877
  console.log(`[${ts()}] ${from_session} → reassign_task → ${task_id.slice(0, 8)} → ${new_alias}`);
847
878
  const taskParams: any[] = [task_id];
848
879
  let taskSql = "SELECT * FROM tasks WHERE task_id = ?1";
@@ -886,7 +917,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
886
917
  },
887
918
  async ({ message, filter_server, filter_status, network_id: netId }) => {
888
919
  const effectiveNetId = getNetworkId(netId);
889
- if (!canWrite(effectiveNetId)) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
920
+ if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
890
921
  console.log(`[${ts()}] hub → broadcast: ${message.slice(0, 60)}${effectiveNetId ? " [net=" + effectiveNetId.slice(0, 12) + "]" : ""}`);
891
922
  let sql = "SELECT alias, network_id FROM sessions WHERE alias IS NOT NULL";
892
923
  const params: any[] = [];