@sleep2agi/commhub-server 0.5.0-preview.3 → 0.5.0-preview.30

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