@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 +43 -8
- package/package.json +1 -1
- package/src/auth.ts +80 -24
- package/src/db-adapter.ts +1 -1
- package/src/db.ts +8 -0
- package/src/index.ts +48 -10
- package/src/tools.ts +22 -2
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 工具 (
|
|
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 | 任务列表
|
|
53
|
-
| `/api/nodes` | GET |
|
|
54
|
-
| `/api/
|
|
76
|
+
| `/api/tasks` | GET | 任务列表 |
|
|
77
|
+
| `/api/nodes` | GET | 节点信息 |
|
|
78
|
+
| `/api/stats` | GET | 统计汇总 |
|
|
55
79
|
| `/api/messages` | GET | 消息列表 |
|
|
56
80
|
| `/api/completions` | GET | 完成记录 |
|
|
57
|
-
| `/
|
|
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
|
-
## 数据表 (
|
|
87
|
+
## 数据表 (13 表)
|
|
60
88
|
|
|
61
|
-
|
|
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.
|
|
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
|
-
//
|
|
49
|
-
const
|
|
50
|
-
const
|
|
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
|
-
[
|
|
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
|
-
//
|
|
72
|
-
let
|
|
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
|
-
|
|
77
|
-
if (
|
|
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(
|
|
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(
|
|
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 =
|
|
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:
|
|
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
|
-
//
|
|
456
|
-
|
|
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 =
|
|
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,
|
|
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" }) }] };
|