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