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