@sleep2agi/commhub-server 0.5.0-preview.26 → 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.26",
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
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * V3 Auth module — user registration, login, token management
3
3
  */
4
- import { db, generateId, hashPassword, hashToken, generateToken, uuidv4 } from "./db.js";
4
+ import { db, generateId, hashPassword, hashToken, generateToken, generateUserToken, generateNetworkToken, uuidv4 } from "./db.js";
5
5
 
6
6
  export interface AuthUser {
7
7
  user_id: string;
@@ -15,23 +15,30 @@ export interface AuthResult {
15
15
  ok: boolean;
16
16
  error?: string;
17
17
  user?: AuthUser;
18
- token?: string;
18
+ token?: string; // user token (utok_)
19
+ network_token?: string; // network token (ntok_) for default network
20
+ network_id?: string;
19
21
  }
20
22
 
21
23
  export function register(username: string, password: string, email?: string, displayName?: string): AuthResult {
22
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)" };
23
26
  if (!password || password.length < 6) return { ok: false, error: "password must be at least 6 characters" };
24
27
  if (!/^[a-zA-Z0-9_\-\u4e00-\u9fff]+$/.test(username)) return { ok: false, error: "username contains invalid characters" };
25
28
 
26
29
  const existing = db.get<any>("SELECT user_id FROM users WHERE username = ?1", username);
27
30
  if (existing) return { ok: false, error: "username already taken" };
28
31
 
32
+ // First user → auto admin
33
+ const userCount = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM users");
34
+ const isFirstUser = !userCount || userCount.cnt === 0;
35
+
29
36
  const userId = generateId("u");
30
37
  const pwHash = hashPassword(password);
31
38
 
32
39
  db.run(
33
- "INSERT INTO users (user_id, username, password_hash, email, display_name) VALUES (?1, ?2, ?3, ?4, ?5)",
34
- [userId, username, pwHash, email || null, displayName || username]
40
+ "INSERT INTO users (user_id, username, password_hash, email, display_name, role) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
41
+ [userId, username, pwHash, email || null, displayName || username, isFirstUser ? "admin" : "user"]
35
42
  );
36
43
 
37
44
  // Auto-create default network + add as owner member
@@ -45,18 +52,28 @@ export function register(username: string, password: string, email?: string, dis
45
52
  [networkId, userId]
46
53
  );
47
54
 
48
- // Auto-create API token
49
- const token = generateToken();
50
- const tokenId = generateId("tok");
55
+ // User token (utok_) — not bound to network, for CLI/Dashboard login
56
+ const userToken = generateUserToken();
57
+ const userTokenId = generateId("tok");
51
58
  db.run(
52
59
  "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
53
- [tokenId, hashToken(token), userId, networkId, "default", "full"]
60
+ [userTokenId, hashToken(userToken), userId, null, "user-login", "user"]
61
+ );
62
+
63
+ // Network token (ntok_) — bound to default network, for agent-node
64
+ const networkToken = generateNetworkToken();
65
+ const networkTokenId = generateId("tok");
66
+ db.run(
67
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
68
+ [networkTokenId, hashToken(networkToken), userId, networkId, "default-network", "network"]
54
69
  );
55
70
 
56
71
  return {
57
72
  ok: true,
58
- user: { user_id: userId, username, display_name: displayName || username, email: email || null, role: "user" },
59
- token,
73
+ user: { user_id: userId, username, display_name: displayName || username, email: email || null, role: isFirstUser ? "admin" : "user" },
74
+ token: userToken,
75
+ network_token: networkToken,
76
+ network_id: networkId,
60
77
  };
61
78
  }
62
79
 
@@ -68,36 +85,54 @@ export function login(username: string, password: string): AuthResult {
68
85
  if (!user) return { ok: false, error: "invalid username or password" };
69
86
  if (user.password_hash !== hashPassword(password)) return { ok: false, error: "invalid username or password" };
70
87
 
71
- // Find or create token
72
- let tokenRow = db.get<any>(
73
- "SELECT token_id FROM api_tokens WHERE user_id = ?1 ORDER BY created_at DESC LIMIT 1",
88
+ // Generate/rotate user token (utok_, not bound to network)
89
+ let userTokenRow = db.get<any>(
90
+ "SELECT token_id FROM api_tokens WHERE user_id = ?1 AND scope = 'user' ORDER BY created_at DESC LIMIT 1",
74
91
  user.user_id);
75
92
 
76
- let token: string;
77
- if (tokenRow) {
78
- // Generate new token (rotate)
79
- token = generateToken();
93
+ const userToken = generateUserToken();
94
+ if (userTokenRow) {
80
95
  db.run("UPDATE api_tokens SET token_hash = ?1, last_used_at = datetime('now') WHERE token_id = ?2",
81
- [hashToken(token), tokenRow.token_id]);
96
+ [hashToken(userToken), userTokenRow.token_id]);
82
97
  } else {
83
- token = generateToken();
84
98
  const tokenId = generateId("tok");
85
- const networkId = db.get<any>(
86
- "SELECT network_id FROM networks WHERE owner_id = ?1 LIMIT 1",
87
- user.user_id)?.network_id;
88
99
  db.run(
89
- "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name) VALUES (?1, ?2, ?3, ?4, ?5)",
90
- [tokenId, hashToken(token), user.user_id, networkId || null, "login"]
100
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
101
+ [tokenId, hashToken(userToken), user.user_id, null, "user-login", "user"]
91
102
  );
92
103
  }
93
104
 
105
+ // Find default network
106
+ const defaultNet = db.get<any>(
107
+ "SELECT network_id FROM network_members WHERE user_id = ?1 ORDER BY role = 'owner' DESC LIMIT 1",
108
+ user.user_id);
109
+ const networkId = defaultNet?.network_id || null;
110
+
111
+ // Backward compat: also try old atok_ tokens
112
+ const token = userToken;
113
+
94
114
  return {
95
115
  ok: true,
96
116
  user: { user_id: user.user_id, username: user.username, display_name: user.display_name, email: user.email, role: user.role },
97
117
  token,
118
+ network_id: networkId,
98
119
  };
99
120
  }
100
121
 
122
+ /** Create a network-scoped token (ntok_) for a specific node */
123
+ export function createNetworkTokenForNode(userId: string, networkId: string, nodeName: string): { ok: boolean; token?: string; error?: string } {
124
+ // Verify user is a member of this network with write access
125
+ const role = getUserNetworkRole(userId, networkId);
126
+ if (!role || role === "viewer") return { ok: false, error: "no write access to this network" };
127
+ const token = generateNetworkToken();
128
+ const tokenId = generateId("tok");
129
+ db.run(
130
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
131
+ [tokenId, hashToken(token), userId, networkId, `node:${nodeName}`, "network"]
132
+ );
133
+ return { ok: true, token };
134
+ }
135
+
101
136
  export function resolveToken(token: string): { user: AuthUser; networkId: string | null } | null {
102
137
  const tHash = hashToken(token);
103
138
  const row = db.get<any>(
@@ -123,7 +158,23 @@ export function getUserNetworks(userId: string) {
123
158
  userId);
124
159
  }
125
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
+
126
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
+
127
178
  const existing = db.get<any>(
128
179
  "SELECT network_id FROM networks WHERE owner_id = ?1 AND network_name = ?2",
129
180
  userId, name);
@@ -169,6 +220,11 @@ export function deleteNetwork(userId: string, networkId: string): { ok: boolean;
169
220
  }
170
221
 
171
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
+ }
172
228
  const token = generateToken();
173
229
  const tokenId = generateId("tok");
174
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/db.ts CHANGED
@@ -324,6 +324,14 @@ export function generateToken(): string {
324
324
  return `atok_${crypto.randomUUID().replace(/-/g, "")}`;
325
325
  }
326
326
 
327
+ export function generateUserToken(): string {
328
+ return `utok_${crypto.randomUUID().replace(/-/g, "")}`;
329
+ }
330
+
331
+ export function generateNetworkToken(): string {
332
+ return `ntok_${crypto.randomUUID().replace(/-/g, "")}`;
333
+ }
334
+
327
335
  export function logAudit(userId: string | null, username: string | null, action: string, targetType?: string, targetId?: string, detail?: string, ip?: string, networkId?: string) {
328
336
  try {
329
337
  db.run(
package/src/index.ts CHANGED
@@ -4,7 +4,7 @@ import { z } from "zod/v4";
4
4
  import { registerTools } from "./tools.js";
5
5
  import { db, logTaskEvent, logAudit } from "./db.js";
6
6
  import { createSSEStream, pushEvent, pushBroadcast, getSSEStats } from "./push.js";
7
- import { register, login, resolveToken, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, listTokens, createToken, revokeToken, getNetworkMembers, getUserNetworkRole, addNetworkMember, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, type AuthUser } from "./auth.js";
7
+ import { register, login, resolveToken, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, listTokens, createToken, revokeToken, getNetworkMembers, getUserNetworkRole, addNetworkMember, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
8
8
 
9
9
  const PORT = Number(process.env.PORT) || 9200;
10
10
  const AUTH_TOKEN = process.env.COMMHUB_AUTH_TOKEN;
@@ -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
 
@@ -304,6 +312,23 @@ Bun.serve({
304
312
  }
305
313
  }
306
314
 
315
+ // ── V3.13: Create network token for a node ──
316
+ if (url.pathname === "/api/auth/node-token" && req.method === "POST") {
317
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "");
318
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
319
+ const resolved = resolveToken(token);
320
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
321
+ try {
322
+ const body = await req.json() as any;
323
+ if (!body.network_id || !body.node_name) return withCors(req, Response.json({ ok: false, error: "network_id and node_name required" }, { status: 400 }));
324
+ const result = createNetworkTokenForNode(resolved.user.user_id, body.network_id, body.node_name);
325
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "node_token_created", "network", body.network_id, body.node_name);
326
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
327
+ } catch (e: any) {
328
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
329
+ }
330
+ }
331
+
307
332
  // ── V3: Token management ──
308
333
  if (url.pathname === "/api/auth/tokens" && req.method === "GET") {
309
334
  const token = req.headers.get("Authorization")?.replace("Bearer ", "");
@@ -346,7 +371,12 @@ Bun.serve({
346
371
  if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
347
372
  const resolved = resolveToken(token);
348
373
  if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
349
- // 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
+ }
350
380
  const networks = getUserAllNetworks(resolved.user.user_id);
351
381
  return withCors(req, Response.json({ ok: true, networks }));
352
382
  }
@@ -452,8 +482,9 @@ Bun.serve({
452
482
  const networkId = netDetailMatch[1];
453
483
  const network = db.get<any>("SELECT * FROM networks WHERE network_id = ?1", networkId);
454
484
  if (!network) return withCors(req, Response.json({ ok: false, error: "network not found" }, { status: 404 }));
455
- // Ownership check: only owner or admin can view
456
- 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") {
457
488
  return withCors(req, Response.json({ ok: false, error: "access denied" }, { status: 403 }));
458
489
  }
459
490
  // Get network stats
@@ -519,11 +550,18 @@ Bun.serve({
519
550
  const authErr = requireAuth(req);
520
551
  if (authErr) return withCors(req, authErr);
521
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
+
522
560
  // ── REST: all sessions status ──
523
561
  if (url.pathname === "/api/status") {
524
562
  const cutoff = new Date(Date.now() - 10 * 60 * 1000).toISOString().replace("T", " ").slice(0, 19);
525
563
  db.run("UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'", [cutoff]);
526
- const netFilter = url.searchParams.get("network_id");
564
+ const netFilter = restNetId;
527
565
  const sql = netFilter
528
566
  ? "SELECT * FROM sessions WHERE network_id = ?1 ORDER BY updated_at DESC"
529
567
  : "SELECT * FROM sessions ORDER BY updated_at DESC";
@@ -733,7 +771,7 @@ Bun.serve({
733
771
  const status = url.searchParams.get("status");
734
772
  const toName = url.searchParams.get("to_name");
735
773
  const fromName = url.searchParams.get("from_name");
736
- const netFilter = url.searchParams.get("network_id");
774
+ const netFilter = restNetId || url.searchParams.get("network_id"); // token-enforced takes priority
737
775
  const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 200);
738
776
 
739
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" }) }] };