@sleep2agi/commhub-server 0.5.0-preview.9 → 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,40 +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, createNetwork, type AuthUser } from "./auth.js";
7
+ import { register, login, resolveToken, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, listTokens, createToken, revokeToken, getNetworkMembers, getUserNetworkRole, addNetworkMember, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
8
8
 
9
9
  const PORT = Number(process.env.PORT) || 9200;
10
+ const HOST = process.env.HOST || "0.0.0.0";
10
11
  const AUTH_TOKEN = process.env.COMMHUB_AUTH_TOKEN;
11
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
+
12
44
  // ── Factory: 每个请求创建新的 McpServer(stateless 模式)──
13
- function createServer(clientIP?: string): McpServer {
45
+ function createServer(clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null): McpServer {
14
46
  const server = new McpServer({
15
47
  name: "commhub",
16
- version: "0.4.1",
48
+ version: "0.5.0",
17
49
  });
18
- registerTools(server, clientIP);
50
+ registerTools(server, clientIP, enforceNetworkId, enforceUserId, callerAlias);
19
51
  return server;
20
52
  }
21
53
 
22
54
  // ── Auth helper ─────────────────────────────────────
23
55
  function requireAuth(req: Request): Response | null {
24
- if (!AUTH_TOKEN) return null; // no token = open mode (dev)
25
- const header = req.headers.get("Authorization");
26
- if (header === `Bearer ${AUTH_TOKEN}`) return null;
27
- // Also check query param for MCP clients that can't set headers
56
+ const header = req.headers.get("Authorization")?.replace("Bearer ", "");
28
57
  const url = new URL(req.url);
29
- 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
+
30
70
  return Response.json({ error: "unauthorized" }, { status: 401 });
31
71
  }
32
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
+
33
148
  // ── REST input schema ───────────────────────────────
34
149
  const TaskSchema = z.object({
35
150
  alias: z.string().min(1).max(200),
36
151
  task: z.string().min(1).max(10000),
37
152
  priority: z.enum(["high", "normal", "low"]).default("normal"),
38
153
  from: z.string().max(200).optional(),
154
+ network_id: z.string().max(200).optional(),
39
155
  });
40
156
 
41
157
  const BroadcastSchema = z.object({
@@ -86,9 +202,8 @@ setInterval(() => {
86
202
  if (result.changes > 0) {
87
203
  console.log(`[patrol] expired ${result.changes} stale task(s)`);
88
204
  // Log events for expired tasks
89
- const expired = db.query<{ task_id: string }, []>(
90
- "SELECT task_id FROM tasks WHERE status = 'expired' AND completed_at >= datetime('now', '-1 minute')"
91
- ).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')");
92
207
  for (const t of expired) logTaskEvent(t.task_id, null, "expired", "patrol");
93
208
  }
94
209
  } catch {}
@@ -96,6 +211,7 @@ setInterval(() => {
96
211
 
97
212
  Bun.serve({
98
213
  port: PORT,
214
+ hostname: HOST,
99
215
  idleTimeout: 255, // max value: keep SSE connections alive (seconds)
100
216
 
101
217
  async fetch(req, server) {
@@ -120,10 +236,21 @@ Bun.serve({
120
236
  if (authErr) return withCors(req, authErr);
121
237
  const fwd = req.headers.get("x-forwarded-for");
122
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);
123
250
  const transport = new WebStandardStreamableHTTPServerTransport({
124
251
  sessionIdGenerator: undefined,
125
252
  });
126
- const server = createServer(clientIP);
253
+ const server = createServer(clientIP, enforceNetId, authCtx?.userId || null, callerAlias);
127
254
  await server.connect(transport);
128
255
  const response = await transport.handleRequest(req);
129
256
  // Disconnect after response to prevent McpServer leak
@@ -141,11 +268,54 @@ Bun.serve({
141
268
  return createSSEStream(sessionName);
142
269
  }
143
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
+
144
309
  // ── V3: Auth endpoints (public) ──
145
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
+ }
146
315
  try {
147
316
  const body = await req.json() as any;
148
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);
149
319
  return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
150
320
  } catch (e: any) {
151
321
  return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
@@ -153,9 +323,16 @@ Bun.serve({
153
323
  }
154
324
 
155
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
+ }
156
331
  try {
157
332
  const body = await req.json() as any;
158
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");
159
336
  return withCors(req, Response.json(result, { status: result.ok ? 200 : 401 }));
160
337
  } catch (e: any) {
161
338
  return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
@@ -167,17 +344,115 @@ Bun.serve({
167
344
  if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
168
345
  const resolved = resolveToken(token);
169
346
  if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
170
- const networks = getUserNetworks(resolved.user.user_id);
347
+ const networks = getUserAllNetworks(resolved.user.user_id);
171
348
  return withCors(req, Response.json({ ok: true, user: resolved.user, networks, current_network: resolved.networkId }));
172
349
  }
173
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
+
174
443
  // ── V3: Network management ──
175
444
  if (url.pathname === "/api/networks" && req.method === "GET") {
176
445
  const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
177
446
  if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
178
447
  const resolved = resolveToken(token);
179
448
  if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
180
- const networks = getUserNetworks(resolved.user.user_id);
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);
181
456
  return withCors(req, Response.json({ ok: true, networks }));
182
457
  }
183
458
 
@@ -195,19 +470,154 @@ Bun.serve({
195
470
  }
196
471
  }
197
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
+
198
603
  // ── REST: health (public, no auth) ──
199
604
  if (url.pathname === "/health") {
200
- 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");
201
606
  const sse = getSSEStats();
607
+ const license = db.get<any>("SELECT type, expires_at FROM licenses LIMIT 1");
202
608
  return withCors(req, Response.json({
203
609
  ok: true,
204
- version: "0.4.1",
610
+ version: SERVER_VERSION,
611
+ api_version: "v3",
205
612
  transport: "streamable-http",
206
- sessions: count?.cnt ?? 0,
613
+ sessions_count: count?.cnt ?? 0,
207
614
  sse_connections: sse.total,
208
615
  sse_sessions: sse.sessions,
209
616
  auth: AUTH_TOKEN ? "enabled" : "disabled",
210
- uptime: process.uptime(),
617
+ v3_auth: true,
618
+ multi_network: true,
619
+ license: license?.type || "none",
620
+ uptime: Math.floor(process.uptime()),
211
621
  }));
212
622
  }
213
623
 
@@ -215,11 +625,27 @@ Bun.serve({
215
625
  const authErr = requireAuth(req);
216
626
  if (authErr) return withCors(req, authErr);
217
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
+
218
637
  // ── REST: all sessions status ──
219
638
  if (url.pathname === "/api/status") {
220
639
  const cutoff = new Date(Date.now() - 10 * 60 * 1000).toISOString().replace("T", " ").slice(0, 19);
221
- db.run("UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'", [cutoff]);
222
- 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);
223
649
  return withCors(req, Response.json({ ok: true, sessions }));
224
650
  }
225
651
 
@@ -236,18 +662,40 @@ Bun.serve({
236
662
  return withCors(req, Response.json({ error: "invalid input", details: parsed.error.format() }, { status: 400 }));
237
663
  }
238
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
+ }
239
682
  const id = crypto.randomUUID();
240
683
  const fromSession = body.from || "api";
241
684
  db.run(
242
- `INSERT INTO inbox (id, session_name, type, priority, content, from_session)
243
- VALUES (?1, ?2, 'task', ?3, ?4, ?5)`,
244
- [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]
245
688
  );
246
689
  // SSE push: 秒达
247
- const pending = db.query<{ cnt: number }, [string]>(
248
- "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0"
249
- ).get(body.alias);
250
- 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 });
251
699
  return withCors(req, Response.json({ ok: true, message_id: id }));
252
700
  }
253
701
 
@@ -264,18 +712,25 @@ Bun.serve({
264
712
  return withCors(req, Response.json({ error: "invalid input", details: parsed.error.format() }, { status: 400 }));
265
713
  }
266
714
  const body = parsed.data;
267
- 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";
268
722
  const params: any[] = [];
723
+ sql = addNetworkScope(sql, params, restScope);
269
724
  if (body.filter_server) { sql += " AND server = ?"; params.push(body.filter_server); }
270
725
  if (body.filter_status) { sql += " AND status = ?"; params.push(body.filter_status); }
271
- const targets = db.query<{ alias: string }, any[]>(sql).all(...params);
726
+ const targets = db.all<{ alias: string; network_id: string | null }>(sql, ...params);
272
727
  const ids: string[] = [];
273
728
  for (const t of targets) {
274
729
  const id = crypto.randomUUID();
275
730
  db.run(
276
- `INSERT INTO inbox (id, session_name, type, priority, content, from_session)
277
- VALUES (?1, ?2, 'broadcast', 'normal', ?3, 'api')`,
278
- [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]
279
734
  );
280
735
  ids.push(id);
281
736
  }
@@ -333,25 +788,49 @@ Bun.serve({
333
788
 
334
789
  // ── REST: recent messages (for Dashboard communication graph) ──
335
790
  if (url.pathname === "/api/messages") {
336
- const limit = Number(url.searchParams.get("limit")) || 100;
791
+ const limit = Math.min(Number(url.searchParams.get("limit")) || 100, 500);
337
792
  const since = url.searchParams.get("since") ?? new Date(Date.now() - 3600000).toISOString().replace("T", " ").slice(0, 19);
338
- const rows = db.query(
339
- "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"
340
- ).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);
341
799
  return withCors(req, Response.json({ ok: true, messages: rows }));
342
800
  }
343
801
 
344
802
  // ── REST: stats summary ──
345
803
  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();
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);
353
831
  return withCors(req, Response.json({
354
832
  ok: true,
833
+ network_id: restScope.networkId || null,
355
834
  tasks: { total: totalTasks?.cnt || 0, by_status: taskStats },
356
835
  sessions: { by_status: sessionStats },
357
836
  nodes: { total: totalNodes?.cnt || 0 },
@@ -359,16 +838,38 @@ Bun.serve({
359
838
  }));
360
839
  }
361
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
+
362
862
  // ── REST: task events (V2 Sprint 2) ──
363
863
  if (url.pathname === "/api/task_events") {
364
864
  const taskId = url.searchParams.get("task_id");
365
865
  const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 500);
366
- let sql = "SELECT * FROM task_events";
866
+ let sql = "SELECT * FROM task_events WHERE 1=1";
367
867
  const params: any[] = [];
368
- if (taskId) { sql += " WHERE task_id = ?1"; params.push(taskId); }
369
- 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}`;
370
871
  params.push(limit);
371
- const rows = db.query(sql).all(...params);
872
+ const rows = db.all(sql, ...params);
372
873
  return withCors(req, Response.json({ ok: true, events: rows, count: rows.length }));
373
874
  }
374
875
 
@@ -378,10 +879,11 @@ Bun.serve({
378
879
  const alias = url.searchParams.get("alias");
379
880
  let sql = "SELECT * FROM nodes WHERE 1=1";
380
881
  const params: any[] = [];
882
+ sql = addNetworkScope(sql, params, restScope);
381
883
  if (nodeId) { sql += ` AND node_id = ?${params.length + 1}`; params.push(nodeId); }
382
884
  if (alias) { sql += ` AND alias = ?${params.length + 1}`; params.push(alias); }
383
885
  sql += " ORDER BY updated_at DESC";
384
- const rows = db.query(sql).all(...params);
886
+ const rows = db.all(sql, ...params);
385
887
  return withCors(req, Response.json({ ok: true, nodes: rows, count: rows.length }));
386
888
  }
387
889
 
@@ -395,6 +897,7 @@ Bun.serve({
395
897
 
396
898
  let sql = "SELECT * FROM tasks WHERE 1=1";
397
899
  const params: any[] = [];
900
+ sql = addNetworkScope(sql, params, restScope);
398
901
  if (taskId) { sql += ` AND task_id = ?${params.length + 1}`; params.push(taskId); }
399
902
  if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
400
903
  if (toName) { sql += ` AND to_name = ?${params.length + 1}`; params.push(toName); }
@@ -402,20 +905,28 @@ Bun.serve({
402
905
  sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
403
906
  params.push(limit);
404
907
 
405
- const rows = db.query(sql).all(...params);
406
- 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);
407
914
  return withCors(req, Response.json({ ok: true, tasks: rows, count: rows.length, stats }));
408
915
  }
409
916
 
410
917
  // ── REST: recent completions ──
411
918
  if (url.pathname === "/api/completions") {
412
919
  const since = url.searchParams.get("since") ?? new Date(Date.now() - 86400000).toISOString();
413
- 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);
414
925
  return withCors(req, Response.json({ ok: true, completions: rows }));
415
926
  }
416
927
 
417
928
  return withCors(req, new Response(
418
- `CommHub MCP Server v0.4.1 (Streamable HTTP + SSE Push)
929
+ `CommHub MCP Server v${SERVER_VERSION} (Streamable HTTP + SSE Push)
419
930
 
420
931
  Endpoints:
421
932
  POST /mcp - MCP Streamable HTTP (for Claude Code / Codex)
@@ -527,12 +1038,12 @@ process.on("SIGINT", shutdown);
527
1038
 
528
1039
  console.log(`
529
1040
  ╔══════════════════════════════════════════════════╗
530
- ║ CommHub MCP Server v0.4.1
1041
+ ║ CommHub MCP Server v${SERVER_VERSION}
531
1042
  ║ Transport: Streamable HTTP (Bun native) ║
532
1043
  ║ Auth: ${AUTH_TOKEN ? "ENABLED (Bearer token)" : "DISABLED (set COMMHUB_AUTH_TOKEN)"}${"".padEnd(AUTH_TOKEN ? 5 : 0)}║
533
1044
  ║ ║
534
- ║ MCP: http://0.0.0.0:${PORT}/mcp ║
535
- ║ REST: http://0.0.0.0:${PORT}/api ║
536
- ║ 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 ║
537
1048
  ╚══════════════════════════════════════════════════╝
538
1049
  `);