@sleep2agi/commhub-server 0.5.0-preview.2 → 0.5.0-preview.21
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 +110 -0
- package/package.json +1 -1
- package/src/auth.ts +164 -0
- package/src/db-adapter.ts +72 -0
- package/src/db.ts +155 -0
- package/src/index.ts +351 -12
- package/src/tools.ts +242 -22
package/src/index.ts
CHANGED
|
@@ -2,33 +2,76 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
3
3
|
import { z } from "zod/v4";
|
|
4
4
|
import { registerTools } from "./tools.js";
|
|
5
|
-
import { db } from "./db.js";
|
|
5
|
+
import { db, logTaskEvent, logAudit } from "./db.js";
|
|
6
6
|
import { createSSEStream, pushEvent, pushBroadcast, getSSEStats } from "./push.js";
|
|
7
|
+
import { register, login, resolveToken, getUserNetworks, createNetwork, changePassword, listTokens, createToken, revokeToken, type AuthUser } from "./auth.js";
|
|
7
8
|
|
|
8
9
|
const PORT = Number(process.env.PORT) || 9200;
|
|
9
10
|
const AUTH_TOKEN = process.env.COMMHUB_AUTH_TOKEN;
|
|
10
11
|
|
|
12
|
+
// ── Rate limiter (in-memory, per IP) ──
|
|
13
|
+
const rateLimits = new Map<string, { count: number; resetAt: number }>();
|
|
14
|
+
function checkRateLimit(ip: string, maxPerMinute = 60): boolean {
|
|
15
|
+
// Skip rate limiting for localhost/internal/unknown (dev/test)
|
|
16
|
+
if (!ip || ip === "unknown" || ip === "127.0.0.1" || ip === "::1") return true;
|
|
17
|
+
const now = Date.now();
|
|
18
|
+
const entry = rateLimits.get(ip);
|
|
19
|
+
if (!entry || now > entry.resetAt) {
|
|
20
|
+
rateLimits.set(ip, { count: 1, resetAt: now + 60000 });
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
if (entry.count >= maxPerMinute) return false;
|
|
24
|
+
entry.count++;
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
// Cleanup stale entries every 5 minutes
|
|
28
|
+
setInterval(() => {
|
|
29
|
+
const now = Date.now();
|
|
30
|
+
for (const [ip, entry] of rateLimits) {
|
|
31
|
+
if (now > entry.resetAt) rateLimits.delete(ip);
|
|
32
|
+
}
|
|
33
|
+
}, 300000);
|
|
34
|
+
|
|
11
35
|
// ── Factory: 每个请求创建新的 McpServer(stateless 模式)──
|
|
12
|
-
function createServer(clientIP?: string): McpServer {
|
|
36
|
+
function createServer(clientIP?: string, enforceNetworkId?: string | null): McpServer {
|
|
13
37
|
const server = new McpServer({
|
|
14
38
|
name: "commhub",
|
|
15
|
-
version: "0.
|
|
39
|
+
version: "0.5.0",
|
|
16
40
|
});
|
|
17
|
-
registerTools(server, clientIP);
|
|
41
|
+
registerTools(server, clientIP, enforceNetworkId);
|
|
18
42
|
return server;
|
|
19
43
|
}
|
|
20
44
|
|
|
21
45
|
// ── Auth helper ─────────────────────────────────────
|
|
22
46
|
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
|
|
47
|
+
const header = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
27
48
|
const url = new URL(req.url);
|
|
28
|
-
|
|
49
|
+
const token = header || url.searchParams.get("token") || "";
|
|
50
|
+
|
|
51
|
+
// V3: check api_tokens first
|
|
52
|
+
if (token) {
|
|
53
|
+
const resolved = resolveToken(token);
|
|
54
|
+
if (resolved) return null; // valid user token
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Legacy: check global COMMHUB_AUTH_TOKEN
|
|
58
|
+
if (!AUTH_TOKEN) return null; // no token = open mode (dev)
|
|
59
|
+
if (token === AUTH_TOKEN) return null;
|
|
60
|
+
|
|
29
61
|
return Response.json({ error: "unauthorized" }, { status: 401 });
|
|
30
62
|
}
|
|
31
63
|
|
|
64
|
+
// Extract user + network from request token (for authorization)
|
|
65
|
+
function resolveRequestAuth(req: Request): { userId: string; networkId: string | null; username: string } | null {
|
|
66
|
+
const header = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
67
|
+
const url = new URL(req.url);
|
|
68
|
+
const token = header || url.searchParams.get("token") || "";
|
|
69
|
+
if (!token) return null;
|
|
70
|
+
const resolved = resolveToken(token);
|
|
71
|
+
if (!resolved) return null;
|
|
72
|
+
return { userId: resolved.user.user_id, networkId: resolved.networkId, username: resolved.user.username };
|
|
73
|
+
}
|
|
74
|
+
|
|
32
75
|
// ── REST input schema ───────────────────────────────
|
|
33
76
|
const TaskSchema = z.object({
|
|
34
77
|
alias: z.string().min(1).max(200),
|
|
@@ -84,6 +127,11 @@ setInterval(() => {
|
|
|
84
127
|
);
|
|
85
128
|
if (result.changes > 0) {
|
|
86
129
|
console.log(`[patrol] expired ${result.changes} stale task(s)`);
|
|
130
|
+
// Log events for expired tasks
|
|
131
|
+
const expired = db.query<{ task_id: string }, []>(
|
|
132
|
+
"SELECT task_id FROM tasks WHERE status = 'expired' AND completed_at >= datetime('now', '-1 minute')"
|
|
133
|
+
).all();
|
|
134
|
+
for (const t of expired) logTaskEvent(t.task_id, null, "expired", "patrol");
|
|
87
135
|
}
|
|
88
136
|
} catch {}
|
|
89
137
|
}, 5 * 60 * 1000);
|
|
@@ -114,10 +162,13 @@ Bun.serve({
|
|
|
114
162
|
if (authErr) return withCors(req, authErr);
|
|
115
163
|
const fwd = req.headers.get("x-forwarded-for");
|
|
116
164
|
const clientIP = fwd ? fwd.split(",")[0].trim() : (req.headers.get("x-real-ip") ?? "unknown");
|
|
165
|
+
// V3: resolve token → enforce network_id in all MCP tools
|
|
166
|
+
const authCtx = resolveRequestAuth(req);
|
|
167
|
+
const enforceNetId = authCtx?.networkId || null;
|
|
117
168
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
118
169
|
sessionIdGenerator: undefined,
|
|
119
170
|
});
|
|
120
|
-
const server = createServer(clientIP);
|
|
171
|
+
const server = createServer(clientIP, enforceNetId);
|
|
121
172
|
await server.connect(transport);
|
|
122
173
|
const response = await transport.handleRequest(req);
|
|
123
174
|
// Disconnect after response to prevent McpServer leak
|
|
@@ -135,6 +186,220 @@ Bun.serve({
|
|
|
135
186
|
return createSSEStream(sessionName);
|
|
136
187
|
}
|
|
137
188
|
|
|
189
|
+
// ── V3: License endpoints ──
|
|
190
|
+
if (url.pathname === "/api/license" && req.method === "GET") {
|
|
191
|
+
const license = db.query<any, []>("SELECT * FROM licenses ORDER BY created_at LIMIT 1").get();
|
|
192
|
+
if (!license) return withCors(req, Response.json({ ok: true, status: "no_license" }));
|
|
193
|
+
const now = new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
194
|
+
const expired = license.expires_at && license.expires_at < now;
|
|
195
|
+
const daysLeft = license.expires_at
|
|
196
|
+
? Math.max(0, Math.ceil((new Date(license.expires_at).getTime() - Date.now()) / 86400000))
|
|
197
|
+
: null;
|
|
198
|
+
return withCors(req, Response.json({
|
|
199
|
+
ok: true,
|
|
200
|
+
license: { type: license.type, expires_at: license.expires_at, days_left: daysLeft, expired },
|
|
201
|
+
limits: { max_agents: license.max_agents, max_networks: license.max_networks, max_tasks_day: license.max_tasks_day },
|
|
202
|
+
}));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (url.pathname === "/api/license/activate" && req.method === "POST") {
|
|
206
|
+
try {
|
|
207
|
+
const body = await req.json() as any;
|
|
208
|
+
const key = body.key;
|
|
209
|
+
if (!key) return withCors(req, Response.json({ ok: false, error: "key required" }, { status: 400 }));
|
|
210
|
+
// For now: accept any key starting with "anet-" as valid pro license
|
|
211
|
+
if (!key.startsWith("anet-") || key.length < 16) {
|
|
212
|
+
return withCors(req, Response.json({ ok: false, error: "invalid license key" }, { status: 400 }));
|
|
213
|
+
}
|
|
214
|
+
// Upgrade existing license or create new
|
|
215
|
+
db.run("DELETE FROM licenses");
|
|
216
|
+
const licId = `lic_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
|
217
|
+
db.run(
|
|
218
|
+
"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'))",
|
|
219
|
+
[licId, key]
|
|
220
|
+
);
|
|
221
|
+
return withCors(req, Response.json({ ok: true, type: "pro", expires_in_days: 365 }));
|
|
222
|
+
} catch (e: any) {
|
|
223
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── V3: Auth endpoints (public) ──
|
|
228
|
+
if (url.pathname === "/api/auth/register" && req.method === "POST") {
|
|
229
|
+
const clientIP = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
|
|
230
|
+
if (!checkRateLimit(clientIP, 30)) {
|
|
231
|
+
return withCors(req, Response.json({ ok: false, error: "too many requests, try again later" }, { status: 429 }));
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
const body = await req.json() as any;
|
|
235
|
+
const result = register(body.username, body.password, body.email, body.display_name);
|
|
236
|
+
if (result.ok) logAudit(result.user!.user_id, body.username, "register", "user", result.user!.user_id);
|
|
237
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
238
|
+
} catch (e: any) {
|
|
239
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (url.pathname === "/api/auth/login" && req.method === "POST") {
|
|
244
|
+
const clientIP = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
|
|
245
|
+
if (!checkRateLimit(clientIP, 10)) {
|
|
246
|
+
logAudit(null, null, "login_rate_limited", "auth", null, clientIP);
|
|
247
|
+
return withCors(req, Response.json({ ok: false, error: "too many attempts, try again later" }, { status: 429 }));
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
const body = await req.json() as any;
|
|
251
|
+
const result = login(body.username, body.password);
|
|
252
|
+
if (result.ok) logAudit(result.user!.user_id, body.username, "login", "user", result.user!.user_id);
|
|
253
|
+
else logAudit(null, body.username, "login_failed", "user", null, "invalid credentials");
|
|
254
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 401 }));
|
|
255
|
+
} catch (e: any) {
|
|
256
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (url.pathname === "/api/auth/me" && req.method === "GET") {
|
|
261
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
262
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
263
|
+
const resolved = resolveToken(token);
|
|
264
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
265
|
+
const networks = getUserNetworks(resolved.user.user_id);
|
|
266
|
+
return withCors(req, Response.json({ ok: true, user: resolved.user, networks, current_network: resolved.networkId }));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (url.pathname === "/api/auth/me" && req.method === "PUT") {
|
|
270
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
271
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
272
|
+
const resolved = resolveToken(token);
|
|
273
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
274
|
+
try {
|
|
275
|
+
const body = await req.json() as any;
|
|
276
|
+
const updates: string[] = [];
|
|
277
|
+
const params: any[] = [];
|
|
278
|
+
if (body.display_name) { updates.push(`display_name = ?${params.length + 1}`); params.push(body.display_name); }
|
|
279
|
+
if (body.email) { updates.push(`email = ?${params.length + 1}`); params.push(body.email); }
|
|
280
|
+
if (updates.length > 0) {
|
|
281
|
+
updates.push(`updated_at = datetime('now')`);
|
|
282
|
+
params.push(resolved.user.user_id);
|
|
283
|
+
db.run(`UPDATE users SET ${updates.join(", ")} WHERE user_id = ?${params.length}`, params);
|
|
284
|
+
}
|
|
285
|
+
// Re-fetch
|
|
286
|
+
const user = db.query<any, [string]>("SELECT user_id, username, display_name, email, role FROM users WHERE user_id = ?1").get(resolved.user.user_id);
|
|
287
|
+
return withCors(req, Response.json({ ok: true, user }));
|
|
288
|
+
} catch (e: any) {
|
|
289
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (url.pathname === "/api/auth/password" && req.method === "POST") {
|
|
294
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
295
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
296
|
+
const resolved = resolveToken(token);
|
|
297
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
298
|
+
try {
|
|
299
|
+
const body = await req.json() as any;
|
|
300
|
+
const result = changePassword(resolved.user.user_id, body.old_password, body.new_password);
|
|
301
|
+
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "password_changed", "user", resolved.user.user_id);
|
|
302
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
303
|
+
} catch (e: any) {
|
|
304
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── V3: Token management ──
|
|
309
|
+
if (url.pathname === "/api/auth/tokens" && req.method === "GET") {
|
|
310
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
311
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
312
|
+
const resolved = resolveToken(token);
|
|
313
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
314
|
+
const tokens = listTokens(resolved.user.user_id);
|
|
315
|
+
return withCors(req, Response.json({ ok: true, tokens }));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (url.pathname === "/api/auth/tokens" && req.method === "POST") {
|
|
319
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
320
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
321
|
+
const resolved = resolveToken(token);
|
|
322
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
323
|
+
try {
|
|
324
|
+
const body = await req.json() as any;
|
|
325
|
+
const result = createToken(resolved.user.user_id, body.name || "api-token", body.network_id);
|
|
326
|
+
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "token_created", "token", result.token_id);
|
|
327
|
+
return withCors(req, Response.json(result));
|
|
328
|
+
} catch (e: any) {
|
|
329
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const tokenDeleteMatch = url.pathname.match(/^\/api\/auth\/tokens\/([^/]+)$/);
|
|
334
|
+
if (tokenDeleteMatch && req.method === "DELETE") {
|
|
335
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
336
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
337
|
+
const resolved = resolveToken(token);
|
|
338
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
339
|
+
const result = revokeToken(resolved.user.user_id, tokenDeleteMatch[1]);
|
|
340
|
+
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "token_revoked", "token", tokenDeleteMatch[1]);
|
|
341
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 404 }));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ── V3: Network management ──
|
|
345
|
+
if (url.pathname === "/api/networks" && req.method === "GET") {
|
|
346
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
347
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
348
|
+
const resolved = resolveToken(token);
|
|
349
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
350
|
+
const networks = getUserNetworks(resolved.user.user_id);
|
|
351
|
+
return withCors(req, Response.json({ ok: true, networks }));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (url.pathname === "/api/networks" && req.method === "POST") {
|
|
355
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
356
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
357
|
+
const resolved = resolveToken(token);
|
|
358
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
359
|
+
try {
|
|
360
|
+
const body = await req.json() as any;
|
|
361
|
+
const result = createNetwork(resolved.user.user_id, body.name, body.description);
|
|
362
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
363
|
+
} catch (e: any) {
|
|
364
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ── V3: Admin APIs (require auth) ──
|
|
369
|
+
if (url.pathname === "/api/users" && req.method === "GET") {
|
|
370
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
371
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
372
|
+
const resolved = resolveToken(token);
|
|
373
|
+
if (!resolved || resolved.user.role !== "admin") {
|
|
374
|
+
return withCors(req, Response.json({ ok: false, error: "admin required" }, { status: 403 }));
|
|
375
|
+
}
|
|
376
|
+
const users = db.query("SELECT user_id, username, display_name, email, role, created_at FROM users ORDER BY created_at").all();
|
|
377
|
+
return withCors(req, Response.json({ ok: true, users }));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const netDetailMatch = url.pathname.match(/^\/api\/networks\/([^/]+)$/);
|
|
381
|
+
if (netDetailMatch && req.method === "GET") {
|
|
382
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
383
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
384
|
+
const resolved = resolveToken(token);
|
|
385
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
386
|
+
const networkId = netDetailMatch[1];
|
|
387
|
+
const network = db.query<any, [string]>("SELECT * FROM networks WHERE network_id = ?1").get(networkId);
|
|
388
|
+
if (!network) return withCors(req, Response.json({ ok: false, error: "network not found" }, { status: 404 }));
|
|
389
|
+
// Ownership check: only owner or admin can view
|
|
390
|
+
if (network.owner_id !== resolved.user.user_id && resolved.user.role !== "admin") {
|
|
391
|
+
return withCors(req, Response.json({ ok: false, error: "access denied" }, { status: 403 }));
|
|
392
|
+
}
|
|
393
|
+
// Get network stats
|
|
394
|
+
const nodeCount = db.query<{ cnt: number }, [string]>("SELECT COUNT(*) as cnt FROM nodes WHERE network_id = ?1").get(networkId);
|
|
395
|
+
const sessionCount = db.query<{ cnt: number }, [string]>("SELECT COUNT(*) as cnt FROM sessions WHERE network_id = ?1").get(networkId);
|
|
396
|
+
const taskStats = db.query<any, [string]>("SELECT status, COUNT(*) as count FROM tasks WHERE network_id = ?1 GROUP BY status").all(networkId);
|
|
397
|
+
return withCors(req, Response.json({
|
|
398
|
+
ok: true, network,
|
|
399
|
+
stats: { nodes: nodeCount?.cnt || 0, sessions: sessionCount?.cnt || 0, tasks: taskStats },
|
|
400
|
+
}));
|
|
401
|
+
}
|
|
402
|
+
|
|
138
403
|
// ── REST: health (public, no auth) ──
|
|
139
404
|
if (url.pathname === "/health") {
|
|
140
405
|
const count = db.query<{ cnt: number }, []>("SELECT COUNT(*) as cnt FROM sessions").get();
|
|
@@ -159,7 +424,11 @@ Bun.serve({
|
|
|
159
424
|
if (url.pathname === "/api/status") {
|
|
160
425
|
const cutoff = new Date(Date.now() - 10 * 60 * 1000).toISOString().replace("T", " ").slice(0, 19);
|
|
161
426
|
db.run("UPDATE sessions SET status = 'offline' WHERE updated_at < ?1 AND status != 'offline'", [cutoff]);
|
|
162
|
-
const
|
|
427
|
+
const netFilter = url.searchParams.get("network_id");
|
|
428
|
+
const sql = netFilter
|
|
429
|
+
? "SELECT * FROM sessions WHERE network_id = ?1 ORDER BY updated_at DESC"
|
|
430
|
+
: "SELECT * FROM sessions ORDER BY updated_at DESC";
|
|
431
|
+
const sessions = netFilter ? db.query(sql).all(netFilter) : db.query(sql).all();
|
|
163
432
|
return withCors(req, Response.json({ ok: true, sessions }));
|
|
164
433
|
}
|
|
165
434
|
|
|
@@ -281,12 +550,77 @@ Bun.serve({
|
|
|
281
550
|
return withCors(req, Response.json({ ok: true, messages: rows }));
|
|
282
551
|
}
|
|
283
552
|
|
|
553
|
+
// ── REST: stats summary ──
|
|
554
|
+
if (url.pathname === "/api/stats") {
|
|
555
|
+
const n = url.searchParams.get("network_id");
|
|
556
|
+
// Parameterized queries to prevent SQL injection
|
|
557
|
+
const taskStats = n
|
|
558
|
+
? db.query<any, [string]>("SELECT status, COUNT(*) as count FROM tasks WHERE network_id = ?1 GROUP BY status").all(n)
|
|
559
|
+
: db.query<any, []>("SELECT status, COUNT(*) as count FROM tasks GROUP BY status").all();
|
|
560
|
+
const sessionStats = n
|
|
561
|
+
? db.query<any, [string]>("SELECT status, COUNT(*) as count FROM sessions WHERE network_id = ?1 GROUP BY status").all(n)
|
|
562
|
+
: db.query<any, []>("SELECT status, COUNT(*) as count FROM sessions GROUP BY status").all();
|
|
563
|
+
const totalTasks = n
|
|
564
|
+
? db.query<{ cnt: number }, [string]>("SELECT COUNT(*) as cnt FROM tasks WHERE network_id = ?1").get(n)
|
|
565
|
+
: db.query<{ cnt: number }, []>("SELECT COUNT(*) as cnt FROM tasks").get();
|
|
566
|
+
const totalNodes = n
|
|
567
|
+
? db.query<{ cnt: number }, [string]>("SELECT COUNT(*) as cnt FROM nodes WHERE network_id = ?1").get(n)
|
|
568
|
+
: db.query<{ cnt: number }, []>("SELECT COUNT(*) as cnt FROM nodes").get();
|
|
569
|
+
const recentTasks = n
|
|
570
|
+
? db.query<any, [string]>("SELECT task_id, from_name, to_name, status, created_at FROM tasks WHERE network_id = ?1 ORDER BY created_at DESC LIMIT 5").all(n)
|
|
571
|
+
: db.query<any, []>("SELECT task_id, from_name, to_name, status, created_at FROM tasks ORDER BY created_at DESC LIMIT 5").all();
|
|
572
|
+
return withCors(req, Response.json({
|
|
573
|
+
ok: true,
|
|
574
|
+
network_id: n || null,
|
|
575
|
+
tasks: { total: totalTasks?.cnt || 0, by_status: taskStats },
|
|
576
|
+
sessions: { by_status: sessionStats },
|
|
577
|
+
nodes: { total: totalNodes?.cnt || 0 },
|
|
578
|
+
recent_tasks: recentTasks,
|
|
579
|
+
}));
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// ── REST: audit log (V3) ──
|
|
583
|
+
if (url.pathname === "/api/audit-log") {
|
|
584
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
585
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
586
|
+
const resolved = resolveToken(token);
|
|
587
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
588
|
+
const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 200);
|
|
589
|
+
const action = url.searchParams.get("action");
|
|
590
|
+
const userId = url.searchParams.get("user_id");
|
|
591
|
+
let sql = "SELECT * FROM audit_log WHERE 1=1";
|
|
592
|
+
const params: any[] = [];
|
|
593
|
+
// Non-admin can only see own logs
|
|
594
|
+
if (resolved.user.role !== "admin") { sql += ` AND user_id = ?${params.length + 1}`; params.push(resolved.user.user_id); }
|
|
595
|
+
if (action) { sql += ` AND action = ?${params.length + 1}`; params.push(action); }
|
|
596
|
+
if (userId && resolved.user.role === "admin") { sql += ` AND user_id = ?${params.length + 1}`; params.push(userId); }
|
|
597
|
+
sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
|
|
598
|
+
params.push(limit);
|
|
599
|
+
const logs = db.query(sql).all(...params);
|
|
600
|
+
return withCors(req, Response.json({ ok: true, logs, count: logs.length }));
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// ── REST: task events (V2 Sprint 2) ──
|
|
604
|
+
if (url.pathname === "/api/task_events") {
|
|
605
|
+
const taskId = url.searchParams.get("task_id");
|
|
606
|
+
const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 500);
|
|
607
|
+
let sql = "SELECT * FROM task_events";
|
|
608
|
+
const params: any[] = [];
|
|
609
|
+
if (taskId) { sql += " WHERE task_id = ?1"; params.push(taskId); }
|
|
610
|
+
sql += " ORDER BY created_at DESC LIMIT ?";
|
|
611
|
+
params.push(limit);
|
|
612
|
+
const rows = db.query(sql).all(...params);
|
|
613
|
+
return withCors(req, Response.json({ ok: true, events: rows, count: rows.length }));
|
|
614
|
+
}
|
|
615
|
+
|
|
284
616
|
// ── REST: nodes table (V2 Sprint 2) ──
|
|
285
617
|
if (url.pathname === "/api/nodes") {
|
|
286
618
|
const nodeId = url.searchParams.get("node_id");
|
|
287
619
|
const alias = url.searchParams.get("alias");
|
|
620
|
+
const netFilter = url.searchParams.get("network_id");
|
|
288
621
|
let sql = "SELECT * FROM nodes WHERE 1=1";
|
|
289
622
|
const params: any[] = [];
|
|
623
|
+
if (netFilter) { sql += ` AND network_id = ?${params.length + 1}`; params.push(netFilter); }
|
|
290
624
|
if (nodeId) { sql += ` AND node_id = ?${params.length + 1}`; params.push(nodeId); }
|
|
291
625
|
if (alias) { sql += ` AND alias = ?${params.length + 1}`; params.push(alias); }
|
|
292
626
|
sql += " ORDER BY updated_at DESC";
|
|
@@ -300,10 +634,12 @@ Bun.serve({
|
|
|
300
634
|
const status = url.searchParams.get("status");
|
|
301
635
|
const toName = url.searchParams.get("to_name");
|
|
302
636
|
const fromName = url.searchParams.get("from_name");
|
|
637
|
+
const netFilter = url.searchParams.get("network_id");
|
|
303
638
|
const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 200);
|
|
304
639
|
|
|
305
640
|
let sql = "SELECT * FROM tasks WHERE 1=1";
|
|
306
641
|
const params: any[] = [];
|
|
642
|
+
if (netFilter) { sql += ` AND network_id = ?${params.length + 1}`; params.push(netFilter); }
|
|
307
643
|
if (taskId) { sql += ` AND task_id = ?${params.length + 1}`; params.push(taskId); }
|
|
308
644
|
if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
|
|
309
645
|
if (toName) { sql += ` AND to_name = ?${params.length + 1}`; params.push(toName); }
|
|
@@ -312,7 +648,10 @@ Bun.serve({
|
|
|
312
648
|
params.push(limit);
|
|
313
649
|
|
|
314
650
|
const rows = db.query(sql).all(...params);
|
|
315
|
-
|
|
651
|
+
const stats = netFilter
|
|
652
|
+
? db.query<any, [string]>("SELECT status, COUNT(*) as count FROM tasks WHERE network_id = ?1 GROUP BY status").all(netFilter)
|
|
653
|
+
: db.query<any, []>("SELECT status, COUNT(*) as count FROM tasks GROUP BY status").all();
|
|
654
|
+
return withCors(req, Response.json({ ok: true, tasks: rows, count: rows.length, stats }));
|
|
316
655
|
}
|
|
317
656
|
|
|
318
657
|
// ── REST: recent completions ──
|