@sleep2agi/commhub-server 0.5.0-preview.2 → 0.5.0-preview.21

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/src/index.ts CHANGED
@@ -2,33 +2,76 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
3
3
  import { z } from "zod/v4";
4
4
  import { registerTools } from "./tools.js";
5
- import { db } from "./db.js";
5
+ import { db, logTaskEvent, logAudit } from "./db.js";
6
6
  import { createSSEStream, pushEvent, pushBroadcast, getSSEStats } from "./push.js";
7
+ import { register, login, resolveToken, getUserNetworks, createNetwork, changePassword, listTokens, createToken, revokeToken, 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;
10
11
 
12
+ // ── Rate limiter (in-memory, per IP) ──
13
+ const rateLimits = new Map<string, { count: number; resetAt: number }>();
14
+ function checkRateLimit(ip: string, maxPerMinute = 60): boolean {
15
+ // Skip rate limiting for localhost/internal/unknown (dev/test)
16
+ if (!ip || ip === "unknown" || ip === "127.0.0.1" || ip === "::1") return true;
17
+ const now = Date.now();
18
+ const entry = rateLimits.get(ip);
19
+ if (!entry || now > entry.resetAt) {
20
+ rateLimits.set(ip, { count: 1, resetAt: now + 60000 });
21
+ return true;
22
+ }
23
+ if (entry.count >= maxPerMinute) return false;
24
+ entry.count++;
25
+ return true;
26
+ }
27
+ // Cleanup stale entries every 5 minutes
28
+ setInterval(() => {
29
+ const now = Date.now();
30
+ for (const [ip, entry] of rateLimits) {
31
+ if (now > entry.resetAt) rateLimits.delete(ip);
32
+ }
33
+ }, 300000);
34
+
11
35
  // ── Factory: 每个请求创建新的 McpServer(stateless 模式)──
12
- function createServer(clientIP?: string): McpServer {
36
+ function createServer(clientIP?: string, enforceNetworkId?: string | null): McpServer {
13
37
  const server = new McpServer({
14
38
  name: "commhub",
15
- version: "0.4.1",
39
+ version: "0.5.0",
16
40
  });
17
- registerTools(server, clientIP);
41
+ registerTools(server, clientIP, enforceNetworkId);
18
42
  return server;
19
43
  }
20
44
 
21
45
  // ── Auth helper ─────────────────────────────────────
22
46
  function requireAuth(req: Request): Response | null {
23
- if (!AUTH_TOKEN) return null; // no token = open mode (dev)
24
- const header = req.headers.get("Authorization");
25
- if (header === `Bearer ${AUTH_TOKEN}`) return null;
26
- // Also check query param for MCP clients that can't set headers
47
+ const header = req.headers.get("Authorization")?.replace("Bearer ", "");
27
48
  const url = new URL(req.url);
28
- if (url.searchParams.get("token") === AUTH_TOKEN) return null;
49
+ const token = header || url.searchParams.get("token") || "";
50
+
51
+ // V3: check api_tokens first
52
+ if (token) {
53
+ const resolved = resolveToken(token);
54
+ if (resolved) return null; // valid user token
55
+ }
56
+
57
+ // Legacy: check global COMMHUB_AUTH_TOKEN
58
+ if (!AUTH_TOKEN) return null; // no token = open mode (dev)
59
+ if (token === AUTH_TOKEN) return null;
60
+
29
61
  return Response.json({ error: "unauthorized" }, { status: 401 });
30
62
  }
31
63
 
64
+ // Extract user + network from request token (for authorization)
65
+ function resolveRequestAuth(req: Request): { userId: string; networkId: string | null; username: string } | null {
66
+ const header = req.headers.get("Authorization")?.replace("Bearer ", "");
67
+ const url = new URL(req.url);
68
+ const token = header || url.searchParams.get("token") || "";
69
+ if (!token) return null;
70
+ const resolved = resolveToken(token);
71
+ if (!resolved) return null;
72
+ return { userId: resolved.user.user_id, networkId: resolved.networkId, username: resolved.user.username };
73
+ }
74
+
32
75
  // ── REST input schema ───────────────────────────────
33
76
  const TaskSchema = z.object({
34
77
  alias: z.string().min(1).max(200),
@@ -84,6 +127,11 @@ setInterval(() => {
84
127
  );
85
128
  if (result.changes > 0) {
86
129
  console.log(`[patrol] expired ${result.changes} stale task(s)`);
130
+ // Log events for expired tasks
131
+ const expired = db.query<{ task_id: string }, []>(
132
+ "SELECT task_id FROM tasks WHERE status = 'expired' AND completed_at >= datetime('now', '-1 minute')"
133
+ ).all();
134
+ for (const t of expired) logTaskEvent(t.task_id, null, "expired", "patrol");
87
135
  }
88
136
  } catch {}
89
137
  }, 5 * 60 * 1000);
@@ -114,10 +162,13 @@ Bun.serve({
114
162
  if (authErr) return withCors(req, authErr);
115
163
  const fwd = req.headers.get("x-forwarded-for");
116
164
  const clientIP = fwd ? fwd.split(",")[0].trim() : (req.headers.get("x-real-ip") ?? "unknown");
165
+ // V3: resolve token → enforce network_id in all MCP tools
166
+ const authCtx = resolveRequestAuth(req);
167
+ const enforceNetId = authCtx?.networkId || null;
117
168
  const transport = new WebStandardStreamableHTTPServerTransport({
118
169
  sessionIdGenerator: undefined,
119
170
  });
120
- const server = createServer(clientIP);
171
+ const server = createServer(clientIP, enforceNetId);
121
172
  await server.connect(transport);
122
173
  const response = await transport.handleRequest(req);
123
174
  // Disconnect after response to prevent McpServer leak
@@ -135,6 +186,220 @@ Bun.serve({
135
186
  return createSSEStream(sessionName);
136
187
  }
137
188
 
189
+ // ── V3: License endpoints ──
190
+ if (url.pathname === "/api/license" && req.method === "GET") {
191
+ const license = db.query<any, []>("SELECT * FROM licenses ORDER BY created_at LIMIT 1").get();
192
+ if (!license) return withCors(req, Response.json({ ok: true, status: "no_license" }));
193
+ const now = new Date().toISOString().replace("T", " ").slice(0, 19);
194
+ const expired = license.expires_at && license.expires_at < now;
195
+ const daysLeft = license.expires_at
196
+ ? Math.max(0, Math.ceil((new Date(license.expires_at).getTime() - Date.now()) / 86400000))
197
+ : null;
198
+ return withCors(req, Response.json({
199
+ ok: true,
200
+ license: { type: license.type, expires_at: license.expires_at, days_left: daysLeft, expired },
201
+ limits: { max_agents: license.max_agents, max_networks: license.max_networks, max_tasks_day: license.max_tasks_day },
202
+ }));
203
+ }
204
+
205
+ if (url.pathname === "/api/license/activate" && req.method === "POST") {
206
+ try {
207
+ const body = await req.json() as any;
208
+ const key = body.key;
209
+ if (!key) return withCors(req, Response.json({ ok: false, error: "key required" }, { status: 400 }));
210
+ // For now: accept any key starting with "anet-" as valid pro license
211
+ if (!key.startsWith("anet-") || key.length < 16) {
212
+ return withCors(req, Response.json({ ok: false, error: "invalid license key" }, { status: 400 }));
213
+ }
214
+ // Upgrade existing license or create new
215
+ db.run("DELETE FROM licenses");
216
+ const licId = `lic_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
217
+ db.run(
218
+ "INSERT INTO licenses (id, license_key, type, max_agents, max_networks, max_tasks_day, activated_at, expires_at) VALUES (?1, ?2, 'pro', 50, 10, 10000, datetime('now'), datetime('now', '+365 days'))",
219
+ [licId, key]
220
+ );
221
+ return withCors(req, Response.json({ ok: true, type: "pro", expires_in_days: 365 }));
222
+ } catch (e: any) {
223
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
224
+ }
225
+ }
226
+
227
+ // ── V3: Auth endpoints (public) ──
228
+ if (url.pathname === "/api/auth/register" && req.method === "POST") {
229
+ const clientIP = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
230
+ if (!checkRateLimit(clientIP, 30)) {
231
+ return withCors(req, Response.json({ ok: false, error: "too many requests, try again later" }, { status: 429 }));
232
+ }
233
+ try {
234
+ const body = await req.json() as any;
235
+ const result = register(body.username, body.password, body.email, body.display_name);
236
+ if (result.ok) logAudit(result.user!.user_id, body.username, "register", "user", result.user!.user_id);
237
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
238
+ } catch (e: any) {
239
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
240
+ }
241
+ }
242
+
243
+ if (url.pathname === "/api/auth/login" && req.method === "POST") {
244
+ const clientIP = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
245
+ if (!checkRateLimit(clientIP, 10)) {
246
+ logAudit(null, null, "login_rate_limited", "auth", null, clientIP);
247
+ return withCors(req, Response.json({ ok: false, error: "too many attempts, try again later" }, { status: 429 }));
248
+ }
249
+ try {
250
+ const body = await req.json() as any;
251
+ const result = login(body.username, body.password);
252
+ if (result.ok) logAudit(result.user!.user_id, body.username, "login", "user", result.user!.user_id);
253
+ else logAudit(null, body.username, "login_failed", "user", null, "invalid credentials");
254
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 401 }));
255
+ } catch (e: any) {
256
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
257
+ }
258
+ }
259
+
260
+ if (url.pathname === "/api/auth/me" && req.method === "GET") {
261
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
262
+ if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
263
+ const resolved = resolveToken(token);
264
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
265
+ const networks = getUserNetworks(resolved.user.user_id);
266
+ return withCors(req, Response.json({ ok: true, user: resolved.user, networks, current_network: resolved.networkId }));
267
+ }
268
+
269
+ if (url.pathname === "/api/auth/me" && req.method === "PUT") {
270
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "");
271
+ if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
272
+ const resolved = resolveToken(token);
273
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
274
+ try {
275
+ const body = await req.json() as any;
276
+ const updates: string[] = [];
277
+ const params: any[] = [];
278
+ if (body.display_name) { updates.push(`display_name = ?${params.length + 1}`); params.push(body.display_name); }
279
+ if (body.email) { updates.push(`email = ?${params.length + 1}`); params.push(body.email); }
280
+ if (updates.length > 0) {
281
+ updates.push(`updated_at = datetime('now')`);
282
+ params.push(resolved.user.user_id);
283
+ db.run(`UPDATE users SET ${updates.join(", ")} WHERE user_id = ?${params.length}`, params);
284
+ }
285
+ // Re-fetch
286
+ const user = db.query<any, [string]>("SELECT user_id, username, display_name, email, role FROM users WHERE user_id = ?1").get(resolved.user.user_id);
287
+ return withCors(req, Response.json({ ok: true, user }));
288
+ } catch (e: any) {
289
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
290
+ }
291
+ }
292
+
293
+ if (url.pathname === "/api/auth/password" && req.method === "POST") {
294
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "");
295
+ if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
296
+ const resolved = resolveToken(token);
297
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
298
+ try {
299
+ const body = await req.json() as any;
300
+ const result = changePassword(resolved.user.user_id, body.old_password, body.new_password);
301
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "password_changed", "user", resolved.user.user_id);
302
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
303
+ } catch (e: any) {
304
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
305
+ }
306
+ }
307
+
308
+ // ── V3: Token management ──
309
+ if (url.pathname === "/api/auth/tokens" && req.method === "GET") {
310
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "");
311
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
312
+ const resolved = resolveToken(token);
313
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
314
+ const tokens = listTokens(resolved.user.user_id);
315
+ return withCors(req, Response.json({ ok: true, tokens }));
316
+ }
317
+
318
+ if (url.pathname === "/api/auth/tokens" && req.method === "POST") {
319
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "");
320
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
321
+ const resolved = resolveToken(token);
322
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
323
+ try {
324
+ const body = await req.json() as any;
325
+ const result = createToken(resolved.user.user_id, body.name || "api-token", body.network_id);
326
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "token_created", "token", result.token_id);
327
+ return withCors(req, Response.json(result));
328
+ } catch (e: any) {
329
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
330
+ }
331
+ }
332
+
333
+ const tokenDeleteMatch = url.pathname.match(/^\/api\/auth\/tokens\/([^/]+)$/);
334
+ if (tokenDeleteMatch && req.method === "DELETE") {
335
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "");
336
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
337
+ const resolved = resolveToken(token);
338
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
339
+ const result = revokeToken(resolved.user.user_id, tokenDeleteMatch[1]);
340
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "token_revoked", "token", tokenDeleteMatch[1]);
341
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 404 }));
342
+ }
343
+
344
+ // ── V3: Network management ──
345
+ if (url.pathname === "/api/networks" && req.method === "GET") {
346
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
347
+ if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
348
+ const resolved = resolveToken(token);
349
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
350
+ const networks = getUserNetworks(resolved.user.user_id);
351
+ return withCors(req, Response.json({ ok: true, networks }));
352
+ }
353
+
354
+ if (url.pathname === "/api/networks" && req.method === "POST") {
355
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
356
+ if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
357
+ const resolved = resolveToken(token);
358
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
359
+ try {
360
+ const body = await req.json() as any;
361
+ const result = createNetwork(resolved.user.user_id, body.name, body.description);
362
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
363
+ } catch (e: any) {
364
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
365
+ }
366
+ }
367
+
368
+ // ── V3: Admin APIs (require auth) ──
369
+ if (url.pathname === "/api/users" && req.method === "GET") {
370
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "");
371
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
372
+ const resolved = resolveToken(token);
373
+ if (!resolved || resolved.user.role !== "admin") {
374
+ return withCors(req, Response.json({ ok: false, error: "admin required" }, { status: 403 }));
375
+ }
376
+ const users = db.query("SELECT user_id, username, display_name, email, role, created_at FROM users ORDER BY created_at").all();
377
+ return withCors(req, Response.json({ ok: true, users }));
378
+ }
379
+
380
+ const netDetailMatch = url.pathname.match(/^\/api\/networks\/([^/]+)$/);
381
+ if (netDetailMatch && req.method === "GET") {
382
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
383
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
384
+ const resolved = resolveToken(token);
385
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
386
+ const networkId = netDetailMatch[1];
387
+ const network = db.query<any, [string]>("SELECT * FROM networks WHERE network_id = ?1").get(networkId);
388
+ if (!network) return withCors(req, Response.json({ ok: false, error: "network not found" }, { status: 404 }));
389
+ // Ownership check: only owner or admin can view
390
+ if (network.owner_id !== resolved.user.user_id && resolved.user.role !== "admin") {
391
+ return withCors(req, Response.json({ ok: false, error: "access denied" }, { status: 403 }));
392
+ }
393
+ // Get network stats
394
+ const nodeCount = db.query<{ cnt: number }, [string]>("SELECT COUNT(*) as cnt FROM nodes WHERE network_id = ?1").get(networkId);
395
+ const sessionCount = db.query<{ cnt: number }, [string]>("SELECT COUNT(*) as cnt FROM sessions WHERE network_id = ?1").get(networkId);
396
+ const taskStats = db.query<any, [string]>("SELECT status, COUNT(*) as count FROM tasks WHERE network_id = ?1 GROUP BY status").all(networkId);
397
+ return withCors(req, Response.json({
398
+ ok: true, network,
399
+ stats: { nodes: nodeCount?.cnt || 0, sessions: sessionCount?.cnt || 0, tasks: taskStats },
400
+ }));
401
+ }
402
+
138
403
  // ── REST: health (public, no auth) ──
139
404
  if (url.pathname === "/health") {
140
405
  const count = db.query<{ cnt: number }, []>("SELECT COUNT(*) as cnt FROM sessions").get();
@@ -159,7 +424,11 @@ Bun.serve({
159
424
  if (url.pathname === "/api/status") {
160
425
  const cutoff = new Date(Date.now() - 10 * 60 * 1000).toISOString().replace("T", " ").slice(0, 19);
161
426
  db.run("UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'", [cutoff]);
162
- const sessions = db.query("SELECT * FROM sessions ORDER BY updated_at DESC").all();
427
+ const netFilter = url.searchParams.get("network_id");
428
+ const sql = netFilter
429
+ ? "SELECT * FROM sessions WHERE network_id = ?1 ORDER BY updated_at DESC"
430
+ : "SELECT * FROM sessions ORDER BY updated_at DESC";
431
+ const sessions = netFilter ? db.query(sql).all(netFilter) : db.query(sql).all();
163
432
  return withCors(req, Response.json({ ok: true, sessions }));
164
433
  }
165
434
 
@@ -281,12 +550,77 @@ Bun.serve({
281
550
  return withCors(req, Response.json({ ok: true, messages: rows }));
282
551
  }
283
552
 
553
+ // ── REST: stats summary ──
554
+ if (url.pathname === "/api/stats") {
555
+ const n = url.searchParams.get("network_id");
556
+ // Parameterized queries to prevent SQL injection
557
+ const taskStats = n
558
+ ? db.query<any, [string]>("SELECT status, COUNT(*) as count FROM tasks WHERE network_id = ?1 GROUP BY status").all(n)
559
+ : db.query<any, []>("SELECT status, COUNT(*) as count FROM tasks GROUP BY status").all();
560
+ const sessionStats = n
561
+ ? db.query<any, [string]>("SELECT status, COUNT(*) as count FROM sessions WHERE network_id = ?1 GROUP BY status").all(n)
562
+ : db.query<any, []>("SELECT status, COUNT(*) as count FROM sessions GROUP BY status").all();
563
+ const totalTasks = n
564
+ ? db.query<{ cnt: number }, [string]>("SELECT COUNT(*) as cnt FROM tasks WHERE network_id = ?1").get(n)
565
+ : db.query<{ cnt: number }, []>("SELECT COUNT(*) as cnt FROM tasks").get();
566
+ const totalNodes = n
567
+ ? db.query<{ cnt: number }, [string]>("SELECT COUNT(*) as cnt FROM nodes WHERE network_id = ?1").get(n)
568
+ : db.query<{ cnt: number }, []>("SELECT COUNT(*) as cnt FROM nodes").get();
569
+ const recentTasks = n
570
+ ? db.query<any, [string]>("SELECT task_id, from_name, to_name, status, created_at FROM tasks WHERE network_id = ?1 ORDER BY created_at DESC LIMIT 5").all(n)
571
+ : db.query<any, []>("SELECT task_id, from_name, to_name, status, created_at FROM tasks ORDER BY created_at DESC LIMIT 5").all();
572
+ return withCors(req, Response.json({
573
+ ok: true,
574
+ network_id: n || null,
575
+ tasks: { total: totalTasks?.cnt || 0, by_status: taskStats },
576
+ sessions: { by_status: sessionStats },
577
+ nodes: { total: totalNodes?.cnt || 0 },
578
+ recent_tasks: recentTasks,
579
+ }));
580
+ }
581
+
582
+ // ── REST: audit log (V3) ──
583
+ if (url.pathname === "/api/audit-log") {
584
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
585
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
586
+ const resolved = resolveToken(token);
587
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
588
+ const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 200);
589
+ const action = url.searchParams.get("action");
590
+ const userId = url.searchParams.get("user_id");
591
+ let sql = "SELECT * FROM audit_log WHERE 1=1";
592
+ const params: any[] = [];
593
+ // Non-admin can only see own logs
594
+ if (resolved.user.role !== "admin") { sql += ` AND user_id = ?${params.length + 1}`; params.push(resolved.user.user_id); }
595
+ if (action) { sql += ` AND action = ?${params.length + 1}`; params.push(action); }
596
+ if (userId && resolved.user.role === "admin") { sql += ` AND user_id = ?${params.length + 1}`; params.push(userId); }
597
+ sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
598
+ params.push(limit);
599
+ const logs = db.query(sql).all(...params);
600
+ return withCors(req, Response.json({ ok: true, logs, count: logs.length }));
601
+ }
602
+
603
+ // ── REST: task events (V2 Sprint 2) ──
604
+ if (url.pathname === "/api/task_events") {
605
+ const taskId = url.searchParams.get("task_id");
606
+ const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 500);
607
+ let sql = "SELECT * FROM task_events";
608
+ const params: any[] = [];
609
+ if (taskId) { sql += " WHERE task_id = ?1"; params.push(taskId); }
610
+ sql += " ORDER BY created_at DESC LIMIT ?";
611
+ params.push(limit);
612
+ const rows = db.query(sql).all(...params);
613
+ return withCors(req, Response.json({ ok: true, events: rows, count: rows.length }));
614
+ }
615
+
284
616
  // ── REST: nodes table (V2 Sprint 2) ──
285
617
  if (url.pathname === "/api/nodes") {
286
618
  const nodeId = url.searchParams.get("node_id");
287
619
  const alias = url.searchParams.get("alias");
620
+ const netFilter = url.searchParams.get("network_id");
288
621
  let sql = "SELECT * FROM nodes WHERE 1=1";
289
622
  const params: any[] = [];
623
+ if (netFilter) { sql += ` AND network_id = ?${params.length + 1}`; params.push(netFilter); }
290
624
  if (nodeId) { sql += ` AND node_id = ?${params.length + 1}`; params.push(nodeId); }
291
625
  if (alias) { sql += ` AND alias = ?${params.length + 1}`; params.push(alias); }
292
626
  sql += " ORDER BY updated_at DESC";
@@ -300,10 +634,12 @@ Bun.serve({
300
634
  const status = url.searchParams.get("status");
301
635
  const toName = url.searchParams.get("to_name");
302
636
  const fromName = url.searchParams.get("from_name");
637
+ const netFilter = url.searchParams.get("network_id");
303
638
  const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 200);
304
639
 
305
640
  let sql = "SELECT * FROM tasks WHERE 1=1";
306
641
  const params: any[] = [];
642
+ if (netFilter) { sql += ` AND network_id = ?${params.length + 1}`; params.push(netFilter); }
307
643
  if (taskId) { sql += ` AND task_id = ?${params.length + 1}`; params.push(taskId); }
308
644
  if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
309
645
  if (toName) { sql += ` AND to_name = ?${params.length + 1}`; params.push(toName); }
@@ -312,7 +648,10 @@ Bun.serve({
312
648
  params.push(limit);
313
649
 
314
650
  const rows = db.query(sql).all(...params);
315
- return withCors(req, Response.json({ ok: true, tasks: rows, count: rows.length }));
651
+ const stats = netFilter
652
+ ? db.query<any, [string]>("SELECT status, COUNT(*) as count FROM tasks WHERE network_id = ?1 GROUP BY status").all(netFilter)
653
+ : db.query<any, []>("SELECT status, COUNT(*) as count FROM tasks GROUP BY status").all();
654
+ return withCors(req, Response.json({ ok: true, tasks: rows, count: rows.length, stats }));
316
655
  }
317
656
 
318
657
  // ── REST: recent completions ──