@sleep2agi/commhub-server 0.5.0-preview.27 → 0.5.0-preview.28

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/README.md CHANGED
@@ -18,7 +18,7 @@ PORT=9200 COMMHUB_AUTH_TOKEN=your-secret bunx @sleep2agi/commhub-server
18
18
  - REST: `http://0.0.0.0:9200/api/*` (Dashboard / 监控)
19
19
  - Health: `http://0.0.0.0:9200/health`
20
20
 
21
- ## MCP 工具 (17 个)
21
+ ## MCP 工具 (18 个)
22
22
 
23
23
  ### Agent 端 (从 Agent 调用)
24
24
  | 工具 | 说明 |
@@ -43,22 +43,50 @@ PORT=9200 COMMHUB_AUTH_TOKEN=your-secret bunx @sleep2agi/commhub-server
43
43
  | `get_session_status` | 单 session 详情 |
44
44
  | `broadcast` | 群发消息 |
45
45
 
46
- ## REST API
46
+ ## REST API (27 端点)
47
47
 
48
48
  | 端点 | 方法 | 说明 |
49
49
  |------|------|------|
50
50
  | `/health` | GET | 健康检查 (无需 auth) |
51
+ | `/mcp` | POST | MCP Streamable HTTP |
52
+ | **认证** | | |
53
+ | `/api/auth/register` | POST | 注册 → utok_ + ntok_ |
54
+ | `/api/auth/login` | POST | 登录 → utok_ |
55
+ | `/api/auth/me` | GET | 当前用户信息 |
56
+ | `/api/auth/me` | PUT | 修改资料 |
57
+ | `/api/auth/password` | POST | 修改密码 |
58
+ | `/api/auth/tokens` | GET | Token 列表 |
59
+ | `/api/auth/tokens` | POST | 创建 Token |
60
+ | `/api/auth/tokens/:id` | DELETE | 撤销 Token |
61
+ | `/api/auth/node-token` | POST | 创建节点网络 Token (ntok_) |
62
+ | **网络** | | |
63
+ | `/api/networks` | GET | 我的网络列表(成员网络) |
64
+ | `/api/networks` | POST | 创建网络 |
65
+ | `/api/networks/:id` | GET | 网络详情 + 统计 |
66
+ | `/api/networks/:id` | PUT | 重命名网络 |
67
+ | `/api/networks/:id` | DELETE | 删除网络 |
68
+ | `/api/networks/:id/members` | GET | 成员列表 |
69
+ | `/api/networks/:id/members` | POST | 添加成员 |
70
+ | `/api/networks/:id/members/:uid` | PUT | 修改成员角色 |
71
+ | `/api/networks/:id/members/:uid` | DELETE | 移除成员 |
72
+ | `/api/networks/:id/invite` | POST | 生成邀请码 |
73
+ | `/api/networks/join` | POST | 用邀请码加入 |
74
+ | **数据** | | |
51
75
  | `/api/status` | GET | 所有 session |
52
- | `/api/tasks` | GET | 任务列表 (支持 status/from_name/to_name/task_id/limit 过滤) |
53
- | `/api/nodes` | GET | 节点持久化信息 |
54
- | `/api/task_events` | GET | 任务审计日志 |
76
+ | `/api/tasks` | GET | 任务列表 |
77
+ | `/api/nodes` | GET | 节点信息 |
78
+ | `/api/stats` | GET | 统计汇总 |
55
79
  | `/api/messages` | GET | 消息列表 |
56
80
  | `/api/completions` | GET | 完成记录 |
57
- | `/mcp` | POST | MCP Streamable HTTP |
81
+ | `/api/task_events` | GET | 任务审计日志 |
82
+ | `/api/audit-log` | GET | 操作审计日志 |
83
+ | `/api/users` | GET | 用户列表 (admin) |
84
+ | `/api/license` | GET | License 状态 |
85
+ | `/api/license/activate` | POST | 激活授权码 |
58
86
 
59
- ## 数据表 (11 表)
87
+ ## 数据表 (13 表)
60
88
 
61
- 自动创建,支持 SQLite 和 PostgreSQL
89
+ 自动创建,SQLite
62
90
 
63
91
  | 表 | 说明 |
64
92
  |---|------|
@@ -68,6 +96,13 @@ PORT=9200 COMMHUB_AUTH_TOKEN=your-secret bunx @sleep2agi/commhub-server
68
96
  | `nodes` | 持久化节点身份 (11 列, 独立于 session) |
69
97
  | `completions` | 完成记录 (7 列) |
70
98
  | `task_events` | 审计日志 (7 列, 每次状态变化记录) |
99
+ | `users` | 用户 (username/password_hash/role/plan) |
100
+ | `networks` | 网络 (name/owner/visibility/max_members) |
101
+ | `api_tokens` | API Token (utok_/ntok_/atok_ + scope + network) |
102
+ | `audit_log` | 操作审计 (user/action/target/ip) |
103
+ | `licenses` | License (type/expires/limits) |
104
+ | `network_members` | 网络成员 (user ↔ network + role) |
105
+ | `network_invites` | 邀请码 (code/role/max_uses/expires) |
71
106
 
72
107
  任务状态机:
73
108
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.5.0-preview.27",
3
+ "version": "0.5.0-preview.28",
4
4
  "description": "CommHub Server \u2014 AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 18 MCP tools.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/auth.ts CHANGED
@@ -22,6 +22,7 @@ export interface AuthResult {
22
22
 
23
23
  export function register(username: string, password: string, email?: string, displayName?: string): AuthResult {
24
24
  if (!username || username.length < 2) return { ok: false, error: "username must be at least 2 characters" };
25
+ if (username.length > 50) return { ok: false, error: "username too long (max 50)" };
25
26
  if (!password || password.length < 6) return { ok: false, error: "password must be at least 6 characters" };
26
27
  if (!/^[a-zA-Z0-9_\-\u4e00-\u9fff]+$/.test(username)) return { ok: false, error: "username contains invalid characters" };
27
28
 
@@ -157,7 +158,23 @@ export function getUserNetworks(userId: string) {
157
158
  userId);
158
159
  }
159
160
 
161
+ // Quota limits by plan
162
+ const QUOTAS: Record<string, { max_networks_owned: number; max_networks_joined: number }> = {
163
+ free: { max_networks_owned: 2, max_networks_joined: 3 },
164
+ pro: { max_networks_owned: 10, max_networks_joined: 20 },
165
+ admin: { max_networks_owned: Infinity, max_networks_joined: Infinity },
166
+ };
167
+
160
168
  export function createNetwork(userId: string, name: string, description?: string) {
169
+ // Quota check
170
+ const user = db.get<any>("SELECT plan, role FROM users WHERE user_id = ?1", userId);
171
+ const plan = user?.role === "admin" ? "admin" : (user?.plan || "free");
172
+ const quota = QUOTAS[plan] || QUOTAS.free;
173
+ const ownedCount = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM networks WHERE owner_id = ?1", userId);
174
+ if ((ownedCount?.cnt || 0) >= quota.max_networks_owned) {
175
+ return { ok: false, error: `quota exceeded: max ${quota.max_networks_owned} networks for ${plan} plan` };
176
+ }
177
+
161
178
  const existing = db.get<any>(
162
179
  "SELECT network_id FROM networks WHERE owner_id = ?1 AND network_name = ?2",
163
180
  userId, name);
@@ -203,6 +220,11 @@ export function deleteNetwork(userId: string, networkId: string): { ok: boolean;
203
220
  }
204
221
 
205
222
  export function createToken(userId: string, name: string, networkId?: string): { ok: boolean; token?: string; token_id?: string; error?: string } {
223
+ // Security: verify user is a member of the target network
224
+ if (networkId) {
225
+ const role = getUserNetworkRole(userId, networkId);
226
+ if (!role) return { ok: false, error: "not a member of this network" };
227
+ }
206
228
  const token = generateToken();
207
229
  const tokenId = generateId("tok");
208
230
  db.run(
package/src/db-adapter.ts CHANGED
@@ -50,7 +50,7 @@ export class SQLiteAdapter implements DbAdapter {
50
50
  constructor(private readonly rawDb: Database) {}
51
51
 
52
52
  run(sql: string, params?: any[]): QueryResult {
53
- return this.rawDb.run(sql, params as any);
53
+ return params ? this.rawDb.run(sql, params as any) : this.rawDb.run(sql);
54
54
  }
55
55
 
56
56
  get<T = any>(sql: string, ...params: any[]): T | null {
package/src/index.ts CHANGED
@@ -33,12 +33,12 @@ setInterval(() => {
33
33
  }, 300000);
34
34
 
35
35
  // ── Factory: 每个请求创建新的 McpServer(stateless 模式)──
36
- function createServer(clientIP?: string, enforceNetworkId?: string | null): McpServer {
36
+ function createServer(clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null): McpServer {
37
37
  const server = new McpServer({
38
38
  name: "commhub",
39
39
  version: "0.5.0",
40
40
  });
41
- registerTools(server, clientIP, enforceNetworkId);
41
+ registerTools(server, clientIP, enforceNetworkId, enforceUserId);
42
42
  return server;
43
43
  }
44
44
 
@@ -164,10 +164,18 @@ Bun.serve({
164
164
  // V3: resolve token → enforce network_id in all MCP tools
165
165
  const authCtx = resolveRequestAuth(req);
166
166
  const enforceNetId = authCtx?.networkId || null;
167
+ // utok_ (no network binding) cannot use MCP — only ntok_/atok_/global token
168
+ if (authCtx && !authCtx.networkId) {
169
+ return withCors(req, Response.json({
170
+ jsonrpc: "2.0",
171
+ error: { code: -32000, message: "User token (utok_) cannot access MCP. Use a network token (ntok_) instead." },
172
+ id: null,
173
+ }, { status: 403 }));
174
+ }
167
175
  const transport = new WebStandardStreamableHTTPServerTransport({
168
176
  sessionIdGenerator: undefined,
169
177
  });
170
- const server = createServer(clientIP, enforceNetId);
178
+ const server = createServer(clientIP, enforceNetId, authCtx?.userId || null);
171
179
  await server.connect(transport);
172
180
  const response = await transport.handleRequest(req);
173
181
  // Disconnect after response to prevent McpServer leak
@@ -261,7 +269,7 @@ Bun.serve({
261
269
  if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
262
270
  const resolved = resolveToken(token);
263
271
  if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
264
- const networks = getUserNetworks(resolved.user.user_id);
272
+ const networks = getUserAllNetworks(resolved.user.user_id);
265
273
  return withCors(req, Response.json({ ok: true, user: resolved.user, networks, current_network: resolved.networkId }));
266
274
  }
267
275
 
@@ -363,7 +371,12 @@ Bun.serve({
363
371
  if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
364
372
  const resolved = resolveToken(token);
365
373
  if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
366
- // V3.13: return all networks user is a member of (not just owner)
374
+ // V3.13: ntok_ can only see its bound network; utok_ sees all member networks
375
+ if (resolved.networkId) {
376
+ // ntok_ — only return the bound network
377
+ const net = db.get<any>("SELECT * FROM networks WHERE network_id = ?1", resolved.networkId);
378
+ return withCors(req, Response.json({ ok: true, networks: net ? [net] : [] }));
379
+ }
367
380
  const networks = getUserAllNetworks(resolved.user.user_id);
368
381
  return withCors(req, Response.json({ ok: true, networks }));
369
382
  }
@@ -469,8 +482,9 @@ Bun.serve({
469
482
  const networkId = netDetailMatch[1];
470
483
  const network = db.get<any>("SELECT * FROM networks WHERE network_id = ?1", networkId);
471
484
  if (!network) return withCors(req, Response.json({ ok: false, error: "network not found" }, { status: 404 }));
472
- // Ownership check: only owner or admin can view
473
- if (network.owner_id !== resolved.user.user_id && resolved.user.role !== "admin") {
485
+ // Membership check: must be a member or system admin
486
+ const viewerRole = getUserNetworkRole(resolved.user.user_id, networkId);
487
+ if (!viewerRole && resolved.user.role !== "admin") {
474
488
  return withCors(req, Response.json({ ok: false, error: "access denied" }, { status: 403 }));
475
489
  }
476
490
  // Get network stats
@@ -536,11 +550,18 @@ Bun.serve({
536
550
  const authErr = requireAuth(req);
537
551
  if (authErr) return withCors(req, authErr);
538
552
 
553
+ // Resolve network scope for REST queries — enforce isolation
554
+ // Token-bound networkId takes precedence (ntok_ → forced), then query param
555
+ const restAuth = resolveRequestAuth(req);
556
+ const isAdmin = restAuth?.username && db.get<any>("SELECT role FROM users WHERE username = ?1", restAuth.username)?.role === "admin";
557
+ // ntok_ token has networkId forced; utok_ has null (uses query param or admin sees all)
558
+ const restNetId = restAuth?.networkId || url.searchParams.get("network_id") || null;
559
+
539
560
  // ── REST: all sessions status ──
540
561
  if (url.pathname === "/api/status") {
541
562
  const cutoff = new Date(Date.now() - 10 * 60 * 1000).toISOString().replace("T", " ").slice(0, 19);
542
563
  db.run("UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'", [cutoff]);
543
- const netFilter = url.searchParams.get("network_id");
564
+ const netFilter = restNetId;
544
565
  const sql = netFilter
545
566
  ? "SELECT * FROM sessions WHERE network_id = ?1 ORDER BY updated_at DESC"
546
567
  : "SELECT * FROM sessions ORDER BY updated_at DESC";
@@ -750,7 +771,7 @@ Bun.serve({
750
771
  const status = url.searchParams.get("status");
751
772
  const toName = url.searchParams.get("to_name");
752
773
  const fromName = url.searchParams.get("from_name");
753
- const netFilter = url.searchParams.get("network_id");
774
+ const netFilter = restNetId || url.searchParams.get("network_id"); // token-enforced takes priority
754
775
  const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 200);
755
776
 
756
777
  let sql = "SELECT * FROM tasks WHERE 1=1";
package/src/tools.ts CHANGED
@@ -2,14 +2,24 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod/v4";
3
3
  import { db, uuidv4, logTaskEvent } from "./db.js";
4
4
  import { pushEvent, pushBroadcast } from "./push.js";
5
+ import { getUserNetworkRole } from "./auth.js";
5
6
 
6
7
  function ts(): string {
7
8
  return new Date().toTimeString().slice(0, 8);
8
9
  }
9
10
 
10
- export function registerTools(server: McpServer, clientIP?: string, enforceNetworkId?: string | null) {
11
+ export function registerTools(server: McpServer, clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null) {
11
12
  // If enforceNetworkId is set, override any client-supplied network_id
12
13
  const getNetworkId = (clientNetId?: string | null) => enforceNetworkId ?? clientNetId ?? null;
14
+
15
+ // Check if the user has write access to the enforced network
16
+ // utok_ (no networkId) cannot do MCP writes — only ntok_/atok_ with network binding can
17
+ const canWrite = (): boolean => {
18
+ if (!enforceUserId) return true; // legacy global token mode, allow
19
+ if (!enforceNetworkId) return false; // utok_ has no network → cannot write MCP
20
+ const role = getUserNetworkRole(enforceUserId, enforceNetworkId);
21
+ return !!role && role !== "viewer"; // owner/admin/member can write, viewer cannot
22
+ };
13
23
  // ═══════════════════════════════════════════
14
24
  // Child Agent Tools (4)
15
25
  // ═══════════════════════════════════════════
@@ -66,7 +76,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
66
76
  session_id = COALESCE(?16, sessions.session_id), config_path = COALESCE(?17, sessions.config_path),
67
77
  channels = COALESCE(?18, sessions.channels), network_id = COALESCE(?19, sessions.network_id),
68
78
  last_seen_at = datetime('now'), updated_at = datetime('now')`,
69
- [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, netId ?? null]
79
+ [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, effectiveNetId ?? null]
70
80
  );
71
81
  });
72
82
 
@@ -325,6 +335,11 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
325
335
  async ({ alias, task, priority, context, from_session, ttl_seconds, network_id: netId }) => {
326
336
  const effectiveNetId = getNetworkId(netId);
327
337
 
338
+ // Role check: viewer cannot send tasks
339
+ if (!canWrite()) {
340
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied", message: "Viewer role cannot send tasks" }) }] };
341
+ }
342
+
328
343
  // License check
329
344
  const license = db.get<any>("SELECT type, expires_at FROM licenses ORDER BY created_at LIMIT 1");
330
345
  if (license?.expires_at) {
@@ -386,6 +401,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
386
401
  from_session: z.string().max(200).optional().default("hub"),
387
402
  },
388
403
  async ({ alias, message, from_session }) => {
404
+ if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
389
405
  console.log(`[${ts()}] ${from_session} → send_message → ${alias}: ${message.slice(0, 60)}`);
390
406
  const id = uuidv4();
391
407
  db.run(
@@ -425,6 +441,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
425
441
  from_session: z.string().max(200).optional().default("hub"),
426
442
  },
427
443
  async ({ alias, text, in_reply_to, status: replyStatus, from_session }) => {
444
+ if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
428
445
  console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${alias}: ${text.slice(0, 60)}`);
429
446
  const id = uuidv4();
430
447
  const replyLogged = db.transaction(() => {
@@ -498,6 +515,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
498
515
  from_session: z.string().max(200).optional().default("hub"),
499
516
  },
500
517
  async ({ task_id, from_session }) => {
518
+ if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
501
519
  console.log(`[${ts()}] ${from_session} → retry_task → ${task_id.slice(0, 8)}`);
502
520
  // Find the original task
503
521
  const task = db.get<any>("SELECT * FROM tasks WHERE task_id = ?1", task_id);
@@ -595,6 +613,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
595
613
  from_session: z.string().max(200).optional().default("hub"),
596
614
  },
597
615
  async ({ task_id, reason, from_session }) => {
616
+ if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
598
617
  console.log(`[${ts()}] ${from_session} → cancel_task → ${task_id.slice(0, 8)}`);
599
618
  const result = db.run(
600
619
  `UPDATE tasks SET status = 'cancelled', result = ?1, completed_at = datetime('now')
@@ -622,6 +641,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
622
641
  from_session: z.string().max(200).optional().default("hub"),
623
642
  },
624
643
  async ({ task_id, new_alias, from_session }) => {
644
+ if (!canWrite()) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "permission_denied" }) }] };
625
645
  console.log(`[${ts()}] ${from_session} → reassign_task → ${task_id.slice(0, 8)} → ${new_alias}`);
626
646
  const task = db.get<any>("SELECT * FROM tasks WHERE task_id = ?1", task_id);
627
647
  if (!task) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "task not found" }) }] };