@sleep2agi/commhub-server 0.5.0-preview.7 → 0.5.0-preview.9

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.5.0-preview.7",
3
+ "version": "0.5.0-preview.9",
4
4
  "description": "CommHub MCP Server — AI Agent communication hub with SSE push, MCP protocol, and REST API",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/auth.ts ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * V3 Auth module — user registration, login, token management
3
+ */
4
+ import { db, generateId, hashPassword, hashToken, generateToken, uuidv4 } from "./db.js";
5
+
6
+ export interface AuthUser {
7
+ user_id: string;
8
+ username: string;
9
+ display_name: string | null;
10
+ email: string | null;
11
+ role: string;
12
+ }
13
+
14
+ export interface AuthResult {
15
+ ok: boolean;
16
+ error?: string;
17
+ user?: AuthUser;
18
+ token?: string;
19
+ }
20
+
21
+ export function register(username: string, password: string, email?: string, displayName?: string): AuthResult {
22
+ if (!username || username.length < 2) return { ok: false, error: "username must be at least 2 characters" };
23
+ if (!password || password.length < 6) return { ok: false, error: "password must be at least 6 characters" };
24
+ if (!/^[a-zA-Z0-9_\-\u4e00-\u9fff]+$/.test(username)) return { ok: false, error: "username contains invalid characters" };
25
+
26
+ const existing = db.query<any, [string]>("SELECT user_id FROM users WHERE username = ?1").get(username);
27
+ if (existing) return { ok: false, error: "username already taken" };
28
+
29
+ const userId = generateId("u");
30
+ const pwHash = hashPassword(password);
31
+
32
+ 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]
35
+ );
36
+
37
+ // Auto-create default network
38
+ const networkId = generateId("net");
39
+ db.run(
40
+ "INSERT INTO networks (network_id, network_name, owner_id, description) VALUES (?1, ?2, ?3, ?4)",
41
+ [networkId, "default", userId, "Auto-created default network"]
42
+ );
43
+
44
+ // Auto-create API token
45
+ const token = generateToken();
46
+ const tokenId = generateId("tok");
47
+ db.run(
48
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
49
+ [tokenId, hashToken(token), userId, networkId, "default", "full"]
50
+ );
51
+
52
+ return {
53
+ ok: true,
54
+ user: { user_id: userId, username, display_name: displayName || username, email: email || null, role: "user" },
55
+ token,
56
+ };
57
+ }
58
+
59
+ export function login(username: string, password: string): AuthResult {
60
+ const user = db.query<any, [string]>(
61
+ "SELECT user_id, username, password_hash, display_name, email, role FROM users WHERE username = ?1"
62
+ ).get(username);
63
+
64
+ if (!user) return { ok: false, error: "invalid username or password" };
65
+ if (user.password_hash !== hashPassword(password)) return { ok: false, error: "invalid username or password" };
66
+
67
+ // Find or create token
68
+ let tokenRow = db.query<any, [string]>(
69
+ "SELECT token_id FROM api_tokens WHERE user_id = ?1 ORDER BY created_at DESC LIMIT 1"
70
+ ).get(user.user_id);
71
+
72
+ let token: string;
73
+ if (tokenRow) {
74
+ // Generate new token (rotate)
75
+ token = generateToken();
76
+ db.run("UPDATE api_tokens SET token_hash = ?1, last_used_at = datetime('now') WHERE token_id = ?2",
77
+ [hashToken(token), tokenRow.token_id]);
78
+ } else {
79
+ token = generateToken();
80
+ const tokenId = generateId("tok");
81
+ const networkId = db.query<any, [string]>(
82
+ "SELECT network_id FROM networks WHERE owner_id = ?1 LIMIT 1"
83
+ ).get(user.user_id)?.network_id;
84
+ db.run(
85
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name) VALUES (?1, ?2, ?3, ?4, ?5)",
86
+ [tokenId, hashToken(token), user.user_id, networkId || null, "login"]
87
+ );
88
+ }
89
+
90
+ return {
91
+ ok: true,
92
+ user: { user_id: user.user_id, username: user.username, display_name: user.display_name, email: user.email, role: user.role },
93
+ token,
94
+ };
95
+ }
96
+
97
+ export function resolveToken(token: string): { user: AuthUser; networkId: string | null } | null {
98
+ const tHash = hashToken(token);
99
+ const row = db.query<any, [string]>(
100
+ `SELECT t.user_id, t.network_id, t.scope, u.username, u.display_name, u.email, u.role
101
+ FROM api_tokens t JOIN users u ON t.user_id = u.user_id
102
+ WHERE t.token_hash = ?1 AND (t.expires_at IS NULL OR t.expires_at > datetime('now'))`
103
+ ).get(tHash);
104
+
105
+ if (!row) return null;
106
+
107
+ // Update last_used
108
+ db.run("UPDATE api_tokens SET last_used_at = datetime('now') WHERE token_hash = ?1", [tHash]);
109
+
110
+ return {
111
+ user: { user_id: row.user_id, username: row.username, display_name: row.display_name, email: row.email, role: row.role },
112
+ networkId: row.network_id,
113
+ };
114
+ }
115
+
116
+ export function getUserNetworks(userId: string) {
117
+ return db.query<any, [string]>(
118
+ "SELECT * FROM networks WHERE owner_id = ?1 ORDER BY created_at"
119
+ ).all(userId);
120
+ }
121
+
122
+ export function createNetwork(userId: string, name: string, description?: string) {
123
+ const existing = db.query<any, [string, string]>(
124
+ "SELECT network_id FROM networks WHERE owner_id = ?1 AND network_name = ?2"
125
+ ).get(userId, name);
126
+ if (existing) return { ok: false, error: "network name already exists" };
127
+
128
+ const networkId = generateId("net");
129
+ db.run(
130
+ "INSERT INTO networks (network_id, network_name, owner_id, description) VALUES (?1, ?2, ?3, ?4)",
131
+ [networkId, name, userId, description || null]
132
+ );
133
+ return { ok: true, network_id: networkId, network_name: name };
134
+ }
package/src/db.ts CHANGED
@@ -151,11 +151,85 @@ db.exec(`
151
151
  CREATE INDEX IF NOT EXISTS idx_task_events_created ON task_events(created_at);
152
152
  `);
153
153
 
154
+ // ── V3: users table ──
155
+ db.exec(`
156
+ CREATE TABLE IF NOT EXISTS users (
157
+ user_id TEXT PRIMARY KEY,
158
+ username TEXT UNIQUE NOT NULL,
159
+ password_hash TEXT NOT NULL,
160
+ email TEXT,
161
+ display_name TEXT,
162
+ role TEXT DEFAULT 'user',
163
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
164
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
165
+ );
166
+
167
+ CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
168
+ `);
169
+
170
+ // ── V3: networks table ──
171
+ db.exec(`
172
+ CREATE TABLE IF NOT EXISTS networks (
173
+ network_id TEXT PRIMARY KEY,
174
+ network_name TEXT NOT NULL,
175
+ owner_id TEXT NOT NULL,
176
+ description TEXT,
177
+ settings TEXT,
178
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
179
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
180
+ UNIQUE(owner_id, network_name)
181
+ );
182
+
183
+ CREATE INDEX IF NOT EXISTS idx_networks_owner ON networks(owner_id);
184
+ `);
185
+
186
+ // ── V3: api_tokens table ──
187
+ db.exec(`
188
+ CREATE TABLE IF NOT EXISTS api_tokens (
189
+ token_id TEXT PRIMARY KEY,
190
+ token_hash TEXT NOT NULL,
191
+ user_id TEXT NOT NULL,
192
+ network_id TEXT,
193
+ name TEXT NOT NULL DEFAULT 'default',
194
+ scope TEXT DEFAULT 'full',
195
+ expires_at TEXT,
196
+ last_used_at TEXT,
197
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
198
+ );
199
+
200
+ CREATE INDEX IF NOT EXISTS idx_tokens_hash ON api_tokens(token_hash);
201
+ CREATE INDEX IF NOT EXISTS idx_tokens_user ON api_tokens(user_id);
202
+ `);
203
+
204
+ // ── V3: add network_id to existing tables ──
205
+ for (const table of ["sessions", "nodes", "tasks", "inbox", "task_events"]) {
206
+ try { db.exec(`ALTER TABLE ${table} ADD COLUMN network_id TEXT`); } catch {}
207
+ }
208
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_sessions_network ON sessions(network_id)"); } catch {}
209
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_network ON tasks(network_id)"); } catch {}
210
+ try { db.exec("CREATE INDEX IF NOT EXISTS idx_nodes_network ON nodes(network_id)"); } catch {}
211
+
154
212
  // Helpers
155
213
  export function uuidv4(): string {
156
214
  return crypto.randomUUID();
157
215
  }
158
216
 
217
+ export function generateId(prefix: string): string {
218
+ return `${prefix}_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
219
+ }
220
+
221
+ export function hashPassword(password: string): string {
222
+ return new Bun.CryptoHasher("sha256").update(`anet:${password}`).digest("hex");
223
+ }
224
+
225
+ export function hashToken(token: string): string {
226
+ return new Bun.CryptoHasher("sha256").update(token).digest("hex");
227
+ }
228
+
229
+ export function generateToken(): string {
230
+ return `atok_${crypto.randomUUID().replace(/-/g, "")}`;
231
+ }
232
+
159
233
  export function logTaskEvent(taskId: string, fromStatus: string | null, toStatus: string, actor: string, detail?: string) {
160
234
  try {
161
235
  db.run(
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ import { z } from "zod/v4";
4
4
  import { registerTools } from "./tools.js";
5
5
  import { db, logTaskEvent } from "./db.js";
6
6
  import { createSSEStream, pushEvent, pushBroadcast, getSSEStats } from "./push.js";
7
+ import { register, login, resolveToken, getUserNetworks, createNetwork, type AuthUser } from "./auth.js";
7
8
 
8
9
  const PORT = Number(process.env.PORT) || 9200;
9
10
  const AUTH_TOKEN = process.env.COMMHUB_AUTH_TOKEN;
@@ -140,6 +141,60 @@ Bun.serve({
140
141
  return createSSEStream(sessionName);
141
142
  }
142
143
 
144
+ // ── V3: Auth endpoints (public) ──
145
+ if (url.pathname === "/api/auth/register" && req.method === "POST") {
146
+ try {
147
+ const body = await req.json() as any;
148
+ const result = register(body.username, body.password, body.email, body.display_name);
149
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
150
+ } catch (e: any) {
151
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
152
+ }
153
+ }
154
+
155
+ if (url.pathname === "/api/auth/login" && req.method === "POST") {
156
+ try {
157
+ const body = await req.json() as any;
158
+ const result = login(body.username, body.password);
159
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 401 }));
160
+ } catch (e: any) {
161
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
162
+ }
163
+ }
164
+
165
+ if (url.pathname === "/api/auth/me" && req.method === "GET") {
166
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
167
+ if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
168
+ const resolved = resolveToken(token);
169
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
170
+ const networks = getUserNetworks(resolved.user.user_id);
171
+ return withCors(req, Response.json({ ok: true, user: resolved.user, networks, current_network: resolved.networkId }));
172
+ }
173
+
174
+ // ── V3: Network management ──
175
+ if (url.pathname === "/api/networks" && req.method === "GET") {
176
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
177
+ if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
178
+ const resolved = resolveToken(token);
179
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
180
+ const networks = getUserNetworks(resolved.user.user_id);
181
+ return withCors(req, Response.json({ ok: true, networks }));
182
+ }
183
+
184
+ if (url.pathname === "/api/networks" && req.method === "POST") {
185
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
186
+ if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
187
+ const resolved = resolveToken(token);
188
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
189
+ try {
190
+ const body = await req.json() as any;
191
+ const result = createNetwork(resolved.user.user_id, body.name, body.description);
192
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
193
+ } catch (e: any) {
194
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
195
+ }
196
+ }
197
+
143
198
  // ── REST: health (public, no auth) ──
144
199
  if (url.pathname === "/health") {
145
200
  const count = db.query<{ cnt: number }, []>("SELECT COUNT(*) as cnt FROM sessions").get();
@@ -286,6 +341,24 @@ Bun.serve({
286
341
  return withCors(req, Response.json({ ok: true, messages: rows }));
287
342
  }
288
343
 
344
+ // ── REST: stats summary ──
345
+ if (url.pathname === "/api/stats") {
346
+ const taskStats = db.query<any, []>("SELECT status, COUNT(*) as count FROM tasks GROUP BY status").all();
347
+ const sessionStats = db.query<any, []>("SELECT status, COUNT(*) as count FROM sessions GROUP BY status").all();
348
+ const totalTasks = db.query<{ cnt: number }, []>("SELECT COUNT(*) as cnt FROM tasks").get();
349
+ const totalNodes = db.query<{ cnt: number }, []>("SELECT COUNT(*) as cnt FROM nodes").get();
350
+ const recentTasks = db.query<any, []>(
351
+ "SELECT task_id, from_name, to_name, status, created_at FROM tasks ORDER BY created_at DESC LIMIT 5"
352
+ ).all();
353
+ return withCors(req, Response.json({
354
+ ok: true,
355
+ tasks: { total: totalTasks?.cnt || 0, by_status: taskStats },
356
+ sessions: { by_status: sessionStats },
357
+ nodes: { total: totalNodes?.cnt || 0 },
358
+ recent_tasks: recentTasks,
359
+ }));
360
+ }
361
+
289
362
  // ── REST: task events (V2 Sprint 2) ──
290
363
  if (url.pathname === "/api/task_events") {
291
364
  const taskId = url.searchParams.get("task_id");
@@ -330,7 +403,8 @@ Bun.serve({
330
403
  params.push(limit);
331
404
 
332
405
  const rows = db.query(sql).all(...params);
333
- return withCors(req, Response.json({ ok: true, tasks: rows, count: rows.length }));
406
+ const stats = db.query<any, []>("SELECT status, COUNT(*) as count FROM tasks GROUP BY status").all();
407
+ return withCors(req, Response.json({ ok: true, tasks: rows, count: rows.length, stats }));
334
408
  }
335
409
 
336
410
  // ── REST: recent completions ──
package/src/tools.ts CHANGED
@@ -565,6 +565,40 @@ export function registerTools(server: McpServer, clientIP?: string) {
565
565
  }
566
566
  );
567
567
 
568
+ // ── V2: list_tasks (查询任务列表) ──
569
+ server.tool(
570
+ "list_tasks",
571
+ "List tasks with filters. Agents can query their own pending/running tasks.",
572
+ {
573
+ alias: z.string().max(200).optional().describe("Filter by to_name (target agent)"),
574
+ status: z.string().max(50).optional().describe("Filter by status"),
575
+ from_name: z.string().max(200).optional().describe("Filter by sender"),
576
+ limit: z.number().min(1).max(100).optional().default(20),
577
+ },
578
+ async ({ alias, status, from_name, limit }) => {
579
+ let sql = "SELECT task_id, from_name, to_name, priority, status, content, result, created_at, completed_at FROM tasks WHERE 1=1";
580
+ const params: any[] = [];
581
+ if (alias) { sql += ` AND to_name = ?${params.length + 1}`; params.push(alias); }
582
+ if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
583
+ if (from_name) { sql += ` AND from_name = ?${params.length + 1}`; params.push(from_name); }
584
+ sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
585
+ params.push(limit);
586
+ const tasks = db.query(sql).all(...params);
587
+
588
+ // Stats
589
+ const stats = db.query<any, []>(
590
+ "SELECT status, COUNT(*) as count FROM tasks GROUP BY status"
591
+ ).all();
592
+
593
+ return {
594
+ content: [{
595
+ type: "text" as const,
596
+ text: JSON.stringify({ ok: true, tasks, count: tasks.length, stats }),
597
+ }],
598
+ };
599
+ }
600
+ );
601
+
568
602
  // ── V2: cancel_task (取消任务) ──
569
603
  server.tool(
570
604
  "cancel_task",