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