@sleep2agi/commhub-server 0.5.0-preview.8 → 0.5.0

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,39 +2,156 @@ 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, logTaskEvent } 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, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, listTokens, createToken, revokeToken, getNetworkMembers, getUserNetworkRole, addNetworkMember, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
7
8
 
8
9
  const PORT = Number(process.env.PORT) || 9200;
10
+ const HOST = process.env.HOST || "0.0.0.0";
9
11
  const AUTH_TOKEN = process.env.COMMHUB_AUTH_TOKEN;
10
12
 
13
+ // Read version from package.json so banners and /health stay in sync.
14
+ const SERVER_VERSION = (() => {
15
+ try {
16
+ const url = new URL("../package.json", import.meta.url);
17
+ return JSON.parse(require("fs").readFileSync(url, "utf8")).version || "?";
18
+ } catch { return "?"; }
19
+ })();
20
+
21
+ // ── Rate limiter (in-memory, per IP) ──
22
+ const rateLimits = new Map<string, { count: number; resetAt: number }>();
23
+ function checkRateLimit(ip: string, maxPerMinute = 60): boolean {
24
+ // Skip rate limiting for localhost/internal/unknown (dev/test)
25
+ if (!ip || ip === "unknown" || ip === "127.0.0.1" || ip === "::1") return true;
26
+ const now = Date.now();
27
+ const entry = rateLimits.get(ip);
28
+ if (!entry || now > entry.resetAt) {
29
+ rateLimits.set(ip, { count: 1, resetAt: now + 60000 });
30
+ return true;
31
+ }
32
+ if (entry.count >= maxPerMinute) return false;
33
+ entry.count++;
34
+ return true;
35
+ }
36
+ // Cleanup stale entries every 5 minutes
37
+ setInterval(() => {
38
+ const now = Date.now();
39
+ for (const [ip, entry] of rateLimits) {
40
+ if (now > entry.resetAt) rateLimits.delete(ip);
41
+ }
42
+ }, 300000);
43
+
11
44
  // ── Factory: 每个请求创建新的 McpServer(stateless 模式)──
12
- function createServer(clientIP?: string): McpServer {
45
+ function createServer(clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null): McpServer {
13
46
  const server = new McpServer({
14
47
  name: "commhub",
15
- version: "0.4.1",
48
+ version: "0.5.0",
16
49
  });
17
- registerTools(server, clientIP);
50
+ registerTools(server, clientIP, enforceNetworkId, enforceUserId, callerAlias);
18
51
  return server;
19
52
  }
20
53
 
21
54
  // ── Auth helper ─────────────────────────────────────
22
55
  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
56
+ const header = req.headers.get("Authorization")?.replace("Bearer ", "");
27
57
  const url = new URL(req.url);
28
- if (url.searchParams.get("token") === AUTH_TOKEN) return null;
58
+ const token = header || url.searchParams.get("token") || "";
59
+
60
+ // V3: check api_tokens first
61
+ if (token) {
62
+ const resolved = resolveToken(token);
63
+ if (resolved) return null; // valid user token
64
+ }
65
+
66
+ // Legacy: check global COMMHUB_AUTH_TOKEN
67
+ if (!AUTH_TOKEN) return null; // no token = open mode (dev)
68
+ if (token === AUTH_TOKEN) return null;
69
+
29
70
  return Response.json({ error: "unauthorized" }, { status: 401 });
30
71
  }
31
72
 
73
+ // Extract user + network + token-binding identity from request token.
74
+ function resolveRequestAuth(req: Request): { userId: string; networkId: string | null; username: string; tokenName: string | null } | null {
75
+ const header = req.headers.get("Authorization")?.replace("Bearer ", "");
76
+ const url = new URL(req.url);
77
+ const token = header || url.searchParams.get("token") || "";
78
+ if (!token) return null;
79
+ const resolved = resolveToken(token);
80
+ if (!resolved) return null;
81
+ return { userId: resolved.user.user_id, networkId: resolved.networkId, username: resolved.user.username, tokenName: resolved.tokenName };
82
+ }
83
+
84
+ type RestNetworkScope = {
85
+ networkId: string | null;
86
+ networkIds: string[] | null;
87
+ denied?: string;
88
+ };
89
+
90
+ function getUserNetworkIds(userId: string): string[] {
91
+ return db.all<{ network_id: string }>(
92
+ "SELECT network_id FROM network_members WHERE user_id = ?1",
93
+ userId
94
+ ).map((row) => row.network_id);
95
+ }
96
+
97
+ function resolveRestNetworkScope(url: URL, authCtx: { userId: string; networkId: string | null } | null, isAdmin: boolean): RestNetworkScope {
98
+ const requested = url.searchParams.get("network_id");
99
+
100
+ // Legacy global token or open dev mode keeps the old global behavior.
101
+ if (!authCtx) return { networkId: requested || null, networkIds: null };
102
+
103
+ // Network tokens are forcibly scoped to their bound network.
104
+ if (authCtx.networkId) return { networkId: authCtx.networkId, networkIds: null };
105
+
106
+ // System admins may intentionally inspect all networks.
107
+ if (isAdmin) return { networkId: requested || null, networkIds: null };
108
+
109
+ if (requested) {
110
+ const role = getUserNetworkRole(authCtx.userId, requested);
111
+ if (!role) return { networkId: null, networkIds: [], denied: "access denied to requested network" };
112
+ return { networkId: requested, networkIds: null };
113
+ }
114
+
115
+ return { networkId: null, networkIds: getUserNetworkIds(authCtx.userId) };
116
+ }
117
+
118
+ function addNetworkScope(sql: string, params: any[], scope: RestNetworkScope, column = "network_id"): string {
119
+ if (scope.networkId) {
120
+ sql += ` AND ${column} = ?${params.length + 1}`;
121
+ params.push(scope.networkId);
122
+ } else if (scope.networkIds) {
123
+ if (scope.networkIds.length === 0) {
124
+ sql += " AND 1=0";
125
+ } else {
126
+ const placeholders = scope.networkIds.map((_, i) => `?${params.length + i + 1}`).join(", ");
127
+ sql += ` AND ${column} IN (${placeholders})`;
128
+ params.push(...scope.networkIds);
129
+ }
130
+ }
131
+ return sql;
132
+ }
133
+
134
+ function singleNetworkId(scope: RestNetworkScope): string | null {
135
+ if (scope.networkId) return scope.networkId;
136
+ if (scope.networkIds?.length === 1) return scope.networkIds[0];
137
+ return null;
138
+ }
139
+
140
+ function canRestWriteNetwork(authCtx: { userId: string; networkId: string | null } | null, networkId: string | null, isAdmin: boolean): boolean {
141
+ if (!authCtx) return true; // legacy global token or open dev mode
142
+ if (isAdmin) return true;
143
+ if (!networkId) return false;
144
+ const role = getUserNetworkRole(authCtx.userId, networkId);
145
+ return !!role && role !== "viewer";
146
+ }
147
+
32
148
  // ── REST input schema ───────────────────────────────
33
149
  const TaskSchema = z.object({
34
150
  alias: z.string().min(1).max(200),
35
151
  task: z.string().min(1).max(10000),
36
152
  priority: z.enum(["high", "normal", "low"]).default("normal"),
37
153
  from: z.string().max(200).optional(),
154
+ network_id: z.string().max(200).optional(),
38
155
  });
39
156
 
40
157
  const BroadcastSchema = z.object({
@@ -85,9 +202,8 @@ setInterval(() => {
85
202
  if (result.changes > 0) {
86
203
  console.log(`[patrol] expired ${result.changes} stale task(s)`);
87
204
  // Log events for expired tasks
88
- const expired = db.query<{ task_id: string }, []>(
89
- "SELECT task_id FROM tasks WHERE status = 'expired' AND completed_at >= datetime('now', '-1 minute')"
90
- ).all();
205
+ const expired = db.all<{ task_id: string }>(
206
+ "SELECT task_id FROM tasks WHERE status = 'expired' AND completed_at >= datetime('now', '-1 minute')");
91
207
  for (const t of expired) logTaskEvent(t.task_id, null, "expired", "patrol");
92
208
  }
93
209
  } catch {}
@@ -95,6 +211,7 @@ setInterval(() => {
95
211
 
96
212
  Bun.serve({
97
213
  port: PORT,
214
+ hostname: HOST,
98
215
  idleTimeout: 255, // max value: keep SSE connections alive (seconds)
99
216
 
100
217
  async fetch(req, server) {
@@ -119,10 +236,21 @@ Bun.serve({
119
236
  if (authErr) return withCors(req, authErr);
120
237
  const fwd = req.headers.get("x-forwarded-for");
121
238
  const clientIP = fwd ? fwd.split(",")[0].trim() : (req.headers.get("x-real-ip") ?? "unknown");
239
+ // V3: resolve token → enforce network_id in all MCP tools.
240
+ // utok_ (user token, not network-bound) is allowed — the tool layer
241
+ // scopes to the user's accessible networks. Without this Dashboard
242
+ // (which logs in as a user) cannot call send_task.
243
+ const authCtx = resolveRequestAuth(req);
244
+ const enforceNetId = authCtx?.networkId || null;
245
+ // Derive the calling alias from the token name (e.g., 'node:视频审查')
246
+ // so peer agents see the real sender instead of 'hub' on send_task.
247
+ const callerAlias = authCtx?.tokenName?.startsWith("node:")
248
+ ? authCtx.tokenName.slice("node:".length)
249
+ : (authCtx?.username || null);
122
250
  const transport = new WebStandardStreamableHTTPServerTransport({
123
251
  sessionIdGenerator: undefined,
124
252
  });
125
- const server = createServer(clientIP);
253
+ const server = createServer(clientIP, enforceNetId, authCtx?.userId || null, callerAlias);
126
254
  await server.connect(transport);
127
255
  const response = await transport.handleRequest(req);
128
256
  // Disconnect after response to prevent McpServer leak
@@ -140,19 +268,356 @@ Bun.serve({
140
268
  return createSSEStream(sessionName);
141
269
  }
142
270
 
271
+ // ── V3: License endpoints ──
272
+ if (url.pathname === "/api/license" && req.method === "GET") {
273
+ const license = db.get<any>("SELECT * FROM licenses ORDER BY created_at LIMIT 1");
274
+ if (!license) return withCors(req, Response.json({ ok: true, status: "no_license" }));
275
+ const now = new Date().toISOString().replace("T", " ").slice(0, 19);
276
+ const expired = license.expires_at && license.expires_at < now;
277
+ const daysLeft = license.expires_at
278
+ ? Math.max(0, Math.ceil((new Date(license.expires_at).getTime() - Date.now()) / 86400000))
279
+ : null;
280
+ return withCors(req, Response.json({
281
+ ok: true,
282
+ license: { type: license.type, expires_at: license.expires_at, days_left: daysLeft, expired },
283
+ limits: { max_agents: license.max_agents, max_networks: license.max_networks, max_tasks_day: license.max_tasks_day },
284
+ }));
285
+ }
286
+
287
+ if (url.pathname === "/api/license/activate" && req.method === "POST") {
288
+ try {
289
+ const body = await req.json() as any;
290
+ const key = body.key;
291
+ if (!key) return withCors(req, Response.json({ ok: false, error: "key required" }, { status: 400 }));
292
+ // For now: accept any key starting with "anet-" as valid pro license
293
+ if (!key.startsWith("anet-") || key.length < 16) {
294
+ return withCors(req, Response.json({ ok: false, error: "invalid license key" }, { status: 400 }));
295
+ }
296
+ // Upgrade existing license or create new
297
+ db.run("DELETE FROM licenses");
298
+ const licId = `lic_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
299
+ db.run(
300
+ "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'))",
301
+ [licId, key]
302
+ );
303
+ return withCors(req, Response.json({ ok: true, type: "pro", expires_in_days: 365 }));
304
+ } catch (e: any) {
305
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
306
+ }
307
+ }
308
+
309
+ // ── V3: Auth endpoints (public) ──
310
+ if (url.pathname === "/api/auth/register" && req.method === "POST") {
311
+ const clientIP = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
312
+ if (!checkRateLimit(clientIP, 30)) {
313
+ return withCors(req, Response.json({ ok: false, error: "too many requests, try again later" }, { status: 429 }));
314
+ }
315
+ try {
316
+ const body = await req.json() as any;
317
+ const result = register(body.username, body.password, body.email, body.display_name);
318
+ if (result.ok) logAudit(result.user!.user_id, body.username, "register", "user", result.user!.user_id);
319
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
320
+ } catch (e: any) {
321
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
322
+ }
323
+ }
324
+
325
+ if (url.pathname === "/api/auth/login" && req.method === "POST") {
326
+ const clientIP = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
327
+ if (!checkRateLimit(clientIP, 10)) {
328
+ logAudit(null, null, "login_rate_limited", "auth", null, clientIP);
329
+ return withCors(req, Response.json({ ok: false, error: "too many attempts, try again later" }, { status: 429 }));
330
+ }
331
+ try {
332
+ const body = await req.json() as any;
333
+ const result = login(body.username, body.password);
334
+ if (result.ok) logAudit(result.user!.user_id, body.username, "login", "user", result.user!.user_id);
335
+ else logAudit(null, body.username, "login_failed", "user", null, "invalid credentials");
336
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 401 }));
337
+ } catch (e: any) {
338
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
339
+ }
340
+ }
341
+
342
+ if (url.pathname === "/api/auth/me" && req.method === "GET") {
343
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
344
+ if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
345
+ const resolved = resolveToken(token);
346
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
347
+ const networks = getUserAllNetworks(resolved.user.user_id);
348
+ return withCors(req, Response.json({ ok: true, user: resolved.user, networks, current_network: resolved.networkId }));
349
+ }
350
+
351
+ if (url.pathname === "/api/auth/me" && req.method === "PUT") {
352
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "");
353
+ if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
354
+ const resolved = resolveToken(token);
355
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
356
+ try {
357
+ const body = await req.json() as any;
358
+ const updates: string[] = [];
359
+ const params: any[] = [];
360
+ if (body.display_name) { updates.push(`display_name = ?${params.length + 1}`); params.push(body.display_name); }
361
+ if (body.email) { updates.push(`email = ?${params.length + 1}`); params.push(body.email); }
362
+ if (updates.length > 0) {
363
+ updates.push(`updated_at = datetime('now')`);
364
+ params.push(resolved.user.user_id);
365
+ db.run(`UPDATE users SET ${updates.join(", ")} WHERE user_id = ?${params.length}`, params);
366
+ }
367
+ // Re-fetch
368
+ const user = db.get<any>("SELECT user_id, username, display_name, email, role FROM users WHERE user_id = ?1", resolved.user.user_id);
369
+ return withCors(req, Response.json({ ok: true, user }));
370
+ } catch (e: any) {
371
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
372
+ }
373
+ }
374
+
375
+ if (url.pathname === "/api/auth/password" && req.method === "POST") {
376
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "");
377
+ if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
378
+ const resolved = resolveToken(token);
379
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
380
+ try {
381
+ const body = await req.json() as any;
382
+ const result = changePassword(resolved.user.user_id, body.old_password, body.new_password);
383
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "password_changed", "user", resolved.user.user_id);
384
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
385
+ } catch (e: any) {
386
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
387
+ }
388
+ }
389
+
390
+ // ── V3.13: Create network token for a node ──
391
+ if (url.pathname === "/api/auth/node-token" && req.method === "POST") {
392
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "");
393
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
394
+ const resolved = resolveToken(token);
395
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
396
+ try {
397
+ const body = await req.json() as any;
398
+ if (!body.network_id || !body.node_name) return withCors(req, Response.json({ ok: false, error: "network_id and node_name required" }, { status: 400 }));
399
+ const result = createNetworkTokenForNode(resolved.user.user_id, body.network_id, body.node_name);
400
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "node_token_created", "network", body.network_id, body.node_name);
401
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
402
+ } catch (e: any) {
403
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
404
+ }
405
+ }
406
+
407
+ // ── V3: Token management ──
408
+ if (url.pathname === "/api/auth/tokens" && req.method === "GET") {
409
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "");
410
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
411
+ const resolved = resolveToken(token);
412
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
413
+ const tokens = listTokens(resolved.user.user_id);
414
+ return withCors(req, Response.json({ ok: true, tokens }));
415
+ }
416
+
417
+ if (url.pathname === "/api/auth/tokens" && req.method === "POST") {
418
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "");
419
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
420
+ const resolved = resolveToken(token);
421
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
422
+ try {
423
+ const body = await req.json() as any;
424
+ const result = createToken(resolved.user.user_id, body.name || "api-token", body.network_id);
425
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "token_created", "token", result.token_id);
426
+ return withCors(req, Response.json(result));
427
+ } catch (e: any) {
428
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
429
+ }
430
+ }
431
+
432
+ const tokenDeleteMatch = url.pathname.match(/^\/api\/auth\/tokens\/([^/]+)$/);
433
+ if (tokenDeleteMatch && req.method === "DELETE") {
434
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "");
435
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
436
+ const resolved = resolveToken(token);
437
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
438
+ const result = revokeToken(resolved.user.user_id, tokenDeleteMatch[1]);
439
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "token_revoked", "token", tokenDeleteMatch[1]);
440
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 404 }));
441
+ }
442
+
443
+ // ── V3: Network management ──
444
+ if (url.pathname === "/api/networks" && req.method === "GET") {
445
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
446
+ if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
447
+ const resolved = resolveToken(token);
448
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
449
+ // V3.13: ntok_ can only see its bound network; utok_ sees all member networks
450
+ if (resolved.networkId) {
451
+ // ntok_ — only return the bound network
452
+ const net = db.get<any>("SELECT * FROM networks WHERE network_id = ?1", resolved.networkId);
453
+ return withCors(req, Response.json({ ok: true, networks: net ? [net] : [] }));
454
+ }
455
+ const networks = getUserAllNetworks(resolved.user.user_id);
456
+ return withCors(req, Response.json({ ok: true, networks }));
457
+ }
458
+
459
+ if (url.pathname === "/api/networks" && req.method === "POST") {
460
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
461
+ if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
462
+ const resolved = resolveToken(token);
463
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
464
+ try {
465
+ const body = await req.json() as any;
466
+ const result = createNetwork(resolved.user.user_id, body.name, body.description);
467
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
468
+ } catch (e: any) {
469
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
470
+ }
471
+ }
472
+
473
+ // ── V3.13: Network members + invites ──
474
+ const membersMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/members(?:\/([^/]+))?$/);
475
+ if (membersMatch) {
476
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
477
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
478
+ const resolved = resolveToken(token);
479
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
480
+ const netId = membersMatch[1];
481
+ const targetUid = membersMatch[2];
482
+ const callerRole = getUserNetworkRole(resolved.user.user_id, netId);
483
+ if (!callerRole) return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
484
+
485
+ if (req.method === "GET") {
486
+ if (!["owner", "admin"].includes(callerRole)) return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
487
+ const members = getNetworkMembers(netId);
488
+ return withCors(req, Response.json({ ok: true, members }));
489
+ }
490
+ if (req.method === "POST") {
491
+ if (!["owner", "admin"].includes(callerRole)) return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
492
+ const body = await req.json() as any;
493
+ const result = addNetworkMember(netId, body.user_id, body.role || "member", resolved.user.user_id);
494
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "member_added", "network", netId, `${body.user_id} as ${body.role || "member"}`);
495
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
496
+ }
497
+ if (req.method === "PUT" && targetUid) {
498
+ if (callerRole !== "owner") return withCors(req, Response.json({ ok: false, error: "owner required" }, { status: 403 }));
499
+ const body = await req.json() as any;
500
+ const result = updateMemberRole(netId, targetUid, body.role);
501
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "member_role_changed", "network", netId, `${targetUid} → ${body.role}`);
502
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
503
+ }
504
+ if (req.method === "DELETE" && targetUid) {
505
+ if (!["owner", "admin"].includes(callerRole)) return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
506
+ const result = removeNetworkMember(netId, targetUid);
507
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "member_removed", "network", netId, targetUid);
508
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
509
+ }
510
+ }
511
+
512
+ if (url.pathname.match(/^\/api\/networks\/([^/]+)\/invite$/) && req.method === "POST") {
513
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
514
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
515
+ const resolved = resolveToken(token);
516
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
517
+ const netId = url.pathname.split("/")[3];
518
+ const callerRole = getUserNetworkRole(resolved.user.user_id, netId);
519
+ if (!callerRole || !["owner", "admin"].includes(callerRole)) {
520
+ return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
521
+ }
522
+ const body = await req.json() as any;
523
+ const result = createInvite(netId, resolved.user.user_id, body.role || "member", body.max_uses || 1, body.expires_days);
524
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "invite_created", "network", netId, result.invite_code);
525
+ return withCors(req, Response.json(result));
526
+ }
527
+
528
+ if (url.pathname === "/api/networks/join" && req.method === "POST") {
529
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
530
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
531
+ const resolved = resolveToken(token);
532
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
533
+ const body = await req.json() as any;
534
+ const result = joinByInvite(body.invite_code, resolved.user.user_id);
535
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "network_joined", "network", result.network_id, `via invite, role=${result.role}`);
536
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
537
+ }
538
+
539
+ // ── V3: Admin APIs (require auth) ──
540
+ if (url.pathname === "/api/users" && req.method === "GET") {
541
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "");
542
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
543
+ const resolved = resolveToken(token);
544
+ if (!resolved || resolved.user.role !== "admin") {
545
+ return withCors(req, Response.json({ ok: false, error: "admin required" }, { status: 403 }));
546
+ }
547
+ const users = db.all("SELECT user_id, username, display_name, email, role, created_at FROM users ORDER BY created_at");
548
+ return withCors(req, Response.json({ ok: true, users }));
549
+ }
550
+
551
+ const netDetailMatch = url.pathname.match(/^\/api\/networks\/([^/]+)$/);
552
+ if (netDetailMatch && req.method === "GET") {
553
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
554
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
555
+ const resolved = resolveToken(token);
556
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
557
+ const networkId = netDetailMatch[1];
558
+ const network = db.get<any>("SELECT * FROM networks WHERE network_id = ?1", networkId);
559
+ if (!network) return withCors(req, Response.json({ ok: false, error: "network not found" }, { status: 404 }));
560
+ // Membership check: must be a member or system admin
561
+ const viewerRole = getUserNetworkRole(resolved.user.user_id, networkId);
562
+ if (!viewerRole && resolved.user.role !== "admin") {
563
+ return withCors(req, Response.json({ ok: false, error: "access denied" }, { status: 403 }));
564
+ }
565
+ // Get network stats
566
+ const nodeCount = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM nodes WHERE network_id = ?1", networkId);
567
+ const sessionCount = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM sessions WHERE network_id = ?1", networkId);
568
+ const taskStats = db.all<any>("SELECT status, COUNT(*) as count FROM tasks WHERE network_id = ?1 GROUP BY status", networkId);
569
+ return withCors(req, Response.json({
570
+ ok: true, network,
571
+ stats: { nodes: nodeCount?.cnt || 0, sessions: sessionCount?.cnt || 0, tasks: taskStats },
572
+ }));
573
+ }
574
+
575
+ if (netDetailMatch && req.method === "DELETE") {
576
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
577
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
578
+ const resolved = resolveToken(token);
579
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
580
+ const result = deleteNetwork(resolved.user.user_id, netDetailMatch[1]);
581
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "network_deleted", "network", netDetailMatch[1]);
582
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
583
+ }
584
+
585
+ if (netDetailMatch && req.method === "PUT") {
586
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
587
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
588
+ const resolved = resolveToken(token);
589
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
590
+ try {
591
+ const body = await req.json() as any;
592
+ if (body.name) {
593
+ const result = renameNetwork(resolved.user.user_id, netDetailMatch[1], body.name);
594
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "network_renamed", "network", netDetailMatch[1], body.name);
595
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
596
+ }
597
+ return withCors(req, Response.json({ ok: false, error: "name required" }, { status: 400 }));
598
+ } catch (e: any) {
599
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
600
+ }
601
+ }
602
+
143
603
  // ── REST: health (public, no auth) ──
144
604
  if (url.pathname === "/health") {
145
- const count = db.query<{ cnt: number }, []>("SELECT COUNT(*) as cnt FROM sessions").get();
605
+ const count = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM sessions");
146
606
  const sse = getSSEStats();
607
+ const license = db.get<any>("SELECT type, expires_at FROM licenses LIMIT 1");
147
608
  return withCors(req, Response.json({
148
609
  ok: true,
149
- version: "0.4.1",
610
+ version: SERVER_VERSION,
611
+ api_version: "v3",
150
612
  transport: "streamable-http",
151
- sessions: count?.cnt ?? 0,
613
+ sessions_count: count?.cnt ?? 0,
152
614
  sse_connections: sse.total,
153
615
  sse_sessions: sse.sessions,
154
616
  auth: AUTH_TOKEN ? "enabled" : "disabled",
155
- uptime: process.uptime(),
617
+ v3_auth: true,
618
+ multi_network: true,
619
+ license: license?.type || "none",
620
+ uptime: Math.floor(process.uptime()),
156
621
  }));
157
622
  }
158
623
 
@@ -160,11 +625,27 @@ Bun.serve({
160
625
  const authErr = requireAuth(req);
161
626
  if (authErr) return withCors(req, authErr);
162
627
 
628
+ // Resolve network scope for REST queries — enforce isolation
629
+ // Token-bound networkId takes precedence (ntok_ → forced), then query param
630
+ const restAuth = resolveRequestAuth(req);
631
+ const isAdmin = !!(restAuth?.username && db.get<any>("SELECT role FROM users WHERE username = ?1", restAuth.username)?.role === "admin");
632
+ const restScope = resolveRestNetworkScope(url, restAuth, isAdmin);
633
+ if (restScope.denied) {
634
+ return withCors(req, Response.json({ ok: false, error: restScope.denied }, { status: 403 }));
635
+ }
636
+
163
637
  // ── REST: all sessions status ──
164
638
  if (url.pathname === "/api/status") {
165
639
  const cutoff = new Date(Date.now() - 10 * 60 * 1000).toISOString().replace("T", " ").slice(0, 19);
166
- db.run("UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'", [cutoff]);
167
- const sessions = db.query("SELECT * FROM sessions ORDER BY updated_at DESC").all();
640
+ const staleParams: any[] = [cutoff];
641
+ let staleSql = "UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'";
642
+ staleSql = addNetworkScope(staleSql, staleParams, restScope);
643
+ db.run(staleSql, staleParams);
644
+ const params: any[] = [];
645
+ let sql = "SELECT * FROM sessions WHERE 1=1";
646
+ sql = addNetworkScope(sql, params, restScope);
647
+ sql += " ORDER BY updated_at DESC";
648
+ const sessions = db.all(sql, ...params);
168
649
  return withCors(req, Response.json({ ok: true, sessions }));
169
650
  }
170
651
 
@@ -181,18 +662,40 @@ Bun.serve({
181
662
  return withCors(req, Response.json({ error: "invalid input", details: parsed.error.format() }, { status: 400 }));
182
663
  }
183
664
  const body = parsed.data;
665
+ let taskNetId: string | null = null;
666
+ if (restAuth?.networkId) {
667
+ taskNetId = restAuth.networkId;
668
+ } else if (body.network_id) {
669
+ if (restAuth && !isAdmin && !getUserNetworkRole(restAuth.userId, body.network_id)) {
670
+ return withCors(req, Response.json({ ok: false, error: "access denied to requested network" }, { status: 403 }));
671
+ }
672
+ taskNetId = body.network_id;
673
+ } else {
674
+ taskNetId = restAuth ? singleNetworkId(restScope) : null;
675
+ }
676
+ if (restAuth && !taskNetId) {
677
+ return withCors(req, Response.json({ ok: false, error: "network_id required for user token when multiple networks are available" }, { status: 400 }));
678
+ }
679
+ if (!canRestWriteNetwork(restAuth, taskNetId, isAdmin)) {
680
+ return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
681
+ }
184
682
  const id = crypto.randomUUID();
185
683
  const fromSession = body.from || "api";
186
684
  db.run(
187
- `INSERT INTO inbox (id, session_name, type, priority, content, from_session)
188
- VALUES (?1, ?2, 'task', ?3, ?4, ?5)`,
189
- [id, body.alias, body.priority, body.task, fromSession]
685
+ `INSERT INTO inbox (id, session_name, type, priority, content, from_session, network_id)
686
+ VALUES (?1, ?2, 'task', ?3, ?4, ?5, ?6)`,
687
+ [id, body.alias, body.priority, body.task, fromSession, taskNetId]
190
688
  );
191
689
  // SSE push: 秒达
192
- const pending = db.query<{ cnt: number }, [string]>(
193
- "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0"
194
- ).get(body.alias);
195
- pushEvent(body.alias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority: body.priority, from: fromSession });
690
+ const pendingParams: any[] = [body.alias];
691
+ let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
692
+ if (taskNetId) { pendingSql += " AND network_id = ?2"; pendingParams.push(taskNetId); }
693
+ const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
694
+ const sessionParams: any[] = [body.alias];
695
+ let sessionSql = "SELECT 1 FROM sessions WHERE alias = ?1";
696
+ if (taskNetId) { sessionSql += " AND network_id = ?2"; sessionParams.push(taskNetId); }
697
+ const targetSession = db.get<any>(sessionSql, ...sessionParams);
698
+ if (targetSession) pushEvent(body.alias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority: body.priority, from: fromSession });
196
699
  return withCors(req, Response.json({ ok: true, message_id: id }));
197
700
  }
198
701
 
@@ -209,18 +712,25 @@ Bun.serve({
209
712
  return withCors(req, Response.json({ error: "invalid input", details: parsed.error.format() }, { status: 400 }));
210
713
  }
211
714
  const body = parsed.data;
212
- let sql = "SELECT alias FROM sessions WHERE alias IS NOT NULL";
715
+ if (restAuth && !restScope.networkId && !isAdmin) {
716
+ return withCors(req, Response.json({ ok: false, error: "network_id required for user token when broadcasting" }, { status: 400 }));
717
+ }
718
+ if (!canRestWriteNetwork(restAuth, restScope.networkId, isAdmin)) {
719
+ return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
720
+ }
721
+ let sql = "SELECT alias, network_id FROM sessions WHERE alias IS NOT NULL";
213
722
  const params: any[] = [];
723
+ sql = addNetworkScope(sql, params, restScope);
214
724
  if (body.filter_server) { sql += " AND server = ?"; params.push(body.filter_server); }
215
725
  if (body.filter_status) { sql += " AND status = ?"; params.push(body.filter_status); }
216
- const targets = db.query<{ alias: string }, any[]>(sql).all(...params);
726
+ const targets = db.all<{ alias: string; network_id: string | null }>(sql, ...params);
217
727
  const ids: string[] = [];
218
728
  for (const t of targets) {
219
729
  const id = crypto.randomUUID();
220
730
  db.run(
221
- `INSERT INTO inbox (id, session_name, type, priority, content, from_session)
222
- VALUES (?1, ?2, 'broadcast', 'normal', ?3, 'api')`,
223
- [id, t.alias, body.message]
731
+ `INSERT INTO inbox (id, session_name, type, priority, content, from_session, network_id)
732
+ VALUES (?1, ?2, 'broadcast', 'normal', ?3, 'api', ?4)`,
733
+ [id, t.alias, body.message, t.network_id]
224
734
  );
225
735
  ids.push(id);
226
736
  }
@@ -278,25 +788,49 @@ Bun.serve({
278
788
 
279
789
  // ── REST: recent messages (for Dashboard communication graph) ──
280
790
  if (url.pathname === "/api/messages") {
281
- const limit = Number(url.searchParams.get("limit")) || 100;
791
+ const limit = Math.min(Number(url.searchParams.get("limit")) || 100, 500);
282
792
  const since = url.searchParams.get("since") ?? new Date(Date.now() - 3600000).toISOString().replace("T", " ").slice(0, 19);
283
- const rows = db.query(
284
- "SELECT id, session_name as to_alias, from_session as from_alias, type, priority, content, created_at FROM inbox WHERE created_at >= ?1 ORDER BY created_at DESC LIMIT ?2"
285
- ).all(since, limit);
793
+ const params: any[] = [since];
794
+ let sql = "SELECT id, session_name as to_alias, from_session as from_alias, type, priority, content, created_at, network_id FROM inbox WHERE created_at >= ?1";
795
+ sql = addNetworkScope(sql, params, restScope);
796
+ sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
797
+ params.push(limit);
798
+ const rows = db.all(sql, ...params);
286
799
  return withCors(req, Response.json({ ok: true, messages: rows }));
287
800
  }
288
801
 
289
802
  // ── REST: stats summary ──
290
803
  if (url.pathname === "/api/stats") {
291
- const taskStats = db.query<any, []>("SELECT status, COUNT(*) as count FROM tasks GROUP BY status").all();
292
- const sessionStats = db.query<any, []>("SELECT status, COUNT(*) as count FROM sessions GROUP BY status").all();
293
- const totalTasks = db.query<{ cnt: number }, []>("SELECT COUNT(*) as cnt FROM tasks").get();
294
- const totalNodes = db.query<{ cnt: number }, []>("SELECT COUNT(*) as cnt FROM nodes").get();
295
- const recentTasks = db.query<any, []>(
296
- "SELECT task_id, from_name, to_name, status, created_at FROM tasks ORDER BY created_at DESC LIMIT 5"
297
- ).all();
804
+ const taskStatsParams: any[] = [];
805
+ let taskStatsSql = "SELECT status, COUNT(*) as count FROM tasks WHERE 1=1";
806
+ taskStatsSql = addNetworkScope(taskStatsSql, taskStatsParams, restScope);
807
+ taskStatsSql += " GROUP BY status";
808
+ const taskStats = db.all<any>(taskStatsSql, ...taskStatsParams);
809
+
810
+ const sessionStatsParams: any[] = [];
811
+ let sessionStatsSql = "SELECT status, COUNT(*) as count FROM sessions WHERE 1=1";
812
+ sessionStatsSql = addNetworkScope(sessionStatsSql, sessionStatsParams, restScope);
813
+ sessionStatsSql += " GROUP BY status";
814
+ const sessionStats = db.all<any>(sessionStatsSql, ...sessionStatsParams);
815
+
816
+ const totalTasksParams: any[] = [];
817
+ let totalTasksSql = "SELECT COUNT(*) as cnt FROM tasks WHERE 1=1";
818
+ totalTasksSql = addNetworkScope(totalTasksSql, totalTasksParams, restScope);
819
+ const totalTasks = db.get<{ cnt: number }>(totalTasksSql, ...totalTasksParams);
820
+
821
+ const totalNodesParams: any[] = [];
822
+ let totalNodesSql = "SELECT COUNT(*) as cnt FROM nodes WHERE 1=1";
823
+ totalNodesSql = addNetworkScope(totalNodesSql, totalNodesParams, restScope);
824
+ const totalNodes = db.get<{ cnt: number }>(totalNodesSql, ...totalNodesParams);
825
+
826
+ const recentTasksParams: any[] = [];
827
+ let recentTasksSql = "SELECT task_id, from_name, to_name, status, created_at FROM tasks WHERE 1=1";
828
+ recentTasksSql = addNetworkScope(recentTasksSql, recentTasksParams, restScope);
829
+ recentTasksSql += " ORDER BY created_at DESC LIMIT 5";
830
+ const recentTasks = db.all<any>(recentTasksSql, ...recentTasksParams);
298
831
  return withCors(req, Response.json({
299
832
  ok: true,
833
+ network_id: restScope.networkId || null,
300
834
  tasks: { total: totalTasks?.cnt || 0, by_status: taskStats },
301
835
  sessions: { by_status: sessionStats },
302
836
  nodes: { total: totalNodes?.cnt || 0 },
@@ -304,16 +838,38 @@ Bun.serve({
304
838
  }));
305
839
  }
306
840
 
841
+ // ── REST: audit log (V3) ──
842
+ if (url.pathname === "/api/audit-log") {
843
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
844
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
845
+ const resolved = resolveToken(token);
846
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
847
+ const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 200);
848
+ const action = url.searchParams.get("action");
849
+ const userId = url.searchParams.get("user_id");
850
+ let sql = "SELECT * FROM audit_log WHERE 1=1";
851
+ const params: any[] = [];
852
+ // Non-admin can only see own logs
853
+ if (resolved.user.role !== "admin") { sql += ` AND user_id = ?${params.length + 1}`; params.push(resolved.user.user_id); }
854
+ if (action) { sql += ` AND action = ?${params.length + 1}`; params.push(action); }
855
+ if (userId && resolved.user.role === "admin") { sql += ` AND user_id = ?${params.length + 1}`; params.push(userId); }
856
+ sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
857
+ params.push(limit);
858
+ const logs = db.all(sql, ...params);
859
+ return withCors(req, Response.json({ ok: true, logs, count: logs.length }));
860
+ }
861
+
307
862
  // ── REST: task events (V2 Sprint 2) ──
308
863
  if (url.pathname === "/api/task_events") {
309
864
  const taskId = url.searchParams.get("task_id");
310
865
  const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 500);
311
- let sql = "SELECT * FROM task_events";
866
+ let sql = "SELECT * FROM task_events WHERE 1=1";
312
867
  const params: any[] = [];
313
- if (taskId) { sql += " WHERE task_id = ?1"; params.push(taskId); }
314
- sql += " ORDER BY created_at DESC LIMIT ?";
868
+ sql = addNetworkScope(sql, params, restScope);
869
+ if (taskId) { sql += ` AND task_id = ?${params.length + 1}`; params.push(taskId); }
870
+ sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
315
871
  params.push(limit);
316
- const rows = db.query(sql).all(...params);
872
+ const rows = db.all(sql, ...params);
317
873
  return withCors(req, Response.json({ ok: true, events: rows, count: rows.length }));
318
874
  }
319
875
 
@@ -323,10 +879,11 @@ Bun.serve({
323
879
  const alias = url.searchParams.get("alias");
324
880
  let sql = "SELECT * FROM nodes WHERE 1=1";
325
881
  const params: any[] = [];
882
+ sql = addNetworkScope(sql, params, restScope);
326
883
  if (nodeId) { sql += ` AND node_id = ?${params.length + 1}`; params.push(nodeId); }
327
884
  if (alias) { sql += ` AND alias = ?${params.length + 1}`; params.push(alias); }
328
885
  sql += " ORDER BY updated_at DESC";
329
- const rows = db.query(sql).all(...params);
886
+ const rows = db.all(sql, ...params);
330
887
  return withCors(req, Response.json({ ok: true, nodes: rows, count: rows.length }));
331
888
  }
332
889
 
@@ -340,6 +897,7 @@ Bun.serve({
340
897
 
341
898
  let sql = "SELECT * FROM tasks WHERE 1=1";
342
899
  const params: any[] = [];
900
+ sql = addNetworkScope(sql, params, restScope);
343
901
  if (taskId) { sql += ` AND task_id = ?${params.length + 1}`; params.push(taskId); }
344
902
  if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
345
903
  if (toName) { sql += ` AND to_name = ?${params.length + 1}`; params.push(toName); }
@@ -347,20 +905,28 @@ Bun.serve({
347
905
  sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
348
906
  params.push(limit);
349
907
 
350
- const rows = db.query(sql).all(...params);
351
- const stats = db.query<any, []>("SELECT status, COUNT(*) as count FROM tasks GROUP BY status").all();
908
+ const rows = db.all(sql, ...params);
909
+ const statsParams: any[] = [];
910
+ let statsSql = "SELECT status, COUNT(*) as count FROM tasks WHERE 1=1";
911
+ statsSql = addNetworkScope(statsSql, statsParams, restScope);
912
+ statsSql += " GROUP BY status";
913
+ const stats = db.all<any>(statsSql, ...statsParams);
352
914
  return withCors(req, Response.json({ ok: true, tasks: rows, count: rows.length, stats }));
353
915
  }
354
916
 
355
917
  // ── REST: recent completions ──
356
918
  if (url.pathname === "/api/completions") {
357
919
  const since = url.searchParams.get("since") ?? new Date(Date.now() - 86400000).toISOString();
358
- const rows = db.query("SELECT * FROM completions WHERE completed_at >= ?1 ORDER BY completed_at DESC LIMIT 100").all(since);
920
+ const params: any[] = [since];
921
+ let sql = "SELECT * FROM completions WHERE completed_at >= ?1";
922
+ sql = addNetworkScope(sql, params, restScope);
923
+ sql += " ORDER BY completed_at DESC LIMIT 100";
924
+ const rows = db.all(sql, ...params);
359
925
  return withCors(req, Response.json({ ok: true, completions: rows }));
360
926
  }
361
927
 
362
928
  return withCors(req, new Response(
363
- `CommHub MCP Server v0.4.1 (Streamable HTTP + SSE Push)
929
+ `CommHub MCP Server v${SERVER_VERSION} (Streamable HTTP + SSE Push)
364
930
 
365
931
  Endpoints:
366
932
  POST /mcp - MCP Streamable HTTP (for Claude Code / Codex)
@@ -472,12 +1038,12 @@ process.on("SIGINT", shutdown);
472
1038
 
473
1039
  console.log(`
474
1040
  ╔══════════════════════════════════════════════════╗
475
- ║ CommHub MCP Server v0.4.1
1041
+ ║ CommHub MCP Server v${SERVER_VERSION}
476
1042
  ║ Transport: Streamable HTTP (Bun native) ║
477
1043
  ║ Auth: ${AUTH_TOKEN ? "ENABLED (Bearer token)" : "DISABLED (set COMMHUB_AUTH_TOKEN)"}${"".padEnd(AUTH_TOKEN ? 5 : 0)}║
478
1044
  ║ ║
479
- ║ MCP: http://0.0.0.0:${PORT}/mcp ║
480
- ║ REST: http://0.0.0.0:${PORT}/api ║
481
- ║ Health: http://0.0.0.0:${PORT}/health ║
1045
+ ║ MCP: http://${HOST}:${PORT}/mcp ║
1046
+ ║ REST: http://${HOST}:${PORT}/api ║
1047
+ ║ Health: http://${HOST}:${PORT}/health ║
482
1048
  ╚══════════════════════════════════════════════════╝
483
1049
  `);