@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 +1 -1
- package/src/auth.ts +134 -0
- package/src/db.ts +74 -0
- package/src/index.ts +75 -1
- package/src/tools.ts +34 -0
package/package.json
CHANGED
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
|
-
|
|
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",
|