@sleep2agi/commhub-server 0.9.0-preview.21 → 0.9.0-preview.22
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 +1 -1
- package/bin/commhub.ts +6 -2
- package/package.json +1 -1
- package/src/api-host-supervisors-fallback.test.ts +15 -5
- package/src/avatar-validate.test.ts +113 -0
- package/src/avatar-validate.ts +69 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/db.ts +10 -0
- package/src/health-redaction.test.ts +176 -0
- package/src/index.ts +11 -2385
- package/src/observer-avatar-http.test.ts +349 -0
- package/src/observer-push.test.ts +176 -0
- package/src/push.ts +87 -7
- package/src/response-charset.test.ts +130 -0
- package/src/server.ts +2642 -0
- package/src/tools.ts +8 -1
- package/src/uploads-http.test.ts +11 -8
package/src/server.ts
ADDED
|
@@ -0,0 +1,2642 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
3
|
+
import { z } from "zod/v4";
|
|
4
|
+
import { registerTools } from "./tools.js";
|
|
5
|
+
import { db, logTaskEvent, logAudit } from "./db.js";
|
|
6
|
+
import { createSSEStream, createNetworkObserverStream, pushEvent, pushNetworkObserverEvent, getSSEStats } from "./push.js";
|
|
7
|
+
import { assertNodeActive } from "./lifecycle-guard.js";
|
|
8
|
+
import { validateAvatarUrl } from "./avatar-validate.js";
|
|
9
|
+
import { register, login, resolveToken, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, issueUserToken, listTokens, createToken, revokeToken, getNetworkMembers, getUserNetworkRole, addNetworkMember, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
|
|
10
|
+
import { abortRename, cleanupCommittedRenameSessions, commitRename, prepareRename, resolveCanonicalAlias } from "./rename.js";
|
|
11
|
+
import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
|
|
12
|
+
import { getLoginClientIp, sharedLoginFailureLockout, sharedLoginIpRateLimiter } from "./auth_login_guard.js";
|
|
13
|
+
import {
|
|
14
|
+
FILE_ID_REGEX,
|
|
15
|
+
MAX_REQUEST_CONTENT_LENGTH,
|
|
16
|
+
MAX_UPLOAD_BYTES,
|
|
17
|
+
buildStoragePath,
|
|
18
|
+
generateFileId,
|
|
19
|
+
getUploadsRoot,
|
|
20
|
+
indexEntryPath,
|
|
21
|
+
isPathInsideUploadsRoot,
|
|
22
|
+
sanitizeExt,
|
|
23
|
+
sharedUploadRateLimiter,
|
|
24
|
+
stripHostLocalPathsForCrossHostSafe,
|
|
25
|
+
validateAttachments,
|
|
26
|
+
validateIndexEntry,
|
|
27
|
+
} from "./uploads.js";
|
|
28
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, statSync } from "fs";
|
|
29
|
+
import { dirname as pathDirname } from "path";
|
|
30
|
+
import { startRetentionSweeper } from "./retention.js";
|
|
31
|
+
import { startStaleSessionSweeper } from "./stale-sweeper.js";
|
|
32
|
+
|
|
33
|
+
const PORT = Number(process.env.PORT) || 9200;
|
|
34
|
+
const HOST = process.env.HOST || "127.0.0.1";
|
|
35
|
+
const AUTH_TOKEN = process.env.COMMHUB_AUTH_TOKEN;
|
|
36
|
+
const DEV_OPEN = process.argv.includes("--dev-open") || process.env.COMMHUB_DEV_OPEN === "1";
|
|
37
|
+
const TMUX_ENABLED = process.env.COMMHUB_ENABLE_TMUX === "1";
|
|
38
|
+
const SECURITY_LABEL = DEV_OPEN ? "⚠️ DEV OPEN MODE" : "🔒 secured";
|
|
39
|
+
const TMUX_ALLOWLIST = new Set(
|
|
40
|
+
(process.env.COMMHUB_TMUX_ALLOWLIST || "")
|
|
41
|
+
.split(",")
|
|
42
|
+
.map((s) => s.trim())
|
|
43
|
+
.filter(Boolean)
|
|
44
|
+
);
|
|
45
|
+
let masterTokenDeprecationLogged = false;
|
|
46
|
+
|
|
47
|
+
if (AUTH_TOKEN) {
|
|
48
|
+
console.warn("[commhub] COMMHUB_AUTH_TOKEN is deprecated and will be removed in v1.0. See RFC-001.");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Read version from package.json so banners and /health stay in sync.
|
|
52
|
+
const SERVER_VERSION = (() => {
|
|
53
|
+
try {
|
|
54
|
+
const url = new URL("../package.json", import.meta.url);
|
|
55
|
+
return JSON.parse(require("fs").readFileSync(url, "utf8")).version || "?";
|
|
56
|
+
} catch { return "?"; }
|
|
57
|
+
})();
|
|
58
|
+
|
|
59
|
+
// In-memory log ring buffer — last N lines streamed via /api/server-logs.
|
|
60
|
+
// Wraps console.log/info/warn/error so EVERY existing log call lands here
|
|
61
|
+
// without source changes. Dashboard tails this buffer for "hub server log
|
|
62
|
+
// view" feature.
|
|
63
|
+
const LOG_RING_CAP = Number(process.env.COMMHUB_LOG_RING || 500);
|
|
64
|
+
type LogEntry = { ts: string; level: "log" | "info" | "warn" | "error"; line: string };
|
|
65
|
+
const logRing: LogEntry[] = [];
|
|
66
|
+
const _origConsole = { log: console.log.bind(console), info: console.info.bind(console), warn: console.warn.bind(console), error: console.error.bind(console) };
|
|
67
|
+
function pushLog(level: LogEntry["level"], args: any[]) {
|
|
68
|
+
const line = args.map(a => typeof a === "string" ? a : (() => { try { return JSON.stringify(a); } catch { return String(a); } })()).join(" ");
|
|
69
|
+
logRing.push({ ts: new Date().toISOString(), level, line: line.slice(0, 4000) });
|
|
70
|
+
if (logRing.length > LOG_RING_CAP) logRing.splice(0, logRing.length - LOG_RING_CAP);
|
|
71
|
+
}
|
|
72
|
+
console.log = (...args: any[]) => { pushLog("log", args); _origConsole.log(...args); };
|
|
73
|
+
console.info = (...args: any[]) => { pushLog("info", args); _origConsole.info(...args); };
|
|
74
|
+
console.warn = (...args: any[]) => { pushLog("warn", args); _origConsole.warn(...args); };
|
|
75
|
+
console.error = (...args: any[]) => { pushLog("error", args); _origConsole.error(...args); };
|
|
76
|
+
|
|
77
|
+
function normalizeMetaJson(meta: unknown): string | null {
|
|
78
|
+
if (!meta || typeof meta !== "object") return null;
|
|
79
|
+
// #222 cross-host safety — same sanitization as tools.ts so REST
|
|
80
|
+
// /api/task and MCP send_task produce identical meta_json shapes.
|
|
81
|
+
// Conditional: only strips `path` when `file_id` is also present
|
|
82
|
+
// (preserves single-host feishu fallback per team rule — assume-unit-before-threshold: cap is in bytes
|
|
83
|
+
// discipline of not breaking other call sites).
|
|
84
|
+
try { return JSON.stringify(stripHostLocalPathsForCrossHostSafe(meta)); } catch { return null; }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Rate limiter (in-memory, per IP) ──
|
|
88
|
+
const rateLimits = new Map<string, { count: number; resetAt: number }>();
|
|
89
|
+
function checkRateLimit(ip: string, maxPerMinute = 60): boolean {
|
|
90
|
+
// Skip rate limiting for localhost/internal/unknown (dev/test)
|
|
91
|
+
if (!ip || ip === "unknown" || ip === "127.0.0.1" || ip === "::1") return true;
|
|
92
|
+
const now = Date.now();
|
|
93
|
+
const entry = rateLimits.get(ip);
|
|
94
|
+
if (!entry || now > entry.resetAt) {
|
|
95
|
+
rateLimits.set(ip, { count: 1, resetAt: now + 60000 });
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
if (entry.count >= maxPerMinute) return false;
|
|
99
|
+
entry.count++;
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
// Cleanup stale entries every 5 minutes. Interval is registered in
|
|
103
|
+
// startHub() (#476) — module scope must stay timer-free so importing
|
|
104
|
+
// this module neither holds the event loop open nor does periodic work.
|
|
105
|
+
function sweepStaleRateLimits(): void {
|
|
106
|
+
const now = Date.now();
|
|
107
|
+
for (const [ip, entry] of rateLimits) {
|
|
108
|
+
if (now > entry.resetAt) rateLimits.delete(ip);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Factory: 每个请求创建新的 McpServer(stateless 模式)──
|
|
113
|
+
function createServer(clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null, callerTokenIsNetwork = false, callerTokenId?: string | null): McpServer {
|
|
114
|
+
const server = new McpServer({
|
|
115
|
+
name: "commhub",
|
|
116
|
+
version: "0.5.0",
|
|
117
|
+
});
|
|
118
|
+
registerTools(server, clientIP, enforceNetworkId, enforceUserId, callerAlias, callerTokenIsNetwork, callerTokenId);
|
|
119
|
+
return server;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Auth helper ─────────────────────────────────────
|
|
123
|
+
function requestToken(req: Request): string {
|
|
124
|
+
const header = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
125
|
+
const url = new URL(req.url);
|
|
126
|
+
return header || url.searchParams.get("token") || "";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isLegacyAuthToken(req: Request): boolean {
|
|
130
|
+
const token = requestToken(req);
|
|
131
|
+
return !!AUTH_TOKEN && token === AUTH_TOKEN;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function requireAuth(req: Request): Response | null {
|
|
135
|
+
const token = requestToken(req);
|
|
136
|
+
|
|
137
|
+
// V3: check api_tokens first
|
|
138
|
+
if (token) {
|
|
139
|
+
const resolved = resolveToken(token);
|
|
140
|
+
if (resolved) return null; // valid user token
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Legacy: check global COMMHUB_AUTH_TOKEN
|
|
144
|
+
if (!AUTH_TOKEN && DEV_OPEN) return null; // explicit local/dev open mode
|
|
145
|
+
if (token === AUTH_TOKEN) {
|
|
146
|
+
const u = new URL(req.url);
|
|
147
|
+
const readOnlyApi = u.pathname.startsWith("/api/") && (req.method === "GET" || req.method === "HEAD" || req.method === "OPTIONS");
|
|
148
|
+
if (!readOnlyApi) return Response.json({ ok: false, error: "master-token auth is deprecated; use admin utok_" }, { status: 401 });
|
|
149
|
+
if (!masterTokenDeprecationLogged) {
|
|
150
|
+
console.warn("[commhub] master-token auth is deprecated and will be removed in v1.0. See RFC-001.");
|
|
151
|
+
masterTokenDeprecationLogged = true;
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return Response.json({ error: "unauthorized" }, { status: 401 });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function getClientIP(req: Request, server?: any): string {
|
|
160
|
+
const direct = server?.requestIP?.(req)?.address;
|
|
161
|
+
if (direct) return direct;
|
|
162
|
+
const fwd = req.headers.get("x-forwarded-for");
|
|
163
|
+
return fwd ? fwd.split(",")[0].trim() : (req.headers.get("x-real-ip") ?? "unknown");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function isLocalhostIP(ip: string): boolean {
|
|
167
|
+
return ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1" || ip === "localhost";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Normalize the raw `agent` field into a canonical runtime identifier for the
|
|
171
|
+
// dashboard's Runtime badge. Returns null for unknown/absent agents so the
|
|
172
|
+
// frontend can fall back to a placeholder.
|
|
173
|
+
function normalizeRuntime(agent: unknown): string | null {
|
|
174
|
+
if (typeof agent !== "string" || agent.length === 0) return null;
|
|
175
|
+
if (agent === "claude-code") return "claude-code-cli";
|
|
176
|
+
if (agent.startsWith("agent-node:codex")) return "codex-sdk";
|
|
177
|
+
if (agent.startsWith("agent-node:claude")) return "claude-agent-sdk";
|
|
178
|
+
if (agent.startsWith("agent-node:grok")) return "grok-build-acp";
|
|
179
|
+
// RFC-029 — agent-node reports `agent-node:opencode` when RUNTIME
|
|
180
|
+
// bucket resolves to "opencode". Dashboard needs the canonical
|
|
181
|
+
// launcher name so its Runtime badge matches wizard choices.
|
|
182
|
+
if (agent.startsWith("agent-node:opencode")) return "opencode-cli";
|
|
183
|
+
if (agent === "http-api" || agent === "http" || agent === "api") return "http-api";
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function isTmuxAllowedIP(ip: string): boolean {
|
|
188
|
+
return isLocalhostIP(ip) || TMUX_ALLOWLIST.has(ip);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function requireAdminAuth(req: Request): Response | null {
|
|
192
|
+
const token = requestToken(req);
|
|
193
|
+
if (!token) return Response.json({ ok: false, error: "auth required" }, { status: 401 });
|
|
194
|
+
const resolved = resolveToken(token);
|
|
195
|
+
if (!resolved) return Response.json({ ok: false, error: "invalid token" }, { status: 401 });
|
|
196
|
+
if (resolved.user.role !== "admin") return Response.json({ ok: false, error: "admin required" }, { status: 403 });
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function requireTmuxAccess(req: Request, server?: any): Response | null {
|
|
201
|
+
if (!TMUX_ENABLED) return Response.json({ ok: false, error: "tmux disabled" }, { status: 404 });
|
|
202
|
+
const ip = getClientIP(req, server);
|
|
203
|
+
if (!isTmuxAllowedIP(ip)) return Response.json({ ok: false, error: "tmux access denied from this ip" }, { status: 403 });
|
|
204
|
+
return requireAdminAuth(req);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Extract user + network + token-binding identity from request token.
|
|
208
|
+
function resolveRequestAuth(req: Request): { userId: string; networkId: string | null; username: string; tokenName: string | null; tokenId: string | null } | null {
|
|
209
|
+
const token = requestToken(req);
|
|
210
|
+
if (!token) return null;
|
|
211
|
+
const resolved = resolveToken(token);
|
|
212
|
+
if (!resolved) return null;
|
|
213
|
+
return { userId: resolved.user.user_id, networkId: resolved.networkId, username: resolved.user.username, tokenName: resolved.tokenName, tokenId: resolved.tokenId };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
type RestNetworkScope = {
|
|
217
|
+
networkId: string | null;
|
|
218
|
+
networkIds: string[] | null;
|
|
219
|
+
denied?: string;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
function getUserNetworkIds(userId: string): string[] {
|
|
223
|
+
return db.all<{ network_id: string }>(
|
|
224
|
+
"SELECT network_id FROM network_members WHERE user_id = ?1",
|
|
225
|
+
userId
|
|
226
|
+
).map((row) => row.network_id);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function resolveRestNetworkScope(url: URL, authCtx: { userId: string; networkId: string | null } | null, isAdmin: boolean): RestNetworkScope {
|
|
230
|
+
const requested = url.searchParams.get("network_id");
|
|
231
|
+
|
|
232
|
+
// Legacy global token or open dev mode keeps the old global behavior.
|
|
233
|
+
if (!authCtx) return { networkId: requested || null, networkIds: null };
|
|
234
|
+
|
|
235
|
+
// Network tokens are forcibly scoped to their bound network.
|
|
236
|
+
if (authCtx.networkId) return { networkId: authCtx.networkId, networkIds: null };
|
|
237
|
+
|
|
238
|
+
// System admins may intentionally inspect all networks.
|
|
239
|
+
if (isAdmin) return { networkId: requested || null, networkIds: null };
|
|
240
|
+
|
|
241
|
+
if (requested) {
|
|
242
|
+
const role = getUserNetworkRole(authCtx.userId, requested);
|
|
243
|
+
if (!role) return { networkId: null, networkIds: [], denied: "access denied to requested network" };
|
|
244
|
+
return { networkId: requested, networkIds: null };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return { networkId: null, networkIds: getUserNetworkIds(authCtx.userId) };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function addNetworkScope(sql: string, params: any[], scope: RestNetworkScope, column = "network_id"): string {
|
|
251
|
+
if (scope.networkId) {
|
|
252
|
+
sql += ` AND ${column} = ?${params.length + 1}`;
|
|
253
|
+
params.push(scope.networkId);
|
|
254
|
+
} else if (scope.networkIds) {
|
|
255
|
+
if (scope.networkIds.length === 0) {
|
|
256
|
+
sql += " AND 1=0";
|
|
257
|
+
} else {
|
|
258
|
+
const placeholders = scope.networkIds.map((_, i) => `?${params.length + i + 1}`).join(", ");
|
|
259
|
+
sql += ` AND ${column} IN (${placeholders})`;
|
|
260
|
+
params.push(...scope.networkIds);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return sql;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function singleNetworkId(scope: RestNetworkScope): string | null {
|
|
267
|
+
if (scope.networkId) return scope.networkId;
|
|
268
|
+
if (scope.networkIds?.length === 1) return scope.networkIds[0];
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function sqliteTime(date: Date): string {
|
|
273
|
+
return date.toISOString().replace("T", " ").slice(0, 19);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function parseSqliteTime(value: string | null | undefined): number {
|
|
277
|
+
if (!value) return 0;
|
|
278
|
+
const normalized = value.includes("T") ? value : `${value.replace(" ", "T")}Z`;
|
|
279
|
+
const ts = Date.parse(normalized);
|
|
280
|
+
return Number.isFinite(ts) ? ts : 0;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function cpuPct(load: number | null | undefined, cores: number | null | undefined): number | null {
|
|
284
|
+
if (typeof load !== "number" || typeof cores !== "number" || cores <= 0) return null;
|
|
285
|
+
return Math.round((load / cores) * 1000) / 10;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function serverAlertLevel(row: any): { level: "green" | "yellow" | "red"; alerts: string[] } {
|
|
289
|
+
const alerts: string[] = [];
|
|
290
|
+
const pct = cpuPct(row?.cpu_load_1min, row?.cpu_cores);
|
|
291
|
+
if (pct !== null && pct >= 80) alerts.push(`cpu ${pct}%`);
|
|
292
|
+
if (typeof row?.mem_avail_gb === "number" && row.mem_avail_gb < 0.5) alerts.push(`memory ${row.mem_avail_gb}GB available`);
|
|
293
|
+
if (typeof row?.disk_avail_gb === "number" && row.disk_avail_gb < 1) alerts.push(`disk ${row.disk_avail_gb}GB available`);
|
|
294
|
+
if (alerts.length > 0) return { level: "red", alerts };
|
|
295
|
+
|
|
296
|
+
if (pct !== null && pct >= 60) alerts.push(`cpu ${pct}%`);
|
|
297
|
+
if (typeof row?.mem_avail_gb === "number" && row.mem_avail_gb < 1) alerts.push(`memory ${row.mem_avail_gb}GB available`);
|
|
298
|
+
if (typeof row?.disk_avail_gb === "number" && row.disk_avail_gb < 5) alerts.push(`disk ${row.disk_avail_gb}GB available`);
|
|
299
|
+
return { level: alerts.length > 0 ? "yellow" : "green", alerts };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function agentHealthChip(status: unknown, lastSeen: string | null | undefined): "online" | "offline" | "stale" {
|
|
303
|
+
if (String(status || "").toLowerCase() === "offline") return "offline";
|
|
304
|
+
const ts = parseSqliteTime(lastSeen);
|
|
305
|
+
if (!ts || Date.now() - ts > 5 * 60 * 1000) return "stale";
|
|
306
|
+
return "online";
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function bucketTelemetry(rows: any[], fromMs: number, bucketMs: number) {
|
|
310
|
+
const buckets = new Map<number, {
|
|
311
|
+
ts: number;
|
|
312
|
+
count: number;
|
|
313
|
+
cpu_pct_sum: number;
|
|
314
|
+
cpu_pct_count: number;
|
|
315
|
+
cpu_load_sum: number;
|
|
316
|
+
cpu_load_count: number;
|
|
317
|
+
mem_avail_min: number | null;
|
|
318
|
+
mem_used_max: number | null;
|
|
319
|
+
disk_avail_min: number | null;
|
|
320
|
+
disk_used_max: number | null;
|
|
321
|
+
}>();
|
|
322
|
+
|
|
323
|
+
for (const row of rows) {
|
|
324
|
+
const ts = parseSqliteTime(row.created_at);
|
|
325
|
+
if (!ts || ts < fromMs) continue;
|
|
326
|
+
const bucketTs = Math.floor(ts / bucketMs) * bucketMs;
|
|
327
|
+
const bucket = buckets.get(bucketTs) ?? {
|
|
328
|
+
ts: bucketTs,
|
|
329
|
+
count: 0,
|
|
330
|
+
cpu_pct_sum: 0,
|
|
331
|
+
cpu_pct_count: 0,
|
|
332
|
+
cpu_load_sum: 0,
|
|
333
|
+
cpu_load_count: 0,
|
|
334
|
+
mem_avail_min: null,
|
|
335
|
+
mem_used_max: null,
|
|
336
|
+
disk_avail_min: null,
|
|
337
|
+
disk_used_max: null,
|
|
338
|
+
};
|
|
339
|
+
bucket.count += 1;
|
|
340
|
+
const pct = cpuPct(row.cpu_load_1min, row.cpu_cores);
|
|
341
|
+
if (pct !== null) {
|
|
342
|
+
bucket.cpu_pct_sum += pct;
|
|
343
|
+
bucket.cpu_pct_count += 1;
|
|
344
|
+
}
|
|
345
|
+
if (typeof row.cpu_load_1min === "number") {
|
|
346
|
+
bucket.cpu_load_sum += row.cpu_load_1min;
|
|
347
|
+
bucket.cpu_load_count += 1;
|
|
348
|
+
}
|
|
349
|
+
if (typeof row.mem_avail_gb === "number") bucket.mem_avail_min = bucket.mem_avail_min === null ? row.mem_avail_gb : Math.min(bucket.mem_avail_min, row.mem_avail_gb);
|
|
350
|
+
if (typeof row.mem_used_gb === "number") bucket.mem_used_max = bucket.mem_used_max === null ? row.mem_used_gb : Math.max(bucket.mem_used_max, row.mem_used_gb);
|
|
351
|
+
if (typeof row.disk_avail_gb === "number") bucket.disk_avail_min = bucket.disk_avail_min === null ? row.disk_avail_gb : Math.min(bucket.disk_avail_min, row.disk_avail_gb);
|
|
352
|
+
if (typeof row.disk_used_gb === "number") bucket.disk_used_max = bucket.disk_used_max === null ? row.disk_used_gb : Math.max(bucket.disk_used_max, row.disk_used_gb);
|
|
353
|
+
buckets.set(bucketTs, bucket);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return Array.from(buckets.values())
|
|
357
|
+
.sort((a, b) => a.ts - b.ts)
|
|
358
|
+
.map((b) => ({
|
|
359
|
+
ts: new Date(b.ts).toISOString(),
|
|
360
|
+
count: b.count,
|
|
361
|
+
cpu_pct: b.cpu_pct_count ? Math.round((b.cpu_pct_sum / b.cpu_pct_count) * 10) / 10 : null,
|
|
362
|
+
cpu_load_1min: b.cpu_load_count ? Math.round((b.cpu_load_sum / b.cpu_load_count) * 100) / 100 : null,
|
|
363
|
+
mem_avail_gb: b.mem_avail_min,
|
|
364
|
+
mem_used_gb: b.mem_used_max,
|
|
365
|
+
disk_avail_gb: b.disk_avail_min,
|
|
366
|
+
disk_used_gb: b.disk_used_max,
|
|
367
|
+
}));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function canRestWriteNetwork(authCtx: { userId: string; networkId: string | null } | null, networkId: string | null, isAdmin: boolean): boolean {
|
|
371
|
+
if (!authCtx) return true; // legacy global token or open dev mode
|
|
372
|
+
if (isAdmin) return true;
|
|
373
|
+
if (!networkId) return false;
|
|
374
|
+
const role = getUserNetworkRole(authCtx.userId, networkId);
|
|
375
|
+
return !!role && role !== "viewer";
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
type RestDeliveryTarget =
|
|
379
|
+
| { state: "online"; alias: string; session: any }
|
|
380
|
+
| { state: "offline"; alias: string; session: any; message: string }
|
|
381
|
+
| { state: "not_found"; alias: string; message: string };
|
|
382
|
+
|
|
383
|
+
function resolveRestDeliveryTarget(alias: string, networkId: string | null): RestDeliveryTarget {
|
|
384
|
+
const params: any[] = [alias];
|
|
385
|
+
let sql = "SELECT status, updated_at, last_seen_at, node_id FROM sessions WHERE alias = ?1";
|
|
386
|
+
if (networkId) {
|
|
387
|
+
sql += " AND network_id = ?2";
|
|
388
|
+
params.push(networkId);
|
|
389
|
+
}
|
|
390
|
+
const session = db.get<any>(sql, ...params);
|
|
391
|
+
if (!session) {
|
|
392
|
+
return { state: "not_found", alias, message: `alias not found: ${alias}` };
|
|
393
|
+
}
|
|
394
|
+
const lastSeen = session.last_seen_at || session.updated_at;
|
|
395
|
+
const lastSeenAt = lastSeen ? new Date(String(lastSeen).replace(" ", "T") + "Z").getTime() : 0;
|
|
396
|
+
const stale = !lastSeenAt || Date.now() - lastSeenAt > 5 * 60 * 1000;
|
|
397
|
+
if (String(session.status || "").toLowerCase() === "offline" || stale) {
|
|
398
|
+
return {
|
|
399
|
+
state: "offline",
|
|
400
|
+
alias,
|
|
401
|
+
session,
|
|
402
|
+
message: `alias is offline; task queued in inbox: ${alias}`,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
return { state: "online", alias, session };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function resolveRestNodeIdForAlias(alias: string, networkId: string | null): string | null {
|
|
409
|
+
if (!alias || alias === "hub" || alias === "api") return null;
|
|
410
|
+
const canonical = resolveCanonicalAlias(networkId, alias);
|
|
411
|
+
const target = resolveRestDeliveryTarget(canonical.alias, networkId);
|
|
412
|
+
return target.state === "not_found" ? null : (target.session?.node_id ?? null);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ── REST input schema ───────────────────────────────
|
|
416
|
+
const TaskSchema = z.object({
|
|
417
|
+
alias: z.string().min(1).max(200),
|
|
418
|
+
task: z.string().min(1).max(10000),
|
|
419
|
+
priority: z.enum(["high", "normal", "low"]).default("normal"),
|
|
420
|
+
from: z.string().max(200).optional(),
|
|
421
|
+
network_id: z.string().max(200).optional(),
|
|
422
|
+
parent_task_id: z.string().max(200).optional(),
|
|
423
|
+
ttl_seconds: z.number().min(1).max(86400).optional(),
|
|
424
|
+
// #221 — top-level attachments mirror the MCP send_task tool. They are
|
|
425
|
+
// merged into `meta.attachments` for persistence so the on-disk shape
|
|
426
|
+
// stays unified regardless of transport (REST or MCP).
|
|
427
|
+
attachments: z.any().optional(),
|
|
428
|
+
meta: z.any().optional(),
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
const BroadcastSchema = z.object({
|
|
432
|
+
message: z.string().min(1).max(10000),
|
|
433
|
+
filter_server: z.string().max(200).optional(),
|
|
434
|
+
filter_status: z.string().max(50).optional(),
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
// ── HTTP Server (Bun native) ────────────────────────
|
|
438
|
+
const CORS_ORIGINS = process.env.COMMHUB_CORS_ORIGINS
|
|
439
|
+
? process.env.COMMHUB_CORS_ORIGINS.split(",").map((s) => s.trim())
|
|
440
|
+
: ["http://localhost:3000", "http://localhost:3001"];
|
|
441
|
+
|
|
442
|
+
function corsHeaders(req: Request): Record<string, string> {
|
|
443
|
+
const origin = req.headers.get("Origin") || "";
|
|
444
|
+
// CORS allowlist is driven entirely by COMMHUB_CORS_ORIGINS env (comma-separated).
|
|
445
|
+
// Default (env unset) allows localhost dev origins only — see CORS_ORIGINS above.
|
|
446
|
+
// No author-specific domains are hardcoded; production deployments must set
|
|
447
|
+
// COMMHUB_CORS_ORIGINS explicitly. See docs/concepts/security.md "CORS 配置".
|
|
448
|
+
const allowed = CORS_ORIGINS.includes(origin) ? origin : "";
|
|
449
|
+
return {
|
|
450
|
+
"Access-Control-Allow-Origin": allowed,
|
|
451
|
+
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
|
452
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
453
|
+
"Access-Control-Max-Age": "86400",
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function withCors(req: Request, res: Response): Response {
|
|
458
|
+
const headers = corsHeaders(req);
|
|
459
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
460
|
+
res.headers.set(k, v);
|
|
461
|
+
}
|
|
462
|
+
return res;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* #426 — append `charset=utf-8` to text-y Content-Type headers that
|
|
467
|
+
* omit it. Legacy Windows clients (PowerShell 5.1 Invoke-RestMethod /
|
|
468
|
+
* Invoke-WebRequest) default to ISO-8859-1 when the charset is
|
|
469
|
+
* unspecified, which double-encodes our clean UTF-8 payloads on the
|
|
470
|
+
* way in.
|
|
471
|
+
*
|
|
472
|
+
* Applied to Responses whose Content-Type header we can't influence at
|
|
473
|
+
* construction time — notably the MCP SDK transport, which builds its
|
|
474
|
+
* own `text/event-stream` / `application/json` reply. Response headers
|
|
475
|
+
* from `fetch` are mutable, so this rewrites in place rather than
|
|
476
|
+
* reconstructing the Response (which would break streamed bodies).
|
|
477
|
+
*
|
|
478
|
+
* No-ops when: charset is already set, the type is binary
|
|
479
|
+
* (application/octet-stream, image/*, video/*, application/pdf, etc.),
|
|
480
|
+
* or the header is missing entirely.
|
|
481
|
+
*/
|
|
482
|
+
function withUtf8CharsetContentType(res: Response): Response {
|
|
483
|
+
const ct = res.headers.get("content-type");
|
|
484
|
+
if (!ct) return res;
|
|
485
|
+
if (/;\s*charset=/i.test(ct)) return res;
|
|
486
|
+
const trimmed = ct.trim();
|
|
487
|
+
// Text-y types that legacy clients decode via charset. Everything
|
|
488
|
+
// else (binary, opaque) is left alone.
|
|
489
|
+
if (
|
|
490
|
+
trimmed === "application/json" ||
|
|
491
|
+
trimmed.startsWith("text/") ||
|
|
492
|
+
trimmed.startsWith("application/json;") ||
|
|
493
|
+
trimmed === "application/ld+json" ||
|
|
494
|
+
trimmed === "application/xml"
|
|
495
|
+
) {
|
|
496
|
+
res.headers.set("content-type", `${trimmed}; charset=utf-8`);
|
|
497
|
+
}
|
|
498
|
+
return res;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// ── WebSocket tmux sessions ────────────────────────
|
|
502
|
+
const wsTmuxIntervals = new Map<object, ReturnType<typeof setInterval>>();
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
// ── Task expiration patrol (every 5 minutes) ──
|
|
506
|
+
// Registered in startHub() (#476): this WRITES the DB, so it must never
|
|
507
|
+
// run as an import side effect — a tool/test importing this module with
|
|
508
|
+
// COMMHUB_DB unset would patrol the production database.
|
|
509
|
+
function patrolExpiredTasks(): void {
|
|
510
|
+
try {
|
|
511
|
+
const result = db.run(
|
|
512
|
+
`UPDATE tasks SET status = 'expired', completed_at = datetime('now')
|
|
513
|
+
WHERE expires_at IS NOT NULL AND expires_at < datetime('now')
|
|
514
|
+
AND status IN ('created', 'delivered')`
|
|
515
|
+
);
|
|
516
|
+
if (result.changes > 0) {
|
|
517
|
+
console.log(`[patrol] expired ${result.changes} stale task(s)`);
|
|
518
|
+
// Log events for expired tasks
|
|
519
|
+
const expired = db.all<{ task_id: string }>(
|
|
520
|
+
"SELECT task_id FROM tasks WHERE status = 'expired' AND completed_at >= datetime('now', '-1 minute')");
|
|
521
|
+
for (const t of expired) logTaskEvent(t.task_id, null, "expired", "patrol");
|
|
522
|
+
}
|
|
523
|
+
} catch {}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// #434 — test-safety seam, same signature as PR #438 so the two branches
|
|
527
|
+
// merge cleanly. Wraps the sole Bun.serve config in a factory so
|
|
528
|
+
// integration tests can request an OS-assigned ephemeral port
|
|
529
|
+
// (`bootServer({ port: 0 })`) and read the actual bound port back from
|
|
530
|
+
// the returned server. In an aggregate `bun test` run only the FIRST test
|
|
531
|
+
// file's import boots the default server (module cache) — later files'
|
|
532
|
+
// PORT env is ignored, so a suite that hard-codes its own port gets
|
|
533
|
+
// ConnectionRefused on every fetch while still "running" (审查修复 per
|
|
534
|
+
// 通信龙 #461 review, finding 2). Suites boot a private instance via this
|
|
535
|
+
// seam instead. #438 additionally moves the default boot below under
|
|
536
|
+
// `import.meta.main`; until that lands, import keeps booting (pre-#434
|
|
537
|
+
// behavior) so the not-yet-migrated suites stay green.
|
|
538
|
+
//
|
|
539
|
+
// Note: `opts.port ?? PORT` uses nullish-coalescing on purpose — `||`
|
|
540
|
+
// would swallow a legitimate `0`. Same for hostname.
|
|
541
|
+
export function bootServer(opts?: { port?: number; hostname?: string }): ReturnType<typeof Bun.serve> {
|
|
542
|
+
return Bun.serve({
|
|
543
|
+
port: opts?.port ?? PORT,
|
|
544
|
+
hostname: opts?.hostname ?? HOST,
|
|
545
|
+
idleTimeout: 255, // max value: keep SSE connections alive (seconds)
|
|
546
|
+
// #221 — defense-in-depth cap on the raw request body. The /api/upload
|
|
547
|
+
// handler also pre-checks Content-Length and post-checks parsed size
|
|
548
|
+
// against the documented 12 MiB cap (MAX_UPLOAD_BYTES); this knob
|
|
549
|
+
// simply guarantees Bun's own buffer won't grow past 13 MiB on any
|
|
550
|
+
// route. Default Bun ceiling is 128 MiB which is too permissive for
|
|
551
|
+
// a hub now exposed to the public internet.
|
|
552
|
+
maxRequestBodySize: MAX_REQUEST_CONTENT_LENGTH,
|
|
553
|
+
|
|
554
|
+
async fetch(req, server) {
|
|
555
|
+
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
556
|
+
|
|
557
|
+
// ── CORS preflight ──
|
|
558
|
+
if (req.method === "OPTIONS") {
|
|
559
|
+
return new Response(null, { status: 204, headers: corsHeaders(req) });
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ── WebSocket: tmux terminal ──
|
|
563
|
+
const wsMatch = url.pathname.match(/^\/ws\/tmux\/([a-zA-Z0-9_-]+)$/);
|
|
564
|
+
if (wsMatch) {
|
|
565
|
+
const tmuxErr = requireTmuxAccess(req, server);
|
|
566
|
+
if (tmuxErr) return withCors(req, tmuxErr);
|
|
567
|
+
if (server.upgrade(req, { data: { tmuxName: wsMatch[1] } } as any)) return;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ── MCP Streamable HTTP endpoint ──
|
|
571
|
+
if (url.pathname === "/mcp") {
|
|
572
|
+
const authErr = requireAuth(req);
|
|
573
|
+
if (authErr) return withCors(req, authErr);
|
|
574
|
+
const clientIP = getClientIP(req, server);
|
|
575
|
+
// V3: resolve token → enforce network_id in all MCP tools.
|
|
576
|
+
// utok_ (user token, not network-bound) is allowed — the tool layer
|
|
577
|
+
// scopes to the user's accessible networks. Without this Dashboard
|
|
578
|
+
// (which logs in as a user) cannot call send_task.
|
|
579
|
+
const token = requestToken(req);
|
|
580
|
+
const authCtx = resolveRequestAuth(req);
|
|
581
|
+
const enforceNetId = authCtx?.networkId || null;
|
|
582
|
+
// Derive the calling alias from the token name (e.g., 'node:视频审查')
|
|
583
|
+
// so peer agents see the real sender instead of 'hub' on send_task.
|
|
584
|
+
const callerAlias = authCtx?.tokenName?.startsWith("node:")
|
|
585
|
+
? authCtx.tokenName.slice("node:".length)
|
|
586
|
+
: (authCtx?.username || null);
|
|
587
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
588
|
+
sessionIdGenerator: undefined,
|
|
589
|
+
});
|
|
590
|
+
const mcpServer = createServer(clientIP, enforceNetId, authCtx?.userId || null, callerAlias, !!token?.startsWith("ntok_"), authCtx?.tokenId || null);
|
|
591
|
+
await mcpServer.connect(transport);
|
|
592
|
+
const response = await transport.handleRequest(req);
|
|
593
|
+
// Disconnect after response to prevent McpServer leak
|
|
594
|
+
setImmediate(() => mcpServer.close().catch(() => {}));
|
|
595
|
+
// #426: the MCP SDK's WebStandardStreamableHTTPServerTransport builds
|
|
596
|
+
// its own Response with `Content-Type: text/event-stream` (or
|
|
597
|
+
// `application/json` for JSON-mode) but WITHOUT charset. Legacy
|
|
598
|
+
// Windows clients (PowerShell 5.1 Invoke-WebRequest / RestMethod)
|
|
599
|
+
// default to ISO-8859-1 when the charset is unspecified and 双重-
|
|
600
|
+
// 编码 our clean UTF-8 payload on the way in — the reported
|
|
601
|
+
// mojibake is client-side, but the header is what tells them to
|
|
602
|
+
// guess. We can't tell the SDK to add it, so wrap the response and
|
|
603
|
+
// rewrite the header. Streamed bodies (SSE) pass through untouched
|
|
604
|
+
// because Response takes the original ReadableStream verbatim.
|
|
605
|
+
return withUtf8CharsetContentType(response);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// ── #461 SSE network observer stream ──
|
|
609
|
+
// GET /events/network/:network_id → 网络级摘要事件(new_task / new_reply
|
|
610
|
+
// routing metadata only, no content)。Dashboard 用它实时感知第三方流量,
|
|
611
|
+
// 退役 15s 软轮询。MUST be matched before the generic /events/(.+) route
|
|
612
|
+
// below, which would otherwise swallow the path as a session name.
|
|
613
|
+
//
|
|
614
|
+
// Auth mirrors /events/:session's three paths:
|
|
615
|
+
// 1. legacy AUTH_TOKEN (master) → any network
|
|
616
|
+
// 2/3. ntok_ / utok_ → must CURRENTLY be a member of the requested
|
|
617
|
+
// network. Membership is checked unconditionally — there is NO
|
|
618
|
+
// token-bound-network shortcut. removeNetworkMember deletes the
|
|
619
|
+
// membership row but does NOT revoke the user's ntok, so the
|
|
620
|
+
// network_members lookup IS the revocation mechanism: an ntok
|
|
621
|
+
// bound to this network whose owner was removed must lose the
|
|
622
|
+
// stream (审查修复 per 通信龙 #461 review, finding 1).
|
|
623
|
+
const netEventsMatch = url.pathname.match(/^\/events\/network\/(.+)$/);
|
|
624
|
+
if (netEventsMatch && req.method === "GET") {
|
|
625
|
+
const authErr = requireAuth(req);
|
|
626
|
+
if (authErr) return authErr;
|
|
627
|
+
const observedNetId = decodeURIComponent(netEventsMatch[1]);
|
|
628
|
+
const authCtx = resolveRequestAuth(req);
|
|
629
|
+
if (!authCtx && isLegacyAuthToken(req)) {
|
|
630
|
+
return createNetworkObserverStream(observedNetId);
|
|
631
|
+
}
|
|
632
|
+
if (!authCtx) {
|
|
633
|
+
return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 403 }));
|
|
634
|
+
}
|
|
635
|
+
const observerRole = getUserNetworkRole(authCtx.userId, observedNetId);
|
|
636
|
+
if (!observerRole) {
|
|
637
|
+
return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
|
|
638
|
+
}
|
|
639
|
+
return createNetworkObserverStream(observedNetId);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// ── SSE push: Agent 实时接收任务推送 ──
|
|
643
|
+
// GET /events/知识哥 → 保持长连接,send_task 时秒推
|
|
644
|
+
const eventsMatch = url.pathname.match(/^\/events\/(.+)$/);
|
|
645
|
+
if (eventsMatch && req.method === "GET") {
|
|
646
|
+
const authErr = requireAuth(req);
|
|
647
|
+
if (authErr) return authErr;
|
|
648
|
+
const sessionName = decodeURIComponent(eventsMatch[1]);
|
|
649
|
+
const token = requestToken(req);
|
|
650
|
+
const authCtx = resolveRequestAuth(req);
|
|
651
|
+
const scopedNetId = authCtx?.networkId || url.searchParams.get("network_id");
|
|
652
|
+
|
|
653
|
+
// ── Path 1: legacy AUTH_TOKEN (master token) — unchanged ──
|
|
654
|
+
if (!authCtx && isLegacyAuthToken(req)) {
|
|
655
|
+
if (scopedNetId) {
|
|
656
|
+
const session = db.get<any>(
|
|
657
|
+
"SELECT 1 FROM sessions WHERE alias = ?1 AND network_id = ?2",
|
|
658
|
+
sessionName, scopedNetId
|
|
659
|
+
);
|
|
660
|
+
if (!session) return withCors(req, Response.json({ ok: false, error: "session not in requested network" }, { status: 403 }));
|
|
661
|
+
}
|
|
662
|
+
return createSSEStream(sessionName, scopedNetId);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// ── Path 2: ntok_ (network-bound agent token) — pre-#247 behavior preserved ──
|
|
666
|
+
if (token?.startsWith("ntok_")) {
|
|
667
|
+
if (!authCtx || !scopedNetId) {
|
|
668
|
+
return withCors(req, Response.json({ ok: false, error: "network-scoped token required for SSE" }, { status: 403 }));
|
|
669
|
+
}
|
|
670
|
+
const role = getUserNetworkRole(authCtx.userId, scopedNetId);
|
|
671
|
+
if (!role) return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
|
|
672
|
+
const session = db.get<any>(
|
|
673
|
+
"SELECT 1 FROM sessions WHERE alias = ?1 AND network_id = ?2",
|
|
674
|
+
sessionName, scopedNetId
|
|
675
|
+
);
|
|
676
|
+
if (!session && authCtx.networkId !== scopedNetId) {
|
|
677
|
+
return withCors(req, Response.json({ ok: false, error: "session not in requested network" }, { status: 403 }));
|
|
678
|
+
}
|
|
679
|
+
return createSSEStream(sessionName, scopedNetId);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// ── Path 3 (#247): utok_ (user token, dashboard SSE path) — 4 gates ──
|
|
683
|
+
//
|
|
684
|
+
// Pre-#247 the only V3 path required `ntok_` and rejected `utok_` outright,
|
|
685
|
+
// which locked the dashboard out of SSE (the dashboard proxy authenticates
|
|
686
|
+
// with the user's `utok_`). That broke real-time chat updates — replies
|
|
687
|
+
// were persisted but never live-pushed to the UI.
|
|
688
|
+
//
|
|
689
|
+
// Auth-scoping rules (all four must pass — see #247 design):
|
|
690
|
+
// gate 1: token resolves to a valid user context (authCtx non-null)
|
|
691
|
+
// gate 2: network scope is explicit — `utok_` has no implicit network,
|
|
692
|
+
// so the caller must pass `?network_id=<id>`
|
|
693
|
+
// gate 3: user is a member of that network (any role: owner / admin / member)
|
|
694
|
+
// gate 4: channel-ownership — one of:
|
|
695
|
+
// (a) sessionName === own username (the dashboard's default channel —
|
|
696
|
+
// `send_reply` pushes to the original sender's alias which equals
|
|
697
|
+
// the dashboard user's username; NO `sessions` row required for
|
|
698
|
+
// this branch since the dashboard user is not a registered agent)
|
|
699
|
+
// (b) sessionName is an existing agent alias in `scopedNetId`
|
|
700
|
+
// (lets members monitor agents within their network)
|
|
701
|
+
//
|
|
702
|
+
// Security: rule 4(a) uses strict equality, so a `utok_` user cannot subscribe
|
|
703
|
+
// to another user's username channel (no cross-user eavesdrop). Rule 4(b) is
|
|
704
|
+
// scoped to `scopedNetId` + gate 3 membership, so cross-network eavesdrop is
|
|
705
|
+
// blocked too. There is intentionally NO org-admin bypass — even users with
|
|
706
|
+
// global `admin` role must be a member of the network they want to listen on.
|
|
707
|
+
if (!authCtx) {
|
|
708
|
+
return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 403 }));
|
|
709
|
+
}
|
|
710
|
+
if (!scopedNetId) {
|
|
711
|
+
return withCors(req, Response.json({
|
|
712
|
+
ok: false,
|
|
713
|
+
error: "network_id required (utok has no implicit network; pass ?network_id=<id>)"
|
|
714
|
+
}, { status: 403 }));
|
|
715
|
+
}
|
|
716
|
+
const utokRole = getUserNetworkRole(authCtx.userId, scopedNetId);
|
|
717
|
+
if (!utokRole) {
|
|
718
|
+
return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
|
|
719
|
+
}
|
|
720
|
+
if (sessionName === authCtx.username) {
|
|
721
|
+
return createSSEStream(sessionName, scopedNetId);
|
|
722
|
+
}
|
|
723
|
+
const utokSession = db.get<any>(
|
|
724
|
+
"SELECT 1 FROM sessions WHERE alias = ?1 AND network_id = ?2",
|
|
725
|
+
sessionName, scopedNetId
|
|
726
|
+
);
|
|
727
|
+
if (!utokSession) {
|
|
728
|
+
return withCors(req, Response.json({
|
|
729
|
+
ok: false,
|
|
730
|
+
error: "channel not allowed: must be your username or an existing agent in your network"
|
|
731
|
+
}, { status: 403 }));
|
|
732
|
+
}
|
|
733
|
+
return createSSEStream(sessionName, scopedNetId);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// ── V3: License endpoints ──
|
|
737
|
+
if (url.pathname === "/api/license" && req.method === "GET") {
|
|
738
|
+
const license = db.get<any>("SELECT * FROM licenses ORDER BY created_at LIMIT 1");
|
|
739
|
+
if (!license) return withCors(req, Response.json({ ok: true, status: "no_license" }));
|
|
740
|
+
const now = new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
741
|
+
const expired = license.expires_at && license.expires_at < now;
|
|
742
|
+
const daysLeft = license.expires_at
|
|
743
|
+
? Math.max(0, Math.ceil((new Date(license.expires_at).getTime() - Date.now()) / 86400000))
|
|
744
|
+
: null;
|
|
745
|
+
return withCors(req, Response.json({
|
|
746
|
+
ok: true,
|
|
747
|
+
license: { type: license.type, expires_at: license.expires_at, days_left: daysLeft, expired },
|
|
748
|
+
limits: { max_agents: license.max_agents, max_networks: license.max_networks, max_tasks_day: license.max_tasks_day },
|
|
749
|
+
}));
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (url.pathname === "/api/license/activate" && req.method === "POST") {
|
|
753
|
+
try {
|
|
754
|
+
const body = await req.json() as any;
|
|
755
|
+
const key = body.key;
|
|
756
|
+
if (!key) return withCors(req, Response.json({ ok: false, error: "key required" }, { status: 400 }));
|
|
757
|
+
// For now: accept any key starting with "anet-" as valid pro license
|
|
758
|
+
if (!key.startsWith("anet-") || key.length < 16) {
|
|
759
|
+
return withCors(req, Response.json({ ok: false, error: "invalid license key" }, { status: 400 }));
|
|
760
|
+
}
|
|
761
|
+
// Upgrade existing license or create new
|
|
762
|
+
db.run("DELETE FROM licenses");
|
|
763
|
+
const licId = `lic_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
|
764
|
+
db.run(
|
|
765
|
+
"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'))",
|
|
766
|
+
[licId, key]
|
|
767
|
+
);
|
|
768
|
+
return withCors(req, Response.json({ ok: true, type: "pro", expires_in_days: 365 }));
|
|
769
|
+
} catch (e: any) {
|
|
770
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// ── V3: Auth endpoints (public) ──
|
|
775
|
+
if (url.pathname === "/api/auth/register" && req.method === "POST") {
|
|
776
|
+
const clientIP = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
|
|
777
|
+
if (!checkRateLimit(clientIP, 30)) {
|
|
778
|
+
return withCors(req, Response.json({ ok: false, error: "too many requests, try again later" }, { status: 429 }));
|
|
779
|
+
}
|
|
780
|
+
try {
|
|
781
|
+
const body = await req.json() as any;
|
|
782
|
+
const result = register(body.username, body.password, body.email, body.display_name);
|
|
783
|
+
if (result.ok) logAudit(result.user!.user_id, body.username, "register", "user", result.user!.user_id);
|
|
784
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
785
|
+
} catch (e: any) {
|
|
786
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
if (url.pathname === "/api/auth/login" && req.method === "POST") {
|
|
791
|
+
const clientIP = getLoginClientIp(req);
|
|
792
|
+
const ipRate = sharedLoginIpRateLimiter.check(clientIP || "unknown");
|
|
793
|
+
if (!ipRate.allowed) {
|
|
794
|
+
logAudit(null, null, "login_rate_limited", "auth", undefined, clientIP);
|
|
795
|
+
return withCors(req, Response.json(
|
|
796
|
+
{ ok: false, error: "rate_limited", message: "Too many login attempts. Try again later.", retry_after_ms: ipRate.retryAfterMs },
|
|
797
|
+
{ status: 429, headers: { "Retry-After": String(Math.ceil((ipRate.retryAfterMs ?? 1000) / 1000)) } }
|
|
798
|
+
));
|
|
799
|
+
}
|
|
800
|
+
try {
|
|
801
|
+
const body = await req.json() as any;
|
|
802
|
+
const lock = sharedLoginFailureLockout.check(body.username);
|
|
803
|
+
if (lock.locked) {
|
|
804
|
+
logAudit(null, body.username, "login_locked", "auth", undefined, `retry_after_ms=${lock.retryAfterMs ?? 0}`);
|
|
805
|
+
return withCors(req, Response.json(
|
|
806
|
+
{ ok: false, error: "login_locked", message: "Too many failed login attempts. Try again later.", retry_after_ms: lock.retryAfterMs },
|
|
807
|
+
{ status: 429, headers: { "Retry-After": String(Math.ceil((lock.retryAfterMs ?? 1000) / 1000)) } }
|
|
808
|
+
));
|
|
809
|
+
}
|
|
810
|
+
const result = login(body.username, body.password);
|
|
811
|
+
if (result.ok) {
|
|
812
|
+
sharedLoginFailureLockout.recordSuccess(body.username);
|
|
813
|
+
logAudit(result.user!.user_id, body.username, "login", "user", result.user!.user_id);
|
|
814
|
+
} else {
|
|
815
|
+
const failure = sharedLoginFailureLockout.recordFailure(body.username);
|
|
816
|
+
if (failure.locked) {
|
|
817
|
+
const safeUsername = String(body.username ?? "").replace(/[\r\n\t]/g, " ").slice(0, 80);
|
|
818
|
+
console.warn(`[auth] login locked for username=${safeUsername} failures=${failure.failures} retry_after_ms=${failure.lockMs}`);
|
|
819
|
+
}
|
|
820
|
+
logAudit(null, body.username, "login_failed", "user", undefined, "invalid credentials");
|
|
821
|
+
}
|
|
822
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 401 }));
|
|
823
|
+
} catch (e: any) {
|
|
824
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
if (url.pathname === "/api/auth/me" && req.method === "GET") {
|
|
829
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
830
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
831
|
+
const resolved = resolveToken(token);
|
|
832
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
833
|
+
const networks = getUserAllNetworks(resolved.user.user_id);
|
|
834
|
+
return withCors(req, Response.json({ ok: true, user: resolved.user, networks, current_network: resolved.networkId }));
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
if (url.pathname === "/api/auth/me" && req.method === "PUT") {
|
|
838
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
839
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
840
|
+
const resolved = resolveToken(token);
|
|
841
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
842
|
+
try {
|
|
843
|
+
const body = await req.json() as any;
|
|
844
|
+
const updates: string[] = [];
|
|
845
|
+
const params: any[] = [];
|
|
846
|
+
if (body.display_name) { updates.push(`display_name = ?${params.length + 1}`); params.push(body.display_name); }
|
|
847
|
+
if (body.email) { updates.push(`email = ?${params.length + 1}`); params.push(body.email); }
|
|
848
|
+
if (updates.length > 0) {
|
|
849
|
+
updates.push(`updated_at = datetime('now')`);
|
|
850
|
+
params.push(resolved.user.user_id);
|
|
851
|
+
db.run(`UPDATE users SET ${updates.join(", ")} WHERE user_id = ?${params.length}`, params);
|
|
852
|
+
}
|
|
853
|
+
// Re-fetch
|
|
854
|
+
const user = db.get<any>("SELECT user_id, username, display_name, email, role FROM users WHERE user_id = ?1", resolved.user.user_id);
|
|
855
|
+
return withCors(req, Response.json({ ok: true, user }));
|
|
856
|
+
} catch (e: any) {
|
|
857
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
if (url.pathname === "/api/auth/password" && req.method === "POST") {
|
|
862
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
863
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
864
|
+
const resolved = resolveToken(token);
|
|
865
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
866
|
+
try {
|
|
867
|
+
const body = await req.json() as any;
|
|
868
|
+
const result = changePassword(resolved.user.user_id, body.old_password, body.new_password, resolved.tokenId);
|
|
869
|
+
if (result.ok) {
|
|
870
|
+
const issued = issueUserToken(resolved.user.user_id, "password-change");
|
|
871
|
+
if (resolved.tokenId) revokeToken(resolved.user.user_id, resolved.tokenId);
|
|
872
|
+
logAudit(resolved.user.user_id, resolved.user.username, "password_changed", "user", resolved.user.user_id);
|
|
873
|
+
return withCors(req, Response.json({ ...result, token: issued.token, token_id: issued.token_id }));
|
|
874
|
+
}
|
|
875
|
+
return withCors(req, Response.json(result, { status: 400 }));
|
|
876
|
+
} catch (e: any) {
|
|
877
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// ── V3.13: Create network token for a node ──
|
|
882
|
+
if (url.pathname === "/api/auth/node-token" && req.method === "POST") {
|
|
883
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
884
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
885
|
+
const resolved = resolveToken(token);
|
|
886
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
887
|
+
try {
|
|
888
|
+
const body = await req.json() as any;
|
|
889
|
+
if (!body.network_id || !body.node_name) return withCors(req, Response.json({ ok: false, error: "network_id and node_name required" }, { status: 400 }));
|
|
890
|
+
const result = createNetworkTokenForNode(resolved.user.user_id, body.network_id, body.node_name);
|
|
891
|
+
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "node_token_created", "network", body.network_id, body.node_name);
|
|
892
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
893
|
+
} catch (e: any) {
|
|
894
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// ── #84: node rename 2PC — Server surface (RFC-010 §4) ──
|
|
899
|
+
// The CLI orchestrates the 2PC; these endpoints are the Server steps:
|
|
900
|
+
// prepare = PHASE 1 P3, commit = PHASE 2 C1, abort = PHASE 1 rollback.
|
|
901
|
+
if (url.pathname === "/api/node-rename/prepare" && req.method === "POST") {
|
|
902
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
903
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
904
|
+
const resolved = resolveToken(token);
|
|
905
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
906
|
+
try {
|
|
907
|
+
const body = await req.json() as any;
|
|
908
|
+
if (!body.network_id || !body.old_alias || !body.new_alias) {
|
|
909
|
+
return withCors(req, Response.json({ ok: false, error: "network_id, old_alias, new_alias required" }, { status: 400 }));
|
|
910
|
+
}
|
|
911
|
+
const result = prepareRename(resolved.user.user_id, body.network_id, body.old_alias, body.new_alias);
|
|
912
|
+
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "node_rename_prepared", "node", body.old_alias, body.new_alias);
|
|
913
|
+
return withCors(req, Response.json(result, { status: result.ok || result.code === "node_local_only" ? 200 : 400 }));
|
|
914
|
+
} catch (e: any) {
|
|
915
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
if (url.pathname === "/api/node-rename/commit" && req.method === "POST") {
|
|
920
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
921
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
922
|
+
const resolved = resolveToken(token);
|
|
923
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
924
|
+
try {
|
|
925
|
+
const body = await req.json() as any;
|
|
926
|
+
if (!body.txn_id) return withCors(req, Response.json({ ok: false, error: "txn_id required" }, { status: 400 }));
|
|
927
|
+
const result = commitRename(resolved.user.user_id, body.txn_id);
|
|
928
|
+
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "node_rename_committed", "node", body.txn_id);
|
|
929
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
930
|
+
} catch (e: any) {
|
|
931
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
if (url.pathname === "/api/node-rename/abort" && req.method === "POST") {
|
|
936
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
937
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
938
|
+
const resolved = resolveToken(token);
|
|
939
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
940
|
+
try {
|
|
941
|
+
const body = await req.json() as any;
|
|
942
|
+
if (!body.txn_id) return withCors(req, Response.json({ ok: false, error: "txn_id required" }, { status: 400 }));
|
|
943
|
+
const result = abortRename(resolved.user.user_id, body.txn_id);
|
|
944
|
+
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "node_rename_aborted", "node", body.txn_id);
|
|
945
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
946
|
+
} catch (e: any) {
|
|
947
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// ── V3: Token management ──
|
|
952
|
+
if (url.pathname === "/api/auth/tokens" && req.method === "GET") {
|
|
953
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
954
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
955
|
+
const resolved = resolveToken(token);
|
|
956
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
957
|
+
const tokens = listTokens(resolved.user.user_id);
|
|
958
|
+
return withCors(req, Response.json({ ok: true, tokens }));
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
if (url.pathname === "/api/auth/tokens" && req.method === "POST") {
|
|
962
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
963
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
964
|
+
const resolved = resolveToken(token);
|
|
965
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
966
|
+
try {
|
|
967
|
+
const body = await req.json() as any;
|
|
968
|
+
const result = createToken(resolved.user.user_id, body.name || "api-token", body.network_id);
|
|
969
|
+
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "token_created", "token", result.token_id);
|
|
970
|
+
return withCors(req, Response.json(result));
|
|
971
|
+
} catch (e: any) {
|
|
972
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const tokenDeleteMatch = url.pathname.match(/^\/api\/auth\/tokens\/([^/]+)$/);
|
|
977
|
+
if (tokenDeleteMatch && req.method === "DELETE") {
|
|
978
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
979
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
980
|
+
const resolved = resolveToken(token);
|
|
981
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
982
|
+
const result = revokeToken(resolved.user.user_id, tokenDeleteMatch[1]);
|
|
983
|
+
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "token_revoked", "token", tokenDeleteMatch[1]);
|
|
984
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 404 }));
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// ── V3: Network management ──
|
|
988
|
+
if (url.pathname === "/api/networks" && req.method === "GET") {
|
|
989
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
990
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
991
|
+
const resolved = resolveToken(token);
|
|
992
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
993
|
+
// V3.13: ntok_ can only see its bound network; utok_ sees all member networks
|
|
994
|
+
if (resolved.networkId) {
|
|
995
|
+
// ntok_ — only return the bound network
|
|
996
|
+
const net = db.get<any>("SELECT * FROM networks WHERE network_id = ?1", resolved.networkId);
|
|
997
|
+
return withCors(req, Response.json({ ok: true, networks: net ? [net] : [] }));
|
|
998
|
+
}
|
|
999
|
+
const networks = getUserAllNetworks(resolved.user.user_id);
|
|
1000
|
+
return withCors(req, Response.json({ ok: true, networks }));
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
if (url.pathname === "/api/networks" && req.method === "POST") {
|
|
1004
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
1005
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
1006
|
+
const resolved = resolveToken(token);
|
|
1007
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1008
|
+
try {
|
|
1009
|
+
const body = await req.json() as any;
|
|
1010
|
+
const result = createNetwork(resolved.user.user_id, body.name, body.description);
|
|
1011
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
1012
|
+
} catch (e: any) {
|
|
1013
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// ── V3.13: Network members + invites ──
|
|
1018
|
+
const membersMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/members(?:\/([^/]+))?$/);
|
|
1019
|
+
if (membersMatch) {
|
|
1020
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
1021
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1022
|
+
const resolved = resolveToken(token);
|
|
1023
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1024
|
+
const netId = membersMatch[1];
|
|
1025
|
+
const targetUid = membersMatch[2];
|
|
1026
|
+
const callerRole = getUserNetworkRole(resolved.user.user_id, netId);
|
|
1027
|
+
if (!callerRole) return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
|
|
1028
|
+
|
|
1029
|
+
if (req.method === "GET") {
|
|
1030
|
+
if (!["owner", "admin"].includes(callerRole)) return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
|
|
1031
|
+
const members = getNetworkMembers(netId);
|
|
1032
|
+
return withCors(req, Response.json({ ok: true, members }));
|
|
1033
|
+
}
|
|
1034
|
+
if (req.method === "POST") {
|
|
1035
|
+
if (!["owner", "admin"].includes(callerRole)) return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
|
|
1036
|
+
const body = await req.json() as any;
|
|
1037
|
+
const result = addNetworkMember(netId, body.user_id, body.role || "member", resolved.user.user_id);
|
|
1038
|
+
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "member_added", "network", netId, `${body.user_id} as ${body.role || "member"}`);
|
|
1039
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
1040
|
+
}
|
|
1041
|
+
if (req.method === "PUT" && targetUid) {
|
|
1042
|
+
if (callerRole !== "owner") return withCors(req, Response.json({ ok: false, error: "owner required" }, { status: 403 }));
|
|
1043
|
+
const body = await req.json() as any;
|
|
1044
|
+
const result = updateMemberRole(netId, targetUid, body.role);
|
|
1045
|
+
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "member_role_changed", "network", netId, `${targetUid} → ${body.role}`);
|
|
1046
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
1047
|
+
}
|
|
1048
|
+
if (req.method === "DELETE" && targetUid) {
|
|
1049
|
+
if (!["owner", "admin"].includes(callerRole)) return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
|
|
1050
|
+
const result = removeNetworkMember(netId, targetUid);
|
|
1051
|
+
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "member_removed", "network", netId, targetUid);
|
|
1052
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
if (url.pathname.match(/^\/api\/networks\/([^/]+)\/invite$/) && req.method === "POST") {
|
|
1057
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
1058
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1059
|
+
const resolved = resolveToken(token);
|
|
1060
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1061
|
+
const netId = url.pathname.split("/")[3];
|
|
1062
|
+
const callerRole = getUserNetworkRole(resolved.user.user_id, netId);
|
|
1063
|
+
if (!callerRole || !["owner", "admin"].includes(callerRole)) {
|
|
1064
|
+
return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
|
|
1065
|
+
}
|
|
1066
|
+
const body = await req.json() as any;
|
|
1067
|
+
const result = createInvite(netId, resolved.user.user_id, body.role || "member", body.max_uses || 1, body.expires_days);
|
|
1068
|
+
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "invite_created", "network", netId, result.invite_code);
|
|
1069
|
+
return withCors(req, Response.json(result));
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
if (url.pathname === "/api/networks/join" && req.method === "POST") {
|
|
1073
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
1074
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1075
|
+
const resolved = resolveToken(token);
|
|
1076
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1077
|
+
const body = await req.json() as any;
|
|
1078
|
+
const result = joinByInvite(body.invite_code, resolved.user.user_id);
|
|
1079
|
+
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "network_joined", "network", result.network_id, `via invite, role=${result.role}`);
|
|
1080
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// ── V3: Admin APIs (require auth) ──
|
|
1084
|
+
if (url.pathname === "/api/users" && req.method === "GET") {
|
|
1085
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
1086
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1087
|
+
const resolved = resolveToken(token);
|
|
1088
|
+
if (!resolved || resolved.user.role !== "admin") {
|
|
1089
|
+
return withCors(req, Response.json({ ok: false, error: "admin required" }, { status: 403 }));
|
|
1090
|
+
}
|
|
1091
|
+
const users = db.all("SELECT user_id, username, display_name, email, role, created_at FROM users ORDER BY created_at");
|
|
1092
|
+
return withCors(req, Response.json({ ok: true, users }));
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
const netDetailMatch = url.pathname.match(/^\/api\/networks\/([^/]+)$/);
|
|
1096
|
+
if (netDetailMatch && req.method === "GET") {
|
|
1097
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
1098
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1099
|
+
const resolved = resolveToken(token);
|
|
1100
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1101
|
+
const networkId = netDetailMatch[1];
|
|
1102
|
+
const network = db.get<any>("SELECT * FROM networks WHERE network_id = ?1", networkId);
|
|
1103
|
+
if (!network) return withCors(req, Response.json({ ok: false, error: "network not found" }, { status: 404 }));
|
|
1104
|
+
// Membership check: must be a member or system admin
|
|
1105
|
+
const viewerRole = getUserNetworkRole(resolved.user.user_id, networkId);
|
|
1106
|
+
if (!viewerRole && resolved.user.role !== "admin") {
|
|
1107
|
+
return withCors(req, Response.json({ ok: false, error: "access denied" }, { status: 403 }));
|
|
1108
|
+
}
|
|
1109
|
+
// Get network stats
|
|
1110
|
+
const nodeCount = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM nodes WHERE network_id = ?1", networkId);
|
|
1111
|
+
const sessionCount = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM sessions WHERE network_id = ?1", networkId);
|
|
1112
|
+
const taskStats = db.all<any>("SELECT status, COUNT(*) as count FROM tasks WHERE network_id = ?1 GROUP BY status", networkId);
|
|
1113
|
+
return withCors(req, Response.json({
|
|
1114
|
+
ok: true, network,
|
|
1115
|
+
stats: { nodes: nodeCount?.cnt || 0, sessions: sessionCount?.cnt || 0, tasks: taskStats },
|
|
1116
|
+
}));
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
if (netDetailMatch && req.method === "DELETE") {
|
|
1120
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
1121
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1122
|
+
const resolved = resolveToken(token);
|
|
1123
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1124
|
+
const result = deleteNetwork(resolved.user.user_id, netDetailMatch[1]);
|
|
1125
|
+
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "network_deleted", "network", netDetailMatch[1]);
|
|
1126
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
if (netDetailMatch && req.method === "PUT") {
|
|
1130
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
1131
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1132
|
+
const resolved = resolveToken(token);
|
|
1133
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1134
|
+
try {
|
|
1135
|
+
const body = await req.json() as any;
|
|
1136
|
+
if (body.name) {
|
|
1137
|
+
const result = renameNetwork(resolved.user.user_id, netDetailMatch[1], body.name);
|
|
1138
|
+
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "network_renamed", "network", netDetailMatch[1], body.name);
|
|
1139
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
1140
|
+
}
|
|
1141
|
+
return withCors(req, Response.json({ ok: false, error: "name required" }, { status: 400 }));
|
|
1142
|
+
} catch (e: any) {
|
|
1143
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// ── REST: health (public, no auth) ──
|
|
1148
|
+
if (url.pathname === "/health") {
|
|
1149
|
+
// #473 — /health is ANONYMOUS BY CONTRACT (the central watchdog
|
|
1150
|
+
// cron curls it every minute to decide dead-or-alive; adding auth
|
|
1151
|
+
// here would blind it — do NOT add requireAuth, do NOT change the
|
|
1152
|
+
// 200-with-JSON shape without telling the watchdog owner first).
|
|
1153
|
+
// Anonymous callers get AGGREGATES ONLY. The per-key SSE detail
|
|
1154
|
+
// (`{networkId}:{alias}` + observer keys) used to be inlined here,
|
|
1155
|
+
// which handed any internet stranger the full network id + all
|
|
1156
|
+
// agent aliases (95 on the public hub when audited) — that detail
|
|
1157
|
+
// now lives behind auth at GET /api/stats/sse.
|
|
1158
|
+
// `version` stays: it does help fingerprinting, but ops needs it
|
|
1159
|
+
// to verify deploys land, and the tradeoff was accepted in review.
|
|
1160
|
+
const count = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM sessions");
|
|
1161
|
+
const sse = getSSEStats();
|
|
1162
|
+
const license = db.get<any>("SELECT type, expires_at FROM licenses LIMIT 1");
|
|
1163
|
+
return withCors(req, Response.json({
|
|
1164
|
+
ok: true,
|
|
1165
|
+
version: SERVER_VERSION,
|
|
1166
|
+
api_version: "v3",
|
|
1167
|
+
transport: "streamable-http",
|
|
1168
|
+
sessions_count: count?.cnt ?? 0,
|
|
1169
|
+
sse_connections: sse.total,
|
|
1170
|
+
auth: DEV_OPEN ? "dev-open" : "user-token",
|
|
1171
|
+
security: DEV_OPEN ? "dev-open" : "secured",
|
|
1172
|
+
tmux: TMUX_ENABLED ? "enabled" : "disabled",
|
|
1173
|
+
v3_auth: true,
|
|
1174
|
+
multi_network: true,
|
|
1175
|
+
license: license?.type || "none",
|
|
1176
|
+
uptime: Math.floor(process.uptime()),
|
|
1177
|
+
}));
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
// ── All REST /api endpoints require auth (if token configured) ──
|
|
1181
|
+
const authErr = requireAuth(req);
|
|
1182
|
+
if (authErr) return withCors(req, authErr);
|
|
1183
|
+
|
|
1184
|
+
// Resolve network scope for REST queries — enforce isolation
|
|
1185
|
+
// Token-bound networkId takes precedence (ntok_ → forced), then query param
|
|
1186
|
+
const restAuth = resolveRequestAuth(req);
|
|
1187
|
+
const isAdmin = !!(restAuth?.username && db.get<any>("SELECT role FROM users WHERE username = ?1", restAuth.username)?.role === "admin");
|
|
1188
|
+
const restScope = resolveRestNetworkScope(url, restAuth, isAdmin);
|
|
1189
|
+
if (restScope.denied) {
|
|
1190
|
+
return withCors(req, Response.json({ ok: false, error: restScope.denied }, { status: 403 }));
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// ── #473 REST: SSE connection detail (ops-only) ──
|
|
1194
|
+
// The per-key breakdown /health used to expose anonymously: keys are
|
|
1195
|
+
// `{networkId}:{alias}` (+ `\0netobs:{networkId}` observer keys via
|
|
1196
|
+
// printableKey), i.e. the full network/agent topology. Ops-scoped:
|
|
1197
|
+
// legacy master token (restAuth null once requireAuth passed) or an
|
|
1198
|
+
// admin user. Regular members get 403 — the detail spans EVERY
|
|
1199
|
+
// network, so per-network filtering would be required to open it
|
|
1200
|
+
// wider; no consumer needs that today.
|
|
1201
|
+
if (url.pathname === "/api/stats/sse") {
|
|
1202
|
+
// restAuth === null here means the caller passed the legacy master
|
|
1203
|
+
// token (requireAuth already accepted it above) OR the hub runs in
|
|
1204
|
+
// DEV_OPEN mode. Both are treated as ops: master token is root, and
|
|
1205
|
+
// under DEV_OPEN this endpoint is anonymously readable BY DESIGN
|
|
1206
|
+
// (dev convenience — production never sets COMMHUB_DEV_OPEN, so the
|
|
1207
|
+
// topology detail stays gated in every real deployment).
|
|
1208
|
+
if (restAuth && !isAdmin) {
|
|
1209
|
+
return withCors(req, Response.json({ ok: false, error: "admin or master token required" }, { status: 403 }));
|
|
1210
|
+
}
|
|
1211
|
+
const sse = getSSEStats();
|
|
1212
|
+
return withCors(req, Response.json({ ok: true, total: sse.total, sessions: sse.sessions }));
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// ── REST: all sessions status ──
|
|
1216
|
+
if (url.pathname === "/api/status") {
|
|
1217
|
+
// Round-2/4 review ③: stale-marking moved to startStaleSessionSweeper()
|
|
1218
|
+
// (background timer, ~60s cadence). Read paths no longer fire UPDATE.
|
|
1219
|
+
// Per-request UPDATE was a write-amp vector — see stale-sweeper.ts.
|
|
1220
|
+
cleanupCommittedRenameSessions(restScope.networkId ? [restScope.networkId] : restScope.networkIds ?? null);
|
|
1221
|
+
// `?light=1` returns a narrow projection (alias / status / agent / task /
|
|
1222
|
+
// server / updated_at + runtime + network_id) — used by the mobile APP
|
|
1223
|
+
// list view, where pulling 30+ fields × 150 agents was making cold open
|
|
1224
|
+
// take 12s+ on flaky cellular (Vincent tg, #220 PWA). Default response
|
|
1225
|
+
// is unchanged so the dashboard / scripts that read full telemetry are
|
|
1226
|
+
// unaffected.
|
|
1227
|
+
const isLight = url.searchParams.get("light") === "1";
|
|
1228
|
+
const params: any[] = [];
|
|
1229
|
+
let sql = isLight
|
|
1230
|
+
? "SELECT alias, status, agent, task, server, updated_at, network_id FROM sessions WHERE 1=1"
|
|
1231
|
+
: "SELECT * FROM sessions WHERE 1=1";
|
|
1232
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
1233
|
+
sql += " ORDER BY updated_at DESC";
|
|
1234
|
+
// `model` comes straight from the sessions row (SELECT *); `runtime` is
|
|
1235
|
+
// derived from the raw `agent` field. Both default to null for old nodes
|
|
1236
|
+
// that never reported a model — the dashboard falls back to a placeholder.
|
|
1237
|
+
const sessions = db.all(sql, ...params).map((s: any) => {
|
|
1238
|
+
if (isLight) {
|
|
1239
|
+
return {
|
|
1240
|
+
alias: s.alias,
|
|
1241
|
+
status: s.status,
|
|
1242
|
+
agent: s.agent ?? null,
|
|
1243
|
+
task: s.task ?? null,
|
|
1244
|
+
server: s.server ?? null,
|
|
1245
|
+
updated_at: s.updated_at ?? null,
|
|
1246
|
+
runtime: normalizeRuntime(s.agent),
|
|
1247
|
+
network_id: s.network_id ?? null,
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
return {
|
|
1251
|
+
...s,
|
|
1252
|
+
model: s.model ?? null,
|
|
1253
|
+
runtime: normalizeRuntime(s.agent),
|
|
1254
|
+
host: {
|
|
1255
|
+
hostname: s.hostname ?? null,
|
|
1256
|
+
ip: s.ip ?? null,
|
|
1257
|
+
cpu_load_1min: s.cpu_load_1min ?? null,
|
|
1258
|
+
cpu_cores: s.cpu_cores ?? null,
|
|
1259
|
+
mem_total_gb: s.mem_total_gb ?? null,
|
|
1260
|
+
mem_used_gb: s.mem_used_gb ?? null,
|
|
1261
|
+
mem_avail_gb: s.mem_avail_gb ?? null,
|
|
1262
|
+
disk_total_gb: s.disk_total_gb ?? null,
|
|
1263
|
+
disk_used_gb: s.disk_used_gb ?? null,
|
|
1264
|
+
disk_avail_gb: s.disk_avail_gb ?? null,
|
|
1265
|
+
},
|
|
1266
|
+
process_telemetry: {
|
|
1267
|
+
rss_bytes: s.process_rss_bytes ?? null,
|
|
1268
|
+
rss_mb: s.process_rss_mb ?? null,
|
|
1269
|
+
cpu_pct: s.process_cpu_pct ?? null,
|
|
1270
|
+
uptime_seconds: s.process_uptime_seconds ?? null,
|
|
1271
|
+
in_flight_count: s.process_in_flight_count ?? null,
|
|
1272
|
+
},
|
|
1273
|
+
};
|
|
1274
|
+
});
|
|
1275
|
+
const summary = sessions.reduce((acc: any, session: any) => {
|
|
1276
|
+
const raw = String(session.status || "").toLowerCase();
|
|
1277
|
+
if (raw === "offline") acc.offline++;
|
|
1278
|
+
else if (["working", "blocked", "error", "waiting_input", "running", "busy"].includes(raw)) acc.working++;
|
|
1279
|
+
else acc.idle++;
|
|
1280
|
+
return acc;
|
|
1281
|
+
}, { idle: 0, working: 0, offline: 0, total: sessions.length });
|
|
1282
|
+
return withCors(req, Response.json({ ok: true, sessions, summary }));
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
// ── REST: aggregate agents by physical server ──
|
|
1286
|
+
if (url.pathname === "/api/servers") {
|
|
1287
|
+
// Round-2/4 review ③: stale-marking moved to background sweeper.
|
|
1288
|
+
const params: any[] = [];
|
|
1289
|
+
let sql = `
|
|
1290
|
+
SELECT hostname, ip, cpu_load_1min, cpu_cores, mem_avail_gb, mem_used_gb,
|
|
1291
|
+
COALESCE(last_seen_at, updated_at) AS last_seen
|
|
1292
|
+
FROM sessions
|
|
1293
|
+
WHERE 1=1
|
|
1294
|
+
`;
|
|
1295
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
1296
|
+
sql += " ORDER BY COALESCE(last_seen_at, updated_at) DESC";
|
|
1297
|
+
|
|
1298
|
+
const grouped = new Map<string, {
|
|
1299
|
+
hostname: string;
|
|
1300
|
+
ip: string;
|
|
1301
|
+
agent_count: number;
|
|
1302
|
+
cpu_load_1min: number | null;
|
|
1303
|
+
cpu_cores: number | null;
|
|
1304
|
+
mem_avail_gb: number | null;
|
|
1305
|
+
mem_used_gb: number | null;
|
|
1306
|
+
last_seen: string | null;
|
|
1307
|
+
}>();
|
|
1308
|
+
|
|
1309
|
+
const preferDisplayIp = (current: string, next: string) => {
|
|
1310
|
+
const isWeak = (ip: string) => !ip || ip === "unknown" || ip === "127.0.0.1" || ip === "::1";
|
|
1311
|
+
if (isWeak(current) && !isWeak(next)) return next;
|
|
1312
|
+
return current;
|
|
1313
|
+
};
|
|
1314
|
+
const hasHostTelemetry = (row: any) =>
|
|
1315
|
+
row.cpu_load_1min != null || row.cpu_cores != null || row.mem_avail_gb != null || row.mem_used_gb != null;
|
|
1316
|
+
|
|
1317
|
+
for (const row of db.all<any>(sql, ...params)) {
|
|
1318
|
+
const hostname = row.hostname || "unknown";
|
|
1319
|
+
const ip = row.ip || "unknown";
|
|
1320
|
+
// Group primarily by hostname. A single host can report both a
|
|
1321
|
+
// routable/container IP and loopback (127.0.0.1); splitting those
|
|
1322
|
+
// into separate cards makes the dashboard show one useful load row
|
|
1323
|
+
// plus one "n/a" duplicate. Unknown hostnames still fall back to IP.
|
|
1324
|
+
const key = hostname !== "unknown" ? `host:${hostname}` : `ip:${ip}`;
|
|
1325
|
+
const existing = grouped.get(key);
|
|
1326
|
+
if (existing) {
|
|
1327
|
+
existing.agent_count += 1;
|
|
1328
|
+
existing.ip = preferDisplayIp(existing.ip, ip);
|
|
1329
|
+
if (parseSqliteTime(row.last_seen) > parseSqliteTime(existing.last_seen)) existing.last_seen = row.last_seen ?? existing.last_seen;
|
|
1330
|
+
if (hasHostTelemetry(row) && (
|
|
1331
|
+
existing.cpu_load_1min == null ||
|
|
1332
|
+
existing.cpu_cores == null ||
|
|
1333
|
+
existing.mem_avail_gb == null ||
|
|
1334
|
+
existing.mem_used_gb == null
|
|
1335
|
+
)) {
|
|
1336
|
+
existing.cpu_load_1min = row.cpu_load_1min ?? existing.cpu_load_1min;
|
|
1337
|
+
existing.cpu_cores = row.cpu_cores ?? existing.cpu_cores;
|
|
1338
|
+
existing.mem_avail_gb = row.mem_avail_gb ?? existing.mem_avail_gb;
|
|
1339
|
+
existing.mem_used_gb = row.mem_used_gb ?? existing.mem_used_gb;
|
|
1340
|
+
}
|
|
1341
|
+
continue;
|
|
1342
|
+
}
|
|
1343
|
+
grouped.set(key, {
|
|
1344
|
+
hostname,
|
|
1345
|
+
ip,
|
|
1346
|
+
agent_count: 1,
|
|
1347
|
+
cpu_load_1min: row.cpu_load_1min ?? null,
|
|
1348
|
+
cpu_cores: row.cpu_cores ?? null,
|
|
1349
|
+
mem_avail_gb: row.mem_avail_gb ?? null,
|
|
1350
|
+
mem_used_gb: row.mem_used_gb ?? null,
|
|
1351
|
+
last_seen: row.last_seen ?? null,
|
|
1352
|
+
});
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
return withCors(req, Response.json(Array.from(grouped.values())));
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
const serverDetailMatch = url.pathname.match(/^\/api\/server\/([^/]+)\/(health|agents)$/);
|
|
1359
|
+
if (serverDetailMatch && req.method === "GET") {
|
|
1360
|
+
const host = decodeURIComponent(serverDetailMatch[1]);
|
|
1361
|
+
const detailKind = serverDetailMatch[2];
|
|
1362
|
+
if (!host) return withCors(req, Response.json({ ok: false, error: "host required" }, { status: 400 }));
|
|
1363
|
+
|
|
1364
|
+
// Round-2/4 review ③: stale-marking moved to background sweeper.
|
|
1365
|
+
if (detailKind === "agents") {
|
|
1366
|
+
const params: any[] = [host, host];
|
|
1367
|
+
let sql = `
|
|
1368
|
+
SELECT alias, agent, status, task, progress, model, hostname, ip,
|
|
1369
|
+
cpu_load_1min, cpu_cores, mem_avail_gb, mem_used_gb, mem_total_gb,
|
|
1370
|
+
disk_avail_gb, disk_used_gb, disk_total_gb,
|
|
1371
|
+
process_rss_bytes, process_rss_mb, process_cpu_pct, process_uptime_seconds, process_in_flight_count,
|
|
1372
|
+
COALESCE(last_seen_at, updated_at) AS last_seen
|
|
1373
|
+
FROM sessions
|
|
1374
|
+
WHERE (hostname = ?1 OR ip = ?2)
|
|
1375
|
+
`;
|
|
1376
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
1377
|
+
sql += " ORDER BY alias";
|
|
1378
|
+
const agents = db.all<any>(sql, ...params).map((s) => ({
|
|
1379
|
+
alias: s.alias,
|
|
1380
|
+
runtime: normalizeRuntime(s.agent),
|
|
1381
|
+
raw_agent: s.agent ?? null,
|
|
1382
|
+
model: s.model ?? null,
|
|
1383
|
+
status: s.status ?? "offline",
|
|
1384
|
+
task: s.task ?? null,
|
|
1385
|
+
progress: s.progress ?? 0,
|
|
1386
|
+
last_seen: s.last_seen ?? null,
|
|
1387
|
+
health: agentHealthChip(s.status, s.last_seen),
|
|
1388
|
+
hostname: s.hostname ?? null,
|
|
1389
|
+
ip: s.ip ?? null,
|
|
1390
|
+
telemetry: {
|
|
1391
|
+
cpu_load_1min: s.cpu_load_1min ?? null,
|
|
1392
|
+
cpu_cores: s.cpu_cores ?? null,
|
|
1393
|
+
cpu_pct: cpuPct(s.cpu_load_1min, s.cpu_cores),
|
|
1394
|
+
mem_total_gb: s.mem_total_gb ?? null,
|
|
1395
|
+
mem_used_gb: s.mem_used_gb ?? null,
|
|
1396
|
+
mem_avail_gb: s.mem_avail_gb ?? null,
|
|
1397
|
+
disk_total_gb: s.disk_total_gb ?? null,
|
|
1398
|
+
disk_used_gb: s.disk_used_gb ?? null,
|
|
1399
|
+
disk_avail_gb: s.disk_avail_gb ?? null,
|
|
1400
|
+
process_rss_bytes: s.process_rss_bytes ?? null,
|
|
1401
|
+
process_rss_mb: s.process_rss_mb ?? null,
|
|
1402
|
+
process_cpu_pct: s.process_cpu_pct ?? null,
|
|
1403
|
+
process_uptime_seconds: s.process_uptime_seconds ?? null,
|
|
1404
|
+
process_in_flight_count: s.process_in_flight_count ?? null,
|
|
1405
|
+
},
|
|
1406
|
+
process_telemetry: {
|
|
1407
|
+
rss_bytes: s.process_rss_bytes ?? null,
|
|
1408
|
+
rss_mb: s.process_rss_mb ?? null,
|
|
1409
|
+
cpu_pct: s.process_cpu_pct ?? null,
|
|
1410
|
+
uptime_seconds: s.process_uptime_seconds ?? null,
|
|
1411
|
+
in_flight_count: s.process_in_flight_count ?? null,
|
|
1412
|
+
},
|
|
1413
|
+
}));
|
|
1414
|
+
if (agents.length === 0) return withCors(req, Response.json({ ok: false, error: "server not found" }, { status: 404 }));
|
|
1415
|
+
return withCors(req, Response.json({ ok: true, host, agent_count: agents.length, agents }));
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
const latestParams: any[] = [host, host];
|
|
1419
|
+
let latestSql = `
|
|
1420
|
+
SELECT hostname, ip, COUNT(*) OVER () AS agent_count,
|
|
1421
|
+
cpu_load_1min, cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb,
|
|
1422
|
+
disk_total_gb, disk_used_gb, disk_avail_gb,
|
|
1423
|
+
COALESCE(last_seen_at, updated_at) AS last_seen
|
|
1424
|
+
FROM sessions
|
|
1425
|
+
WHERE (hostname = ?1 OR ip = ?2)
|
|
1426
|
+
`;
|
|
1427
|
+
latestSql = addNetworkScope(latestSql, latestParams, restScope);
|
|
1428
|
+
latestSql += " ORDER BY COALESCE(last_seen_at, updated_at) DESC LIMIT 1";
|
|
1429
|
+
const latest = db.get<any>(latestSql, ...latestParams);
|
|
1430
|
+
if (!latest) return withCors(req, Response.json({ ok: false, error: "server not found" }, { status: 404 }));
|
|
1431
|
+
|
|
1432
|
+
const since24h = sqliteTime(new Date(Date.now() - 24 * 60 * 60 * 1000));
|
|
1433
|
+
const histParams: any[] = [host, host, since24h];
|
|
1434
|
+
let histSql = `
|
|
1435
|
+
SELECT created_at, cpu_load_1min, cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb,
|
|
1436
|
+
disk_total_gb, disk_used_gb, disk_avail_gb
|
|
1437
|
+
FROM agent_telemetry
|
|
1438
|
+
WHERE (hostname = ?1 OR ip = ?2) AND created_at >= ?3
|
|
1439
|
+
`;
|
|
1440
|
+
histSql = addNetworkScope(histSql, histParams, restScope);
|
|
1441
|
+
histSql += " ORDER BY created_at ASC";
|
|
1442
|
+
const historyRows = db.all<any>(histSql, ...histParams);
|
|
1443
|
+
const now = Date.now();
|
|
1444
|
+
const alert = serverAlertLevel(latest);
|
|
1445
|
+
|
|
1446
|
+
return withCors(req, Response.json({
|
|
1447
|
+
ok: true,
|
|
1448
|
+
host,
|
|
1449
|
+
hostname: latest.hostname ?? null,
|
|
1450
|
+
ip: latest.ip ?? null,
|
|
1451
|
+
agent_count: latest.agent_count ?? 0,
|
|
1452
|
+
alert_level: alert.level,
|
|
1453
|
+
alerts: alert.alerts,
|
|
1454
|
+
latest: {
|
|
1455
|
+
cpu_load_1min: latest.cpu_load_1min ?? null,
|
|
1456
|
+
cpu_cores: latest.cpu_cores ?? null,
|
|
1457
|
+
cpu_pct: cpuPct(latest.cpu_load_1min, latest.cpu_cores),
|
|
1458
|
+
mem_total_gb: latest.mem_total_gb ?? null,
|
|
1459
|
+
mem_used_gb: latest.mem_used_gb ?? null,
|
|
1460
|
+
mem_avail_gb: latest.mem_avail_gb ?? null,
|
|
1461
|
+
disk_total_gb: latest.disk_total_gb ?? null,
|
|
1462
|
+
disk_used_gb: latest.disk_used_gb ?? null,
|
|
1463
|
+
disk_avail_gb: latest.disk_avail_gb ?? null,
|
|
1464
|
+
last_seen: latest.last_seen ?? null,
|
|
1465
|
+
},
|
|
1466
|
+
history: {
|
|
1467
|
+
"5m": bucketTelemetry(historyRows, now - 5 * 60 * 1000, 60 * 1000),
|
|
1468
|
+
"1h": bucketTelemetry(historyRows, now - 60 * 60 * 1000, 5 * 60 * 1000),
|
|
1469
|
+
"24h": bucketTelemetry(historyRows, now - 24 * 60 * 60 * 1000, 60 * 60 * 1000),
|
|
1470
|
+
},
|
|
1471
|
+
}));
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
// ── REST: file upload (#221) ──
|
|
1475
|
+
// POST multipart/form-data with a single `file` field. Bearer auth.
|
|
1476
|
+
// 12 MiB cap (two-stage: Content-Length pre-check + post-parse size
|
|
1477
|
+
// verify). Returns { ok, file_id, path, url, size, mime }. The
|
|
1478
|
+
// file_id is server-generated (crypto.randomUUID with hyphens
|
|
1479
|
+
// stripped) so a client-supplied filename never enters the storage
|
|
1480
|
+
// path. The download URL `/api/files/<file_id>` requires the same
|
|
1481
|
+
// Bearer auth and forces Content-Disposition: attachment +
|
|
1482
|
+
// X-Content-Type-Options: nosniff so uploaded files can never be
|
|
1483
|
+
// executed or served as HTML.
|
|
1484
|
+
if (url.pathname === "/api/upload" && req.method === "POST") {
|
|
1485
|
+
const authErr = requireAuth(req);
|
|
1486
|
+
if (authErr) return withCors(req, authErr);
|
|
1487
|
+
|
|
1488
|
+
// Rate-limit key prefers the token id (so 60/h is per-token, not
|
|
1489
|
+
// per-IP); falls back to IP for legacy or anonymous-but-dev-open.
|
|
1490
|
+
const authCtx = resolveRequestAuth(req);
|
|
1491
|
+
const rateKey = authCtx?.tokenId ?? `ip:${getClientIP(req, server)}`;
|
|
1492
|
+
const rate = sharedUploadRateLimiter.check(rateKey);
|
|
1493
|
+
if (!rate.allowed) {
|
|
1494
|
+
const headers: Record<string, string> = {
|
|
1495
|
+
"X-RateLimit-Limit": String(60),
|
|
1496
|
+
"X-RateLimit-Remaining": "0",
|
|
1497
|
+
"X-RateLimit-Reset": String(Math.floor(rate.resetAt / 1000)),
|
|
1498
|
+
};
|
|
1499
|
+
if (rate.retryAfterMs) headers["Retry-After"] = String(Math.ceil(rate.retryAfterMs / 1000));
|
|
1500
|
+
return withCors(req, new Response(
|
|
1501
|
+
JSON.stringify({ ok: false, error: "rate_limited", message: "Upload rate limit exceeded (60/hour). Try again later.", retry_after_ms: rate.retryAfterMs }),
|
|
1502
|
+
{ status: 429, headers: { ...headers, "Content-Type": "application/json; charset=utf-8" } }, // #426 — legacy clients default to ISO-8859-1 without charset
|
|
1503
|
+
));
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
// Two-stage size cap per 通信龙 dispatch c38de7a9:
|
|
1507
|
+
// stage 1 — reject before reading body when Content-Length
|
|
1508
|
+
// exceeds the cap (saves bandwidth, fails fast)
|
|
1509
|
+
// stage 2 — re-verify the parsed File.size after Bun has parsed
|
|
1510
|
+
// the multipart envelope (defends against a missing
|
|
1511
|
+
// or lied Content-Length header)
|
|
1512
|
+
const contentLength = req.headers.get("Content-Length");
|
|
1513
|
+
if (!contentLength) {
|
|
1514
|
+
return withCors(req, Response.json(
|
|
1515
|
+
{ ok: false, error: "length_required", message: "Content-Length header is required for /api/upload" },
|
|
1516
|
+
{ status: 411 },
|
|
1517
|
+
));
|
|
1518
|
+
}
|
|
1519
|
+
const declaredBytes = Number(contentLength);
|
|
1520
|
+
if (!Number.isFinite(declaredBytes) || declaredBytes < 0) {
|
|
1521
|
+
return withCors(req, Response.json({ ok: false, error: "bad_content_length" }, { status: 400 }));
|
|
1522
|
+
}
|
|
1523
|
+
if (declaredBytes > MAX_REQUEST_CONTENT_LENGTH) {
|
|
1524
|
+
return withCors(req, Response.json(
|
|
1525
|
+
{ ok: false, error: "payload_too_large", message: `Upload exceeds the ${MAX_UPLOAD_BYTES} byte limit`, limit_bytes: MAX_UPLOAD_BYTES },
|
|
1526
|
+
{ status: 413 },
|
|
1527
|
+
));
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
const contentType = req.headers.get("Content-Type") ?? "";
|
|
1531
|
+
if (!/^multipart\/form-data/i.test(contentType)) {
|
|
1532
|
+
return withCors(req, Response.json(
|
|
1533
|
+
{ ok: false, error: "unsupported_media_type", message: "Use multipart/form-data with a 'file' field" },
|
|
1534
|
+
{ status: 415 },
|
|
1535
|
+
));
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
let form: FormData;
|
|
1539
|
+
try {
|
|
1540
|
+
form = await req.formData();
|
|
1541
|
+
} catch (e: any) {
|
|
1542
|
+
return withCors(req, Response.json(
|
|
1543
|
+
{ ok: false, error: "multipart_parse_failed", message: e?.message?.slice(0, 200) ?? "multipart parse failed" },
|
|
1544
|
+
{ status: 400 },
|
|
1545
|
+
));
|
|
1546
|
+
}
|
|
1547
|
+
const file = form.get("file");
|
|
1548
|
+
if (!file || typeof file === "string") {
|
|
1549
|
+
return withCors(req, Response.json(
|
|
1550
|
+
{ ok: false, error: "missing_file", message: "Expected a 'file' field with binary content" },
|
|
1551
|
+
{ status: 400 },
|
|
1552
|
+
));
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
// Stage 2 — re-verify parsed size against the documented cap.
|
|
1556
|
+
if (file.size > MAX_UPLOAD_BYTES) {
|
|
1557
|
+
return withCors(req, Response.json(
|
|
1558
|
+
{ ok: false, error: "payload_too_large", message: `Upload exceeds the ${MAX_UPLOAD_BYTES} byte limit`, limit_bytes: MAX_UPLOAD_BYTES, observed_bytes: file.size },
|
|
1559
|
+
{ status: 413 },
|
|
1560
|
+
));
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
const fileId = generateFileId();
|
|
1564
|
+
const ext = sanitizeExt(file.name);
|
|
1565
|
+
let storage;
|
|
1566
|
+
try {
|
|
1567
|
+
storage = buildStoragePath(fileId, ext);
|
|
1568
|
+
} catch (e: any) {
|
|
1569
|
+
// Should be unreachable — we generated the id ourselves — but
|
|
1570
|
+
// surface as 500 if our own primitives reject our own input.
|
|
1571
|
+
console.error("[/api/upload] buildStoragePath rejected:", e?.message);
|
|
1572
|
+
return withCors(req, Response.json({ ok: false, error: "internal_path_error" }, { status: 500 }));
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
try {
|
|
1576
|
+
mkdirSync(pathDirname(storage.absolutePath), { recursive: true });
|
|
1577
|
+
const buf = Buffer.from(await file.arrayBuffer());
|
|
1578
|
+
writeFileSync(storage.absolutePath, buf);
|
|
1579
|
+
// Defense-in-depth: after writing, confirm the path still sits
|
|
1580
|
+
// inside the uploads root (catches a symlink swap or root env
|
|
1581
|
+
// poisoning across processes).
|
|
1582
|
+
if (!isPathInsideUploadsRoot(storage.absolutePath)) {
|
|
1583
|
+
// Refuse to acknowledge a write that landed outside the root.
|
|
1584
|
+
// Don't try to clean up — better to keep evidence on disk than
|
|
1585
|
+
// chase a symlink for cleanup.
|
|
1586
|
+
console.error("[/api/upload] write escaped uploads root:", storage.absolutePath);
|
|
1587
|
+
return withCors(req, Response.json({ ok: false, error: "internal_path_escape" }, { status: 500 }));
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
// Persist the index entry so /api/files/<file_id> can do an
|
|
1591
|
+
// O(1) lookup without scanning every date bucket.
|
|
1592
|
+
const idxPath = indexEntryPath(fileId);
|
|
1593
|
+
if (idxPath) {
|
|
1594
|
+
mkdirSync(pathDirname(idxPath), { recursive: true });
|
|
1595
|
+
writeFileSync(idxPath, JSON.stringify({
|
|
1596
|
+
file_id: fileId,
|
|
1597
|
+
date_bucket: storage.dateBucket,
|
|
1598
|
+
ext: storage.ext,
|
|
1599
|
+
name: typeof file.name === "string" ? file.name.slice(0, 255) : "",
|
|
1600
|
+
mime: typeof file.type === "string" ? file.type.slice(0, 100) : "application/octet-stream",
|
|
1601
|
+
size: file.size,
|
|
1602
|
+
owner: authCtx?.username ?? null,
|
|
1603
|
+
owner_id: authCtx?.userId ?? null,
|
|
1604
|
+
uploaded_at: new Date().toISOString(),
|
|
1605
|
+
}, null, 2));
|
|
1606
|
+
}
|
|
1607
|
+
} catch (e: any) {
|
|
1608
|
+
console.error("[/api/upload] write failed:", e?.message);
|
|
1609
|
+
return withCors(req, Response.json({ ok: false, error: "write_failed", message: e?.message?.slice(0, 200) ?? "write failed" }, { status: 500 }));
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
return withCors(req, Response.json({
|
|
1613
|
+
ok: true,
|
|
1614
|
+
file_id: fileId,
|
|
1615
|
+
path: storage.absolutePath,
|
|
1616
|
+
url: `/api/files/${fileId}`,
|
|
1617
|
+
size: file.size,
|
|
1618
|
+
mime: typeof file.type === "string" ? file.type : "application/octet-stream",
|
|
1619
|
+
}));
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
// ── REST: file download (#221) ──
|
|
1623
|
+
// GET /api/files/:file_id with Bearer auth. Always forces
|
|
1624
|
+
// Content-Disposition: attachment + X-Content-Type-Options: nosniff
|
|
1625
|
+
// so the served file can never be executed or rendered as HTML.
|
|
1626
|
+
// The file_id is validated against the same strict regex used at
|
|
1627
|
+
// generation time before any filesystem access.
|
|
1628
|
+
const fileMatch = url.pathname.match(/^\/api\/files\/(.+)$/);
|
|
1629
|
+
if (fileMatch && req.method === "GET") {
|
|
1630
|
+
const authErr = requireAuth(req);
|
|
1631
|
+
if (authErr) return withCors(req, authErr);
|
|
1632
|
+
|
|
1633
|
+
const fileId = decodeURIComponent(fileMatch[1]);
|
|
1634
|
+
if (!FILE_ID_REGEX.test(fileId)) {
|
|
1635
|
+
return withCors(req, Response.json({ ok: false, error: "bad_file_id" }, { status: 400 }));
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
const idxPath = indexEntryPath(fileId);
|
|
1639
|
+
if (!idxPath || !existsSync(idxPath)) {
|
|
1640
|
+
return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
let entry: any;
|
|
1644
|
+
try { entry = JSON.parse(readFileSync(idxPath, "utf-8")); } catch {
|
|
1645
|
+
return withCors(req, Response.json({ ok: false, error: "index_corrupt" }, { status: 500 }));
|
|
1646
|
+
}
|
|
1647
|
+
if (!validateIndexEntry(entry)) {
|
|
1648
|
+
return withCors(req, Response.json({ ok: false, error: "index_invalid" }, { status: 500 }));
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
let storage;
|
|
1652
|
+
try {
|
|
1653
|
+
storage = buildStoragePath(entry.file_id, entry.ext);
|
|
1654
|
+
} catch {
|
|
1655
|
+
return withCors(req, Response.json({ ok: false, error: "index_invalid" }, { status: 500 }));
|
|
1656
|
+
}
|
|
1657
|
+
if (!isPathInsideUploadsRoot(storage.absolutePath)) {
|
|
1658
|
+
// Defence: should be unreachable since buildStoragePath rejected
|
|
1659
|
+
// malformed inputs, but a poisoned index could try to fool us.
|
|
1660
|
+
return withCors(req, Response.json({ ok: false, error: "path_escape" }, { status: 500 }));
|
|
1661
|
+
}
|
|
1662
|
+
if (!existsSync(storage.absolutePath)) {
|
|
1663
|
+
return withCors(req, Response.json({ ok: false, error: "blob_missing" }, { status: 404 }));
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
// Defence: verify the on-disk size matches what the index claims
|
|
1667
|
+
// before serving — a tampered index could otherwise mislead the
|
|
1668
|
+
// caller about file integrity.
|
|
1669
|
+
const st = statSync(storage.absolutePath);
|
|
1670
|
+
if (st.size !== entry.size) {
|
|
1671
|
+
console.error("[/api/files] size mismatch:", { fileId, indexed: entry.size, onDisk: st.size });
|
|
1672
|
+
return withCors(req, Response.json({ ok: false, error: "size_mismatch" }, { status: 500 }));
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
const safeFilename = (entry.name && /^[\x20-\x7e]+$/.test(entry.name))
|
|
1676
|
+
? entry.name
|
|
1677
|
+
: `${fileId}${entry.ext}`;
|
|
1678
|
+
const blob = Bun.file(storage.absolutePath);
|
|
1679
|
+
const responseHeaders: Record<string, string> = {
|
|
1680
|
+
...corsHeaders(req),
|
|
1681
|
+
"Content-Type": "application/octet-stream", // always opaque; nosniff prevents the client from re-deciding
|
|
1682
|
+
"Content-Length": String(entry.size),
|
|
1683
|
+
"Content-Disposition": `attachment; filename="${safeFilename.replace(/"/g, "")}"`,
|
|
1684
|
+
"X-Content-Type-Options": "nosniff",
|
|
1685
|
+
"Cache-Control": "private, no-store",
|
|
1686
|
+
};
|
|
1687
|
+
return new Response(blob, { status: 200, headers: responseHeaders });
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
// ── REST: send task ──
|
|
1691
|
+
if (url.pathname === "/api/task" && req.method === "POST") {
|
|
1692
|
+
let raw: unknown;
|
|
1693
|
+
try {
|
|
1694
|
+
raw = await req.json();
|
|
1695
|
+
} catch {
|
|
1696
|
+
return withCors(req, Response.json({ error: "invalid JSON" }, { status: 400 }));
|
|
1697
|
+
}
|
|
1698
|
+
const parsed = TaskSchema.safeParse(raw);
|
|
1699
|
+
if (!parsed.success) {
|
|
1700
|
+
return withCors(req, Response.json({ error: "invalid input", details: parsed.error.format() }, { status: 400 }));
|
|
1701
|
+
}
|
|
1702
|
+
const body = parsed.data;
|
|
1703
|
+
let taskNetId: string | null = null;
|
|
1704
|
+
if (restAuth?.networkId) {
|
|
1705
|
+
taskNetId = restAuth.networkId;
|
|
1706
|
+
} else if (body.network_id) {
|
|
1707
|
+
if (restAuth && !isAdmin && !getUserNetworkRole(restAuth.userId, body.network_id)) {
|
|
1708
|
+
return withCors(req, Response.json({ ok: false, error: "access denied to requested network" }, { status: 403 }));
|
|
1709
|
+
}
|
|
1710
|
+
taskNetId = body.network_id;
|
|
1711
|
+
} else {
|
|
1712
|
+
taskNetId = restAuth ? singleNetworkId(restScope) : null;
|
|
1713
|
+
}
|
|
1714
|
+
if (restAuth && !taskNetId) {
|
|
1715
|
+
return withCors(req, Response.json({ ok: false, error: "network_id required for user token when multiple networks are available" }, { status: 400 }));
|
|
1716
|
+
}
|
|
1717
|
+
if (!canRestWriteNetwork(restAuth, taskNetId, isAdmin)) {
|
|
1718
|
+
return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
|
|
1719
|
+
}
|
|
1720
|
+
const canonical = resolveCanonicalAlias(taskNetId, body.alias);
|
|
1721
|
+
const targetAlias = canonical.alias;
|
|
1722
|
+
const target = resolveRestDeliveryTarget(targetAlias, taskNetId);
|
|
1723
|
+
if (target.state === "not_found") {
|
|
1724
|
+
return withCors(req, Response.json({
|
|
1725
|
+
ok: false,
|
|
1726
|
+
error: "alias_not_found",
|
|
1727
|
+
message: target.message,
|
|
1728
|
+
alias: targetAlias,
|
|
1729
|
+
queued: false,
|
|
1730
|
+
...(canonical.renamed ? { renamed_from: body.alias, renamed_to: targetAlias } : {}),
|
|
1731
|
+
}, { status: 404 }));
|
|
1732
|
+
}
|
|
1733
|
+
const id = crypto.randomUUID();
|
|
1734
|
+
const fromSession = body.from || "api";
|
|
1735
|
+
const ttlSeconds = (body as any).ttl_seconds || 3600;
|
|
1736
|
+
const fromNodeId = resolveRestNodeIdForAlias(fromSession, taskNetId);
|
|
1737
|
+
const targetNodeId = target.session?.node_id ?? null;
|
|
1738
|
+
// #221 — fold top-level `attachments` into `meta.attachments` so
|
|
1739
|
+
// the REST and MCP send_task transports produce identical
|
|
1740
|
+
// tasks.meta_json shape downstream. Top-level wins over any
|
|
1741
|
+
// duplicate `meta.attachments` the client supplied. Validation
|
|
1742
|
+
// matches the MCP-side schema (max 20 entries, file_id regex,
|
|
1743
|
+
// size cap, etc.).
|
|
1744
|
+
const attachmentsResult = validateAttachments((body as any).attachments ?? (body as any).meta?.attachments);
|
|
1745
|
+
if (!attachmentsResult.ok) {
|
|
1746
|
+
return withCors(req, Response.json({ ok: false, error: "bad_attachments", message: attachmentsResult.error }, { status: 400 }));
|
|
1747
|
+
}
|
|
1748
|
+
const mergedMeta = attachmentsResult.attachments.length
|
|
1749
|
+
? { ...((body as any).meta && typeof (body as any).meta === "object" ? (body as any).meta : {}), attachments: attachmentsResult.attachments }
|
|
1750
|
+
: (body as any).meta;
|
|
1751
|
+
const metaJson = normalizeMetaJson(mergedMeta);
|
|
1752
|
+
|
|
1753
|
+
// #212 dedup guardrail. Mirrors the MCP `send_task` tool: same
|
|
1754
|
+
// (from_session, target_alias, task) within COMMHUB_SEND_DEDUP_WINDOW_MS
|
|
1755
|
+
// is rejected with a structured `duplicate_send` error so dashboard
|
|
1756
|
+
// dispatch buttons and scripted REST callers get the same guarantee
|
|
1757
|
+
// as agent-driven MCP traffic.
|
|
1758
|
+
const dedup = sharedSendDedup.check(fromSession, targetAlias, body.task);
|
|
1759
|
+
if (dedup.duplicate) {
|
|
1760
|
+
const payload = buildDuplicateSendPayload({
|
|
1761
|
+
from: fromSession,
|
|
1762
|
+
to: targetAlias,
|
|
1763
|
+
ageMs: dedup.ageMs,
|
|
1764
|
+
windowMs: sharedSendDedup.windowMs,
|
|
1765
|
+
});
|
|
1766
|
+
console.log(`[/api/task] ${fromSession} → ${targetAlias}: DROPPED duplicate (age=${dedup.ageMs}ms, window=${sharedSendDedup.windowMs}ms)`);
|
|
1767
|
+
return withCors(req, Response.json(payload, { status: 429 }));
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
// RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.2a REST site
|
|
1771
|
+
// 1/2 — paired with the 6 MCP sites guarded in tools.ts at PR1.1).
|
|
1772
|
+
// Dashboard's "Dispatch" button hits this endpoint, so without
|
|
1773
|
+
// the guard a stopping/stopped/deleting node still got tasks
|
|
1774
|
+
// routed through REST. Returns 409 with the same structured
|
|
1775
|
+
// payload the MCP handlers use.
|
|
1776
|
+
{
|
|
1777
|
+
const lc = assertNodeActive(targetAlias, taskNetId ?? null);
|
|
1778
|
+
if (!lc.ok) {
|
|
1779
|
+
return withCors(req, Response.json(lc, { status: 409 }));
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
// Mirror send_task MCP: write inbox + tasks rows in a single
|
|
1783
|
+
// transaction so the dispatch is visible to dashboard's Tasks page
|
|
1784
|
+
// and the parent_task_id lineage chain. Previously this endpoint
|
|
1785
|
+
// only wrote inbox, leaving GET /api/tasks empty for any task
|
|
1786
|
+
// dispatched via REST (anet demo, dashboard Dispatch button, etc.).
|
|
1787
|
+
db.transaction(() => {
|
|
1788
|
+
db.run(
|
|
1789
|
+
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, requires_response, network_id, meta_json)
|
|
1790
|
+
VALUES (?1, ?2, ?3, 'task', ?4, ?5, ?6, 'reply', ?7, ?8)`,
|
|
1791
|
+
[id, targetAlias, targetNodeId, body.priority, body.task, fromSession, taskNetId, metaJson]
|
|
1792
|
+
);
|
|
1793
|
+
db.run(
|
|
1794
|
+
`INSERT INTO tasks (task_id, from_node_id, from_name, to_node_id, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at, network_id, parent_task_id, meta_json)
|
|
1795
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'delivered', ?7, 'reply', datetime('now'), datetime('now'), datetime('now', ?8), ?9, ?10, ?11)`,
|
|
1796
|
+
[id, fromNodeId, fromSession, targetNodeId, targetAlias, body.priority, body.task, `+${ttlSeconds} seconds`, taskNetId, body.parent_task_id ?? null, metaJson]
|
|
1797
|
+
);
|
|
1798
|
+
// Touch session row so the dashboard reflects "task in flight"
|
|
1799
|
+
// immediately, without waiting for the agent's report_status to
|
|
1800
|
+
// arrive. Updating both `task` and `updated_at` is enough — we
|
|
1801
|
+
// leave `status` to the agent (idle → working → idle).
|
|
1802
|
+
const touchParams: any[] = [body.task.slice(0, 200), targetAlias];
|
|
1803
|
+
let touchSql = "UPDATE sessions SET task = ?1, updated_at = datetime('now') WHERE alias = ?2";
|
|
1804
|
+
if (taskNetId) { touchSql += " AND network_id = ?3"; touchParams.push(taskNetId); }
|
|
1805
|
+
db.run(touchSql, touchParams);
|
|
1806
|
+
});
|
|
1807
|
+
// SSE push: 秒达
|
|
1808
|
+
const pendingParams: any[] = [targetAlias];
|
|
1809
|
+
let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
|
|
1810
|
+
if (taskNetId) { pendingSql += " AND network_id = ?2"; pendingParams.push(taskNetId); }
|
|
1811
|
+
const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
|
|
1812
|
+
if (target.state === "online") {
|
|
1813
|
+
pushEvent(targetAlias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority: body.priority, from: fromSession, ...(canonical.renamed ? { renamed_from: body.alias } : {}) }, taskNetId);
|
|
1814
|
+
}
|
|
1815
|
+
// #461 network observer summary — unconditional (the task row
|
|
1816
|
+
// exists even when the target is offline/queued), metadata only.
|
|
1817
|
+
pushNetworkObserverEvent(taskNetId, { type: "new_task", task_id: id, from: fromSession, to: targetAlias, status: target.state === "online" ? "delivered" : "queued", priority: body.priority });
|
|
1818
|
+
// #212 — stamp the dedup index only after the inbox/tasks insert
|
|
1819
|
+
// succeeds. Mirrors the MCP `send_task` path so a failed write
|
|
1820
|
+
// never shadows a legitimate retry.
|
|
1821
|
+
sharedSendDedup.record(fromSession, targetAlias, body.task);
|
|
1822
|
+
if (target.state === "offline") {
|
|
1823
|
+
return withCors(req, Response.json({
|
|
1824
|
+
ok: false,
|
|
1825
|
+
error: "alias_offline",
|
|
1826
|
+
message: target.message,
|
|
1827
|
+
alias: targetAlias,
|
|
1828
|
+
queued: true,
|
|
1829
|
+
task_id: id,
|
|
1830
|
+
message_id: id,
|
|
1831
|
+
session_status: target.session.status ?? "offline",
|
|
1832
|
+
...(canonical.renamed ? { renamed_from: body.alias, renamed_to: targetAlias } : {}),
|
|
1833
|
+
}, { status: 202 }));
|
|
1834
|
+
}
|
|
1835
|
+
return withCors(req, Response.json({ ok: true, task_id: id, message_id: id, ...(canonical.renamed ? { renamed_from: body.alias, renamed_to: targetAlias } : {}) }));
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
// ── REST: broadcast ──
|
|
1839
|
+
if (url.pathname === "/api/broadcast" && req.method === "POST") {
|
|
1840
|
+
let raw: unknown;
|
|
1841
|
+
try {
|
|
1842
|
+
raw = await req.json();
|
|
1843
|
+
} catch {
|
|
1844
|
+
return withCors(req, Response.json({ error: "invalid JSON" }, { status: 400 }));
|
|
1845
|
+
}
|
|
1846
|
+
const parsed = BroadcastSchema.safeParse(raw);
|
|
1847
|
+
if (!parsed.success) {
|
|
1848
|
+
return withCors(req, Response.json({ error: "invalid input", details: parsed.error.format() }, { status: 400 }));
|
|
1849
|
+
}
|
|
1850
|
+
const body = parsed.data;
|
|
1851
|
+
if (restAuth && !restScope.networkId && !isAdmin) {
|
|
1852
|
+
return withCors(req, Response.json({ ok: false, error: "network_id required for user token when broadcasting" }, { status: 400 }));
|
|
1853
|
+
}
|
|
1854
|
+
if (!canRestWriteNetwork(restAuth, restScope.networkId, isAdmin)) {
|
|
1855
|
+
return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
|
|
1856
|
+
}
|
|
1857
|
+
let sql = "SELECT alias, node_id, network_id FROM sessions WHERE alias IS NOT NULL";
|
|
1858
|
+
const params: any[] = [];
|
|
1859
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
1860
|
+
if (body.filter_server) { sql += " AND server = ?"; params.push(body.filter_server); }
|
|
1861
|
+
if (body.filter_status) { sql += " AND status = ?"; params.push(body.filter_status); }
|
|
1862
|
+
const targets = db.all<{ alias: string; node_id: string | null; network_id: string | null }>(sql, ...params);
|
|
1863
|
+
const ids: string[] = [];
|
|
1864
|
+
for (const t of targets) {
|
|
1865
|
+
// RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.2a REST site
|
|
1866
|
+
// 2/2). Broadcast skips non-active recipients silently per its
|
|
1867
|
+
// best-effort semantics — mirrors the MCP broadcast handler
|
|
1868
|
+
// in tools.ts (PR1.1 site 6/6).
|
|
1869
|
+
const lc = assertNodeActive(t.alias, t.network_id ?? null);
|
|
1870
|
+
if (!lc.ok) continue;
|
|
1871
|
+
const id = crypto.randomUUID();
|
|
1872
|
+
db.run(
|
|
1873
|
+
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id)
|
|
1874
|
+
VALUES (?1, ?2, ?3, 'broadcast', 'normal', ?4, 'api', ?5)`,
|
|
1875
|
+
[id, t.alias, t.node_id ?? null, body.message, t.network_id]
|
|
1876
|
+
);
|
|
1877
|
+
ids.push(id);
|
|
1878
|
+
}
|
|
1879
|
+
for (const t of targets) {
|
|
1880
|
+
pushEvent(t.alias, { type: "broadcast", inbox_count: 1 }, t.network_id);
|
|
1881
|
+
}
|
|
1882
|
+
return withCors(req, Response.json({ ok: true, recipients: targets.length, message_ids: ids }));
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
// ── REST: tmux capture-pane ──
|
|
1886
|
+
const tmuxCapture = url.pathname.match(/^\/api\/tmux\/([a-zA-Z0-9_-]+)$/);
|
|
1887
|
+
if (tmuxCapture && req.method === "GET") {
|
|
1888
|
+
const tmuxErr = requireTmuxAccess(req, server);
|
|
1889
|
+
if (tmuxErr) return withCors(req, tmuxErr);
|
|
1890
|
+
const name = tmuxCapture[1];
|
|
1891
|
+
const lines = Number(url.searchParams.get("lines")) || 30;
|
|
1892
|
+
try {
|
|
1893
|
+
const proc = Bun.spawn(["tmux", "capture-pane", "-t", name, "-p"], {
|
|
1894
|
+
stdout: "pipe", stderr: "pipe",
|
|
1895
|
+
});
|
|
1896
|
+
const text = await new Response(proc.stdout).text();
|
|
1897
|
+
const err = await new Response(proc.stderr).text();
|
|
1898
|
+
const code = await proc.exited;
|
|
1899
|
+
if (code !== 0) {
|
|
1900
|
+
return withCors(req, Response.json({ ok: false, error: err.trim() || `exit ${code}` }, { status: 400 }));
|
|
1901
|
+
}
|
|
1902
|
+
const trimmed = text.split("\n").slice(-lines).join("\n");
|
|
1903
|
+
return withCors(req, Response.json({ ok: true, tmux_name: name, lines: lines, output: trimmed }));
|
|
1904
|
+
} catch (e) {
|
|
1905
|
+
return withCors(req, Response.json({ ok: false, error: (e as Error).message }, { status: 500 }));
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
// ── REST: tmux send-keys ──
|
|
1910
|
+
const tmuxSend = url.pathname.match(/^\/api\/tmux\/([a-zA-Z0-9_-]+)\/send$/);
|
|
1911
|
+
if (tmuxSend && req.method === "POST") {
|
|
1912
|
+
const tmuxErr = requireTmuxAccess(req, server);
|
|
1913
|
+
if (tmuxErr) return withCors(req, tmuxErr);
|
|
1914
|
+
const name = tmuxSend[1];
|
|
1915
|
+
let body: { text?: string; enter?: boolean };
|
|
1916
|
+
try { body = await req.json(); } catch {
|
|
1917
|
+
return withCors(req, Response.json({ error: "invalid JSON" }, { status: 400 }));
|
|
1918
|
+
}
|
|
1919
|
+
if (!body.text || typeof body.text !== "string") {
|
|
1920
|
+
return withCors(req, Response.json({ error: "text is required" }, { status: 400 }));
|
|
1921
|
+
}
|
|
1922
|
+
const args = ["tmux", "send-keys", "-t", name, body.text];
|
|
1923
|
+
if (body.enter !== false) args.push("Enter");
|
|
1924
|
+
try {
|
|
1925
|
+
const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });
|
|
1926
|
+
const err = await new Response(proc.stderr).text();
|
|
1927
|
+
const code = await proc.exited;
|
|
1928
|
+
if (code !== 0) {
|
|
1929
|
+
return withCors(req, Response.json({ ok: false, error: err.trim() || `exit ${code}` }, { status: 400 }));
|
|
1930
|
+
}
|
|
1931
|
+
return withCors(req, Response.json({ ok: true, sent: body.text }));
|
|
1932
|
+
} catch (e) {
|
|
1933
|
+
return withCors(req, Response.json({ ok: false, error: (e as Error).message }, { status: 500 }));
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
// ── REST: recent messages (for Dashboard communication graph) ──
|
|
1938
|
+
if (url.pathname === "/api/messages") {
|
|
1939
|
+
const limit = Math.min(Number(url.searchParams.get("limit")) || 100, 500);
|
|
1940
|
+
const since = url.searchParams.get("since") ?? new Date(Date.now() - 3600000).toISOString().replace("T", " ").slice(0, 19);
|
|
1941
|
+
const params: any[] = [since];
|
|
1942
|
+
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";
|
|
1943
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
1944
|
+
sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
|
|
1945
|
+
params.push(limit);
|
|
1946
|
+
const rows = db.all(sql, ...params);
|
|
1947
|
+
return withCors(req, Response.json({ ok: true, messages: rows }));
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
// ── REST: stats summary ──
|
|
1951
|
+
if (url.pathname === "/api/stats") {
|
|
1952
|
+
const taskStatsParams: any[] = [];
|
|
1953
|
+
let taskStatsSql = "SELECT status, COUNT(*) as count FROM tasks WHERE 1=1";
|
|
1954
|
+
taskStatsSql = addNetworkScope(taskStatsSql, taskStatsParams, restScope);
|
|
1955
|
+
taskStatsSql += " GROUP BY status";
|
|
1956
|
+
const taskStats = db.all<any>(taskStatsSql, ...taskStatsParams);
|
|
1957
|
+
|
|
1958
|
+
const sessionStatsParams: any[] = [];
|
|
1959
|
+
let sessionStatsSql = "SELECT status, COUNT(*) as count FROM sessions WHERE 1=1";
|
|
1960
|
+
sessionStatsSql = addNetworkScope(sessionStatsSql, sessionStatsParams, restScope);
|
|
1961
|
+
sessionStatsSql += " GROUP BY status";
|
|
1962
|
+
const sessionStats = db.all<any>(sessionStatsSql, ...sessionStatsParams);
|
|
1963
|
+
|
|
1964
|
+
const totalTasksParams: any[] = [];
|
|
1965
|
+
let totalTasksSql = "SELECT COUNT(*) as cnt FROM tasks WHERE 1=1";
|
|
1966
|
+
totalTasksSql = addNetworkScope(totalTasksSql, totalTasksParams, restScope);
|
|
1967
|
+
const totalTasks = db.get<{ cnt: number }>(totalTasksSql, ...totalTasksParams);
|
|
1968
|
+
|
|
1969
|
+
const totalNodesParams: any[] = [];
|
|
1970
|
+
let totalNodesSql = "SELECT COUNT(*) as cnt FROM nodes WHERE 1=1";
|
|
1971
|
+
totalNodesSql = addNetworkScope(totalNodesSql, totalNodesParams, restScope);
|
|
1972
|
+
const totalNodes = db.get<{ cnt: number }>(totalNodesSql, ...totalNodesParams);
|
|
1973
|
+
|
|
1974
|
+
const recentTasksParams: any[] = [];
|
|
1975
|
+
let recentTasksSql = "SELECT task_id, from_name, to_name, status, created_at FROM tasks WHERE 1=1";
|
|
1976
|
+
recentTasksSql = addNetworkScope(recentTasksSql, recentTasksParams, restScope);
|
|
1977
|
+
recentTasksSql += " ORDER BY created_at DESC LIMIT 5";
|
|
1978
|
+
const recentTasks = db.all<any>(recentTasksSql, ...recentTasksParams);
|
|
1979
|
+
return withCors(req, Response.json({
|
|
1980
|
+
ok: true,
|
|
1981
|
+
network_id: restScope.networkId || null,
|
|
1982
|
+
tasks: { total: totalTasks?.cnt || 0, by_status: taskStats },
|
|
1983
|
+
sessions: { by_status: sessionStats },
|
|
1984
|
+
nodes: { total: totalNodes?.cnt || 0 },
|
|
1985
|
+
recent_tasks: recentTasks,
|
|
1986
|
+
}));
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
// ── REST: server log tail (in-memory ring buffer, last LOG_RING_CAP lines) ──
|
|
1990
|
+
// Admin-only because logs may include user names + task content.
|
|
1991
|
+
if (url.pathname === "/api/server-logs") {
|
|
1992
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
1993
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1994
|
+
const resolved = resolveToken(token);
|
|
1995
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1996
|
+
if (resolved.user.role !== "admin") return withCors(req, Response.json({ ok: false, error: "admin only" }, { status: 403 }));
|
|
1997
|
+
const limit = Math.min(Number(url.searchParams.get("limit")) || 200, LOG_RING_CAP);
|
|
1998
|
+
const since = url.searchParams.get("since"); // ISO timestamp; only return logs newer
|
|
1999
|
+
let entries = logRing.slice(-limit);
|
|
2000
|
+
if (since) entries = entries.filter(e => e.ts > since);
|
|
2001
|
+
// Newest first
|
|
2002
|
+
entries = entries.slice().reverse();
|
|
2003
|
+
return withCors(req, Response.json({ ok: true, logs: entries, capacity: LOG_RING_CAP }));
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
// ── REST: audit log (V3) ──
|
|
2007
|
+
if (url.pathname === "/api/audit-log") {
|
|
2008
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
2009
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
2010
|
+
const resolved = resolveToken(token);
|
|
2011
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
2012
|
+
const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 200);
|
|
2013
|
+
const action = url.searchParams.get("action");
|
|
2014
|
+
const userId = url.searchParams.get("user_id");
|
|
2015
|
+
let sql = "SELECT * FROM audit_log WHERE 1=1";
|
|
2016
|
+
const params: any[] = [];
|
|
2017
|
+
// Non-admin can only see own logs
|
|
2018
|
+
if (resolved.user.role !== "admin") { sql += ` AND user_id = ?${params.length + 1}`; params.push(resolved.user.user_id); }
|
|
2019
|
+
if (action) { sql += ` AND action = ?${params.length + 1}`; params.push(action); }
|
|
2020
|
+
if (userId && resolved.user.role === "admin") { sql += ` AND user_id = ?${params.length + 1}`; params.push(userId); }
|
|
2021
|
+
sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
|
|
2022
|
+
params.push(limit);
|
|
2023
|
+
const logs = db.all(sql, ...params);
|
|
2024
|
+
return withCors(req, Response.json({ ok: true, logs, count: logs.length }));
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
// ── REST: task events (V2 Sprint 2) ──
|
|
2028
|
+
if (url.pathname === "/api/task_events") {
|
|
2029
|
+
const taskId = url.searchParams.get("task_id");
|
|
2030
|
+
const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 500);
|
|
2031
|
+
let sql = "SELECT * FROM task_events WHERE 1=1";
|
|
2032
|
+
const params: any[] = [];
|
|
2033
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
2034
|
+
if (taskId) { sql += ` AND task_id = ?${params.length + 1}`; params.push(taskId); }
|
|
2035
|
+
sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
|
|
2036
|
+
params.push(limit);
|
|
2037
|
+
const rows = db.all(sql, ...params);
|
|
2038
|
+
return withCors(req, Response.json({ ok: true, events: rows, count: rows.length }));
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
// ── REST: delete node (Dashboard/CLI remote cleanup) ──
|
|
2042
|
+
const nodeDeleteMatch = url.pathname.match(/^\/api\/nodes\/([^/]+)$/);
|
|
2043
|
+
if (nodeDeleteMatch && req.method === "DELETE") {
|
|
2044
|
+
const ref = decodeURIComponent(nodeDeleteMatch[1]);
|
|
2045
|
+
const params: any[] = [ref, ref, ref];
|
|
2046
|
+
let sql = "SELECT * FROM nodes WHERE (node_id = ?1 OR node_name = ?2 OR alias = ?3)";
|
|
2047
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
2048
|
+
sql += " ORDER BY updated_at DESC LIMIT 1";
|
|
2049
|
+
const node = db.get<any>(sql, ...params);
|
|
2050
|
+
if (!node) return withCors(req, Response.json({ ok: false, error: "node not found" }, { status: 404 }));
|
|
2051
|
+
|
|
2052
|
+
const nodeNetId = node.network_id ?? singleNetworkId(restScope);
|
|
2053
|
+
if (!canRestWriteNetwork(restAuth, nodeNetId, isAdmin)) {
|
|
2054
|
+
return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
db.transaction(() => {
|
|
2058
|
+
db.run("DELETE FROM nodes WHERE node_id = ?1", [node.node_id]);
|
|
2059
|
+
if (node.alias) {
|
|
2060
|
+
db.run(
|
|
2061
|
+
"DELETE FROM sessions WHERE alias = ?1 AND (network_id = ?2 OR (?2 IS NULL AND network_id IS NULL))",
|
|
2062
|
+
[node.alias, node.network_id ?? null]
|
|
2063
|
+
);
|
|
2064
|
+
}
|
|
2065
|
+
});
|
|
2066
|
+
|
|
2067
|
+
if (node.alias) {
|
|
2068
|
+
pushEvent(node.alias, {
|
|
2069
|
+
type: "node_deleted",
|
|
2070
|
+
node_id: node.node_id,
|
|
2071
|
+
node_name: node.node_name,
|
|
2072
|
+
alias: node.alias,
|
|
2073
|
+
network_id: node.network_id ?? null,
|
|
2074
|
+
}, node.network_id ?? null);
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
return withCors(req, Response.json({
|
|
2078
|
+
ok: true,
|
|
2079
|
+
deleted: true,
|
|
2080
|
+
node_id: node.node_id,
|
|
2081
|
+
node_name: node.node_name,
|
|
2082
|
+
alias: node.alias,
|
|
2083
|
+
network_id: node.network_id ?? null,
|
|
2084
|
+
}));
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
// ── REST: single node config snapshot (RFC-024 B5) ──
|
|
2088
|
+
// Dashboard calls this for the snapshot path. Returns the masked
|
|
2089
|
+
// {model, flags, config_revision} the node last posted via
|
|
2090
|
+
// report_status (B6). NEVER reads per-node files. Network-scoped
|
|
2091
|
+
// via addNetworkScope so a netA viewer can't read a netB node.
|
|
2092
|
+
const nodeConfigMatch = url.pathname.match(/^\/api\/nodes\/([^/]+)\/config$/);
|
|
2093
|
+
if (nodeConfigMatch && req.method === "GET") {
|
|
2094
|
+
const nodeId = decodeURIComponent(nodeConfigMatch[1] ?? "");
|
|
2095
|
+
const params: any[] = [nodeId];
|
|
2096
|
+
let sql = "SELECT node_id, alias, network_id, config_revision, config_snapshot FROM nodes WHERE node_id = ?1";
|
|
2097
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
2098
|
+
sql += " LIMIT 1";
|
|
2099
|
+
const node = db.get<any>(sql, ...params);
|
|
2100
|
+
if (!node) {
|
|
2101
|
+
return withCors(req, Response.json({ ok: false, error: "node_not_found", node_id: nodeId }, { status: 404 }));
|
|
2102
|
+
}
|
|
2103
|
+
let snapshot: any = {};
|
|
2104
|
+
if (node.config_snapshot) {
|
|
2105
|
+
try { snapshot = JSON.parse(node.config_snapshot); } catch { snapshot = {}; }
|
|
2106
|
+
}
|
|
2107
|
+
return withCors(req, Response.json({
|
|
2108
|
+
ok: true,
|
|
2109
|
+
node_id: node.node_id,
|
|
2110
|
+
alias: node.alias,
|
|
2111
|
+
network_id: node.network_id,
|
|
2112
|
+
config_revision: node.config_revision || 0,
|
|
2113
|
+
model: snapshot.model ?? null,
|
|
2114
|
+
flags: snapshot.flags ?? {},
|
|
2115
|
+
config_update_capable: snapshot.config_update_capable ?? false,
|
|
2116
|
+
}));
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
// ── #462 REST: set / clear a node's custom avatar ──
|
|
2120
|
+
// PUT /api/nodes/:ref/avatar body: { "avatar_url": "https://…" | null }
|
|
2121
|
+
//
|
|
2122
|
+
// Deliberately NOT part of the RFC-024 config-apply pipeline: avatar
|
|
2123
|
+
// is a pure display attribute — nothing for agent-node to consume, no
|
|
2124
|
+
// config_revision / ack round-trip, no restart tier. Persisted on the
|
|
2125
|
+
// nodes row and served back via GET /api/nodes (cross-device).
|
|
2126
|
+
//
|
|
2127
|
+
// Auth: same write gate as node delete above — network-scoped lookup
|
|
2128
|
+
// + canRestWriteNetwork (member with role above viewer, or admin, or
|
|
2129
|
+
// legacy master token). Value passes validateAvatarUrl (http/https
|
|
2130
|
+
// only, ≤ 2048 chars, no control chars — XSS boundary, see
|
|
2131
|
+
// avatar-validate.ts).
|
|
2132
|
+
const nodeAvatarMatch = url.pathname.match(/^\/api\/nodes\/([^/]+)\/avatar$/);
|
|
2133
|
+
if (nodeAvatarMatch && req.method === "PUT") {
|
|
2134
|
+
let avatarBody: any;
|
|
2135
|
+
try {
|
|
2136
|
+
avatarBody = await req.json();
|
|
2137
|
+
} catch {
|
|
2138
|
+
return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 }));
|
|
2139
|
+
}
|
|
2140
|
+
const validated = validateAvatarUrl(avatarBody?.avatar_url);
|
|
2141
|
+
if (!validated.ok) {
|
|
2142
|
+
return withCors(req, Response.json({ ok: false, error: "invalid_avatar_url", reason: validated.reason }, { status: 400 }));
|
|
2143
|
+
}
|
|
2144
|
+
const ref = decodeURIComponent(nodeAvatarMatch[1]);
|
|
2145
|
+
const params: any[] = [ref, ref, ref];
|
|
2146
|
+
let sql = "SELECT node_id, alias, network_id FROM nodes WHERE (node_id = ?1 OR node_name = ?2 OR alias = ?3)";
|
|
2147
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
2148
|
+
sql += " ORDER BY updated_at DESC LIMIT 1";
|
|
2149
|
+
const node = db.get<any>(sql, ...params);
|
|
2150
|
+
if (!node) return withCors(req, Response.json({ ok: false, error: "node not found" }, { status: 404 }));
|
|
2151
|
+
|
|
2152
|
+
const nodeNetId = node.network_id ?? singleNetworkId(restScope);
|
|
2153
|
+
if (!canRestWriteNetwork(restAuth, nodeNetId, isAdmin)) {
|
|
2154
|
+
return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
db.run(
|
|
2158
|
+
"UPDATE nodes SET avatar_url = ?1, updated_at = datetime('now') WHERE node_id = ?2",
|
|
2159
|
+
[validated.value, node.node_id]
|
|
2160
|
+
);
|
|
2161
|
+
return withCors(req, Response.json({ ok: true, node_id: node.node_id, alias: node.alias, avatar_url: validated.value }));
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
// ── REST: nodes table (V2 Sprint 2) ──
|
|
2165
|
+
// Explicit column list — NOT `SELECT *`. Two reasons:
|
|
2166
|
+
// 1. `SELECT *` silently broadcasts any column added later to the
|
|
2167
|
+
// response (RFC-024 added config_revision/config_snapshot which
|
|
2168
|
+
// the dashboard's GET /api/nodes/:id/config endpoint owns;
|
|
2169
|
+
// RFC-028 will add more vault-flavored columns). Tests asserting
|
|
2170
|
+
// response shape break on every ALTER; integrations that key
|
|
2171
|
+
// off Object.keys() can pick up internals.
|
|
2172
|
+
// 2. The list endpoint and the per-node config snapshot endpoint
|
|
2173
|
+
// have different consumer contracts; this list should be the
|
|
2174
|
+
// stable, dashboard-facing fields only.
|
|
2175
|
+
// Add a new column to this list explicitly when a real client needs
|
|
2176
|
+
// it; do NOT switch back to SELECT *.
|
|
2177
|
+
// ── REST: list host_supervisor daemons (RFC-026 §9.2.2 / #338 PR2) ──
|
|
2178
|
+
// Mirror of the `list_host_supervisors` MCP tool for non-MCP callers.
|
|
2179
|
+
// Same SQL + role-extract + member-脱敏 logic; SEC-1 scoped via REST
|
|
2180
|
+
// auth pipeline (restScope). Returns {ok, daemons:[...], count}.
|
|
2181
|
+
if (url.pathname === "/api/host-supervisors") {
|
|
2182
|
+
// #380 — utok callers with exactly one accessible network get a
|
|
2183
|
+
// safe fallback so the create-node wizard doesn't have to bake
|
|
2184
|
+
// network_id into every request (it was hard-4xxing when the
|
|
2185
|
+
// dashboard omitted the query param — that's what "hub 400" was).
|
|
2186
|
+
// `singleNetworkId` returns:
|
|
2187
|
+
// - scope.networkId when it's set (ntok binding, or an
|
|
2188
|
+
// explicitly-requested network the user has verified access to)
|
|
2189
|
+
// - the sole member of scope.networkIds when the utok user
|
|
2190
|
+
// belongs to exactly one network (safe unambiguous fallback)
|
|
2191
|
+
// - null when the user belongs to 0 or 2+ networks (authz
|
|
2192
|
+
// boundary — refuse to guess which one, mirrors 通信龙 spec
|
|
2193
|
+
// "别 fallback 到错 network")
|
|
2194
|
+
// The 400 branch is retained for the ambiguous / no-access cases
|
|
2195
|
+
// and distinguishes them in the error message so the client can
|
|
2196
|
+
// recover (send network_id explicitly).
|
|
2197
|
+
const effectiveNetId = singleNetworkId(restScope);
|
|
2198
|
+
if (!effectiveNetId) {
|
|
2199
|
+
const memberships = restScope.networkIds?.length ?? 0;
|
|
2200
|
+
const error = memberships > 1
|
|
2201
|
+
? "network_id_required_multi"
|
|
2202
|
+
: "missing_network_id";
|
|
2203
|
+
return withCors(req, Response.json({ ok: false, error, memberships }, { status: 400 }));
|
|
2204
|
+
}
|
|
2205
|
+
// Role for member-脱敏 (admin/owner = full host_telemetry, others = masked)
|
|
2206
|
+
let isPrivileged = false;
|
|
2207
|
+
if (restAuth?.userId) {
|
|
2208
|
+
const role = getUserNetworkRole(restAuth.userId, effectiveNetId);
|
|
2209
|
+
isPrivileged = role === "admin" || role === "owner";
|
|
2210
|
+
} else {
|
|
2211
|
+
// Network-token caller (no user) — treat as privileged within its network
|
|
2212
|
+
isPrivileged = true;
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
// EXISTS subquery handles BOTH revoked_at SET and DELETEd token
|
|
2216
|
+
// rows (revokeToken DELETEs the row in production — PR2 v1
|
|
2217
|
+
// SHOULD-FIX per 通信龙 audit). EXISTS naturally dedupes daemons
|
|
2218
|
+
// with rotated tokens.
|
|
2219
|
+
const sqlRows = db.all<Record<string, any>>(`
|
|
2220
|
+
SELECT
|
|
2221
|
+
n.node_id, n.alias, n.hostname, n.network_id,
|
|
2222
|
+
n.runtimes_supported, n.allowed_secret_keys,
|
|
2223
|
+
n.created_at, n.updated_at,
|
|
2224
|
+
s.last_seen_at AS session_last_seen,
|
|
2225
|
+
s.cpu_cores AS session_cpu_cores,
|
|
2226
|
+
s.mem_total_gb AS session_mem_total_gb,
|
|
2227
|
+
s.ip AS session_ip,
|
|
2228
|
+
n.config_snapshot
|
|
2229
|
+
FROM nodes n
|
|
2230
|
+
LEFT JOIN sessions s ON s.alias = n.alias AND (s.network_id = n.network_id OR s.network_id IS NULL)
|
|
2231
|
+
WHERE n.network_id = ?1
|
|
2232
|
+
AND EXISTS (
|
|
2233
|
+
SELECT 1 FROM api_tokens t
|
|
2234
|
+
WHERE t.network_id = n.network_id
|
|
2235
|
+
AND t.name = 'node:' || n.alias
|
|
2236
|
+
AND t.revoked_at IS NULL
|
|
2237
|
+
)
|
|
2238
|
+
ORDER BY n.updated_at DESC
|
|
2239
|
+
`, effectiveNetId);
|
|
2240
|
+
|
|
2241
|
+
const nowMs = Date.now();
|
|
2242
|
+
const ONLINE_MS = 60_000;
|
|
2243
|
+
const daemons = sqlRows
|
|
2244
|
+
.map(r => {
|
|
2245
|
+
let snapRole: string | null = null;
|
|
2246
|
+
if (r.config_snapshot) {
|
|
2247
|
+
try {
|
|
2248
|
+
const parsed = typeof r.config_snapshot === "string" ? JSON.parse(r.config_snapshot) : r.config_snapshot;
|
|
2249
|
+
snapRole = typeof parsed?.role === "string" ? parsed.role : null;
|
|
2250
|
+
} catch { /* malformed */ }
|
|
2251
|
+
}
|
|
2252
|
+
return { row: r, role: snapRole };
|
|
2253
|
+
})
|
|
2254
|
+
.filter(({ role }) => role === "host_supervisor")
|
|
2255
|
+
.map(({ row: r }) => {
|
|
2256
|
+
let online = false;
|
|
2257
|
+
let lastSeenAt: string | null = null;
|
|
2258
|
+
if (r.session_last_seen) {
|
|
2259
|
+
lastSeenAt = r.session_last_seen;
|
|
2260
|
+
const t = Date.parse(r.session_last_seen);
|
|
2261
|
+
if (!isNaN(t)) online = (nowMs - t) <= ONLINE_MS;
|
|
2262
|
+
}
|
|
2263
|
+
let runtimes: string[] = [];
|
|
2264
|
+
let secrets: string[] = [];
|
|
2265
|
+
try {
|
|
2266
|
+
if (r.runtimes_supported) {
|
|
2267
|
+
const parsed = JSON.parse(r.runtimes_supported);
|
|
2268
|
+
runtimes = Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string") : [];
|
|
2269
|
+
}
|
|
2270
|
+
} catch {}
|
|
2271
|
+
try {
|
|
2272
|
+
if (r.allowed_secret_keys) {
|
|
2273
|
+
const parsed = JSON.parse(r.allowed_secret_keys);
|
|
2274
|
+
secrets = Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string") : [];
|
|
2275
|
+
}
|
|
2276
|
+
} catch {}
|
|
2277
|
+
const telemetry: Record<string, unknown> = {
|
|
2278
|
+
alert_level: online ? "green" : "gray",
|
|
2279
|
+
};
|
|
2280
|
+
if (isPrivileged) {
|
|
2281
|
+
telemetry.cpu_cores = r.session_cpu_cores ?? null;
|
|
2282
|
+
telemetry.mem_gb = r.session_mem_total_gb ?? null;
|
|
2283
|
+
telemetry.ip_internal = r.session_ip ?? null;
|
|
2284
|
+
}
|
|
2285
|
+
return {
|
|
2286
|
+
daemon_node_id: r.node_id,
|
|
2287
|
+
alias: r.alias,
|
|
2288
|
+
hostname: r.hostname,
|
|
2289
|
+
online,
|
|
2290
|
+
last_seen_at: lastSeenAt,
|
|
2291
|
+
runtimes_supported: runtimes,
|
|
2292
|
+
allowed_secret_keys: secrets,
|
|
2293
|
+
host_telemetry: telemetry,
|
|
2294
|
+
};
|
|
2295
|
+
});
|
|
2296
|
+
return withCors(req, Response.json({ ok: true, daemons, count: daemons.length }));
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
if (url.pathname === "/api/nodes") {
|
|
2300
|
+
const nodeId = url.searchParams.get("node_id");
|
|
2301
|
+
const alias = url.searchParams.get("alias");
|
|
2302
|
+
// `config_snapshot` SELECTed ONLY to extract the daemon `role` field
|
|
2303
|
+
// for dashboard discovery — full snapshot is NOT broadcast (would
|
|
2304
|
+
// re-introduce the #312 SELECT * fragility). Mapping step pulls
|
|
2305
|
+
// `role` out then drops `config_snapshot` from the response row.
|
|
2306
|
+
// RFC-027 PR2 dashboard prereq: lifecycle_state is needed
|
|
2307
|
+
// client-side to drive the ⋮ menu (active → "停止/删除";
|
|
2308
|
+
// stopped → "重启/删除"). Hub schema gained the column at PR1
|
|
2309
|
+
// (#345) but /api/nodes never SELECTed it. Following #312
|
|
2310
|
+
// "explicit SELECT" discipline — extend the projection here,
|
|
2311
|
+
// do NOT switch to SELECT *.
|
|
2312
|
+
// #462: avatar_url added to the projection so custom avatars are
|
|
2313
|
+
// cross-device (dashboard hydrates from here; localStorage was the
|
|
2314
|
+
// only store before). Nullable — null means "use default set".
|
|
2315
|
+
let sql = `SELECT node_id, node_name, alias, runtime, model,
|
|
2316
|
+
config_path, channels, server, hostname,
|
|
2317
|
+
network_id, created_at, updated_at,
|
|
2318
|
+
config_snapshot, lifecycle_state, avatar_url
|
|
2319
|
+
FROM nodes WHERE 1=1`;
|
|
2320
|
+
const params: any[] = [];
|
|
2321
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
2322
|
+
if (nodeId) { sql += ` AND node_id = ?${params.length + 1}`; params.push(nodeId); }
|
|
2323
|
+
if (alias) { sql += ` AND alias = ?${params.length + 1}`; params.push(alias); }
|
|
2324
|
+
sql += " ORDER BY updated_at DESC";
|
|
2325
|
+
const rawRows = db.all<Record<string, any>>(sql, ...params);
|
|
2326
|
+
// Add a single new field `role: string | null` per row, extracted from
|
|
2327
|
+
// config_snapshot JSON. Daemon nodes that posted role='host_supervisor'
|
|
2328
|
+
// via report_status surface here; dashboard uses this for daemon
|
|
2329
|
+
// discovery (replacing the prior `node_daemon_` prefix heuristic).
|
|
2330
|
+
// RFC-026 P1 P2 §9 will supersede this REST proxy with the
|
|
2331
|
+
// `list_host_supervisors` MCP tool.
|
|
2332
|
+
const rows = rawRows.map(r => {
|
|
2333
|
+
let role: string | null = null;
|
|
2334
|
+
const snap = r.config_snapshot;
|
|
2335
|
+
if (snap) {
|
|
2336
|
+
try { role = (typeof snap === "string" ? JSON.parse(snap) : snap)?.role ?? null; }
|
|
2337
|
+
catch { /* malformed snapshot — leave role null */ }
|
|
2338
|
+
}
|
|
2339
|
+
const { config_snapshot, ...rest } = r;
|
|
2340
|
+
return { ...rest, role };
|
|
2341
|
+
});
|
|
2342
|
+
return withCors(req, Response.json({ ok: true, nodes: rows, count: rows.length }));
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
// ── REST: single task lookup (V2) ──
|
|
2346
|
+
const taskPathMatch = url.pathname.match(/^\/api\/tasks?\/([^/]+)$/);
|
|
2347
|
+
if (taskPathMatch && req.method === "GET") {
|
|
2348
|
+
const taskId = decodeURIComponent(taskPathMatch[1] ?? "");
|
|
2349
|
+
const params: any[] = [taskId];
|
|
2350
|
+
let sql = "SELECT * FROM tasks WHERE task_id = ?1";
|
|
2351
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
2352
|
+
sql += " LIMIT 1";
|
|
2353
|
+
const task = db.get(sql, ...params);
|
|
2354
|
+
if (!task) {
|
|
2355
|
+
return withCors(req, Response.json({ ok: false, error: "task_not_found", task_id: taskId }, { status: 404 }));
|
|
2356
|
+
}
|
|
2357
|
+
return withCors(req, Response.json({ ok: true, task }));
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
// ── REST: tasks table (V2) ──
|
|
2361
|
+
if (url.pathname === "/api/tasks") {
|
|
2362
|
+
const taskId = url.searchParams.get("task_id");
|
|
2363
|
+
const status = url.searchParams.get("status");
|
|
2364
|
+
const toName = url.searchParams.get("to_name");
|
|
2365
|
+
const fromName = url.searchParams.get("from_name");
|
|
2366
|
+
const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 200);
|
|
2367
|
+
// #248 — opt-out of the stats GROUP-BY scan. The dashboard chat panel
|
|
2368
|
+
// never consumes `stats`; it's a global per-status table scan that
|
|
2369
|
+
// dominates the request time on a large DB (270 MB / many tasks).
|
|
2370
|
+
// Existing callers that don't pass `skip_stats=1` get the same
|
|
2371
|
+
// {ok, tasks, count, stats} shape — backwards-compatible.
|
|
2372
|
+
const skipStats = url.searchParams.get("skip_stats") === "1";
|
|
2373
|
+
|
|
2374
|
+
let sql = "SELECT * FROM tasks WHERE 1=1";
|
|
2375
|
+
const params: any[] = [];
|
|
2376
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
2377
|
+
if (taskId) { sql += ` AND task_id = ?${params.length + 1}`; params.push(taskId); }
|
|
2378
|
+
if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
|
|
2379
|
+
if (toName) { sql += ` AND to_name = ?${params.length + 1}`; params.push(toName); }
|
|
2380
|
+
if (fromName) { sql += ` AND from_name = ?${params.length + 1}`; params.push(fromName); }
|
|
2381
|
+
sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
|
|
2382
|
+
params.push(limit);
|
|
2383
|
+
|
|
2384
|
+
const rows = db.all(sql, ...params);
|
|
2385
|
+
if (skipStats) {
|
|
2386
|
+
return withCors(req, Response.json({ ok: true, tasks: rows, count: rows.length }));
|
|
2387
|
+
}
|
|
2388
|
+
const statsParams: any[] = [];
|
|
2389
|
+
let statsSql = "SELECT status, COUNT(*) as count FROM tasks WHERE 1=1";
|
|
2390
|
+
statsSql = addNetworkScope(statsSql, statsParams, restScope);
|
|
2391
|
+
statsSql += " GROUP BY status";
|
|
2392
|
+
const stats = db.all<any>(statsSql, ...statsParams);
|
|
2393
|
+
return withCors(req, Response.json({ ok: true, tasks: rows, count: rows.length, stats }));
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
// ── REST: recent completions ──
|
|
2397
|
+
if (url.pathname === "/api/completions") {
|
|
2398
|
+
const since = url.searchParams.get("since") ?? new Date(Date.now() - 86400000).toISOString();
|
|
2399
|
+
const params: any[] = [since];
|
|
2400
|
+
let sql = "SELECT * FROM completions WHERE completed_at >= ?1";
|
|
2401
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
2402
|
+
sql += " ORDER BY completed_at DESC LIMIT 100";
|
|
2403
|
+
const rows = db.all(sql, ...params);
|
|
2404
|
+
return withCors(req, Response.json({ ok: true, completions: rows }));
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
return withCors(req, new Response(
|
|
2408
|
+
`CommHub MCP Server v${SERVER_VERSION} (Streamable HTTP + SSE Push)
|
|
2409
|
+
|
|
2410
|
+
Endpoints:
|
|
2411
|
+
POST /mcp - MCP Streamable HTTP (for Claude Code / Codex)
|
|
2412
|
+
GET /events/:session - SSE realtime push (Agent subscribes here)
|
|
2413
|
+
GET /health - Health check
|
|
2414
|
+
GET /api/status - All sessions (auth required)
|
|
2415
|
+
POST /api/task - Send task via REST (auth required)
|
|
2416
|
+
GET /api/tasks - Tasks table (V2) (auth required)
|
|
2417
|
+
GET /api/completions - Recent completions (auth required)
|
|
2418
|
+
GET /api/tmux/:name - Capture tmux pane output (${TMUX_ENABLED ? "admin + localhost/allowlist required" : "disabled"})
|
|
2419
|
+
POST /api/tmux/:name/send - Send keys to tmux (${TMUX_ENABLED ? "admin + localhost/allowlist required" : "disabled"})
|
|
2420
|
+
|
|
2421
|
+
Security: ${SECURITY_LABEL}
|
|
2422
|
+
`,
|
|
2423
|
+
{ status: 200, headers: { "Content-Type": "text/plain; charset=utf-8" } } // #426
|
|
2424
|
+
));
|
|
2425
|
+
},
|
|
2426
|
+
|
|
2427
|
+
// ── WebSocket handler for tmux terminal streaming ──
|
|
2428
|
+
websocket: {
|
|
2429
|
+
open(ws) {
|
|
2430
|
+
const { tmuxName } = ws.data as unknown as { tmuxName: string };
|
|
2431
|
+
console.log(`[ws] tmux terminal opened: ${tmuxName}`);
|
|
2432
|
+
let lastOutput = "";
|
|
2433
|
+
|
|
2434
|
+
// Poll capture-pane every 200ms and send diffs
|
|
2435
|
+
const interval = setInterval(async () => {
|
|
2436
|
+
try {
|
|
2437
|
+
const proc = Bun.spawn(["tmux", "capture-pane", "-t", tmuxName, "-p", "-e"], {
|
|
2438
|
+
stdout: "pipe", stderr: "pipe",
|
|
2439
|
+
});
|
|
2440
|
+
const output = await new Response(proc.stdout).text();
|
|
2441
|
+
const code = await proc.exited;
|
|
2442
|
+
if (code !== 0) return;
|
|
2443
|
+
|
|
2444
|
+
if (output !== lastOutput) {
|
|
2445
|
+
lastOutput = output;
|
|
2446
|
+
ws.send(JSON.stringify({ type: "output", data: output }));
|
|
2447
|
+
}
|
|
2448
|
+
} catch { /* session gone */ }
|
|
2449
|
+
}, 200);
|
|
2450
|
+
|
|
2451
|
+
wsTmuxIntervals.set(ws, interval);
|
|
2452
|
+
|
|
2453
|
+
// Send initial capture immediately
|
|
2454
|
+
(async () => {
|
|
2455
|
+
try {
|
|
2456
|
+
const proc = Bun.spawn(["tmux", "capture-pane", "-t", tmuxName, "-p", "-e"], {
|
|
2457
|
+
stdout: "pipe", stderr: "pipe",
|
|
2458
|
+
});
|
|
2459
|
+
const output = await new Response(proc.stdout).text();
|
|
2460
|
+
const code = await proc.exited;
|
|
2461
|
+
if (code === 0) {
|
|
2462
|
+
lastOutput = output;
|
|
2463
|
+
ws.send(JSON.stringify({ type: "output", data: output }));
|
|
2464
|
+
} else {
|
|
2465
|
+
const err = await new Response(proc.stderr).text();
|
|
2466
|
+
ws.send(JSON.stringify({ type: "error", data: err.trim() || "tmux session not found" }));
|
|
2467
|
+
}
|
|
2468
|
+
} catch (e) {
|
|
2469
|
+
ws.send(JSON.stringify({ type: "error", data: (e as Error).message }));
|
|
2470
|
+
}
|
|
2471
|
+
})();
|
|
2472
|
+
},
|
|
2473
|
+
|
|
2474
|
+
async message(ws, message) {
|
|
2475
|
+
const { tmuxName } = ws.data as unknown as { tmuxName: string };
|
|
2476
|
+
try {
|
|
2477
|
+
const msg = JSON.parse(typeof message === "string" ? message : new TextDecoder().decode(message));
|
|
2478
|
+
|
|
2479
|
+
if (msg.type === "input" && typeof msg.data === "string") {
|
|
2480
|
+
// Send individual characters/sequences via send-keys
|
|
2481
|
+
const proc = Bun.spawn(["tmux", "send-keys", "-t", tmuxName, "-l", msg.data], {
|
|
2482
|
+
stdout: "pipe", stderr: "pipe",
|
|
2483
|
+
});
|
|
2484
|
+
await proc.exited;
|
|
2485
|
+
} else if (msg.type === "key" && typeof msg.data === "string") {
|
|
2486
|
+
// Send special key names (Enter, C-c, etc.)
|
|
2487
|
+
const proc = Bun.spawn(["tmux", "send-keys", "-t", tmuxName, msg.data], {
|
|
2488
|
+
stdout: "pipe", stderr: "pipe",
|
|
2489
|
+
});
|
|
2490
|
+
await proc.exited;
|
|
2491
|
+
} else if (msg.type === "resize" && msg.cols && msg.rows) {
|
|
2492
|
+
// Resize tmux pane
|
|
2493
|
+
Bun.spawn(["tmux", "resize-window", "-t", tmuxName, "-x", String(msg.cols), "-y", String(msg.rows)], {
|
|
2494
|
+
stdout: "pipe", stderr: "pipe",
|
|
2495
|
+
});
|
|
2496
|
+
}
|
|
2497
|
+
} catch { /* ignore malformed messages */ }
|
|
2498
|
+
},
|
|
2499
|
+
|
|
2500
|
+
close(ws) {
|
|
2501
|
+
const { tmuxName } = ws.data as unknown as { tmuxName: string };
|
|
2502
|
+
console.log(`[ws] tmux terminal closed: ${tmuxName}`);
|
|
2503
|
+
const interval = wsTmuxIntervals.get(ws);
|
|
2504
|
+
if (interval) { clearInterval(interval); wsTmuxIntervals.delete(ws); }
|
|
2505
|
+
},
|
|
2506
|
+
},
|
|
2507
|
+
});
|
|
2508
|
+
} // end bootServer
|
|
2509
|
+
|
|
2510
|
+
// ── Explicit startup (#438 corrective / 通信龙派单) ──────────────────
|
|
2511
|
+
//
|
|
2512
|
+
// Importing this module NO LONGER binds a port. The two failure modes
|
|
2513
|
+
// this design dodges, both field-verified:
|
|
2514
|
+
//
|
|
2515
|
+
// 1. `if (import.meta.main)` guard — the published package's process
|
|
2516
|
+
// entry is bin/commhub.ts which DYNAMICALLY imports this module, so
|
|
2517
|
+
// import.meta.main is always false there: the process comes up but
|
|
2518
|
+
// listens on nothing (#438 review finding; would have shipped dead).
|
|
2519
|
+
// 2. Module-level `export const server = bootServer()` side effect —
|
|
2520
|
+
// fixes (1) but makes EVERY import bind a port: tooling scripts,
|
|
2521
|
+
// typechecks, and unrelated tests all grab the port by accident.
|
|
2522
|
+
//
|
|
2523
|
+
// The contract instead: bin/commhub.ts (and anything else that wants a
|
|
2524
|
+
// running hub) calls `startHub()` explicitly. Tests that need a private
|
|
2525
|
+
// throwaway instance keep using `bootServer({ port: 0 })`, which stays
|
|
2526
|
+
// side-effect-free (no sweepers / signal handlers / banner).
|
|
2527
|
+
//
|
|
2528
|
+
// startHub is SINGLE-SHOT and observable: a second call throws with the
|
|
2529
|
+
// first boot's coordinates rather than silently no-op'ing — "I thought I
|
|
2530
|
+
// started it but nothing happened" is the same failure family as (1)
|
|
2531
|
+
// (承 通信测试马 verification design requirement).
|
|
2532
|
+
|
|
2533
|
+
let startedHub: { server: ReturnType<typeof Bun.serve>; startedAt: string } | null = null;
|
|
2534
|
+
|
|
2535
|
+
/** The hub instance startHub() booted, or null before startHub() runs.
|
|
2536
|
+
* Read-only introspection for diagnostics; do not boot through this. */
|
|
2537
|
+
export function getStartedHub(): { server: ReturnType<typeof Bun.serve>; startedAt: string } | null {
|
|
2538
|
+
return startedHub;
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
export function startHub(opts?: { port?: number; hostname?: string }): ReturnType<typeof Bun.serve> {
|
|
2542
|
+
if (startedHub) {
|
|
2543
|
+
throw new Error(
|
|
2544
|
+
`startHub() already ran at ${startedHub.startedAt} (listening on ` +
|
|
2545
|
+
`${startedHub.server.hostname}:${startedHub.server.port}) — it is single-shot ` +
|
|
2546
|
+
`by design (sweepers + signal handlers must register exactly once). ` +
|
|
2547
|
+
`For an additional throwaway instance use bootServer({ port: 0 }).`
|
|
2548
|
+
);
|
|
2549
|
+
}
|
|
2550
|
+
const server = bootServer(opts);
|
|
2551
|
+
|
|
2552
|
+
// Round-2/4 review ② — periodic retention sweep + incremental VACUUM.
|
|
2553
|
+
// Sweeps every hour by default. Operators can disable any single table
|
|
2554
|
+
// by setting COMMHUB_RETENTION_*_DAYS to a negative value, or shorten
|
|
2555
|
+
// the sweep window via COMMHUB_RETENTION_SWEEP_MINUTES.
|
|
2556
|
+
const sweepIntervalMinutes = Number(process.env.COMMHUB_RETENTION_SWEEP_MINUTES);
|
|
2557
|
+
const sweepIntervalMs = Number.isFinite(sweepIntervalMinutes) && sweepIntervalMinutes > 0
|
|
2558
|
+
? sweepIntervalMinutes * 60 * 1000
|
|
2559
|
+
: 60 * 60 * 1000;
|
|
2560
|
+
const retentionSweeperTimer = startRetentionSweeper(sweepIntervalMs);
|
|
2561
|
+
|
|
2562
|
+
// Round-2/4 review ③ — stale session sweeper (coexists with the
|
|
2563
|
+
// retention sweeper above; different concerns + different cadences).
|
|
2564
|
+
// Replaces the per-request UPDATE in GET /api/status (+ /api/servers,
|
|
2565
|
+
// /api/server-detail/*, MCP get_all_status). Each of those endpoints
|
|
2566
|
+
// used to run UPDATE on the sessions table just to maintain the
|
|
2567
|
+
// derived `offline` status; with the dashboard polling fast that was
|
|
2568
|
+
// 99% no-op write-amp under hot read paths. Now done globally once
|
|
2569
|
+
// every COMMHUB_STALE_SWEEP_SECONDS (default 60s).
|
|
2570
|
+
const staleSweeperTimer = startStaleSessionSweeper();
|
|
2571
|
+
|
|
2572
|
+
// #476 — the two legacy module-level intervals, now owned by startHub
|
|
2573
|
+
// like every other periodic job: rate-limit map cleanup (memory only)
|
|
2574
|
+
// and task expiration patrol (writes DB — must never run on import).
|
|
2575
|
+
// Periods env-overridable so tests can observe a real firing without
|
|
2576
|
+
// waiting minutes (mirrors COMMHUB_RETENTION_SWEEP_MINUTES above).
|
|
2577
|
+
const rateLimitSweepMs = Number(process.env.COMMHUB_RATELIMIT_SWEEP_MS) > 0
|
|
2578
|
+
? Number(process.env.COMMHUB_RATELIMIT_SWEEP_MS) : 300000;
|
|
2579
|
+
const taskPatrolMs = Number(process.env.COMMHUB_TASK_PATROL_MS) > 0
|
|
2580
|
+
? Number(process.env.COMMHUB_TASK_PATROL_MS) : 5 * 60 * 1000;
|
|
2581
|
+
const rateLimitSweepTimer = setInterval(sweepStaleRateLimits, rateLimitSweepMs);
|
|
2582
|
+
const taskPatrolTimer = setInterval(patrolExpiredTasks, taskPatrolMs);
|
|
2583
|
+
|
|
2584
|
+
// The Bun.serve socket is what keeps the process alive; the periodic
|
|
2585
|
+
// jobs must not — otherwise a test (or a future caller) that stops the
|
|
2586
|
+
// server would still hang on live intervals it cannot reach.
|
|
2587
|
+
(retentionSweeperTimer as any)?.unref?.();
|
|
2588
|
+
(staleSweeperTimer as any)?.unref?.();
|
|
2589
|
+
(rateLimitSweepTimer as any)?.unref?.();
|
|
2590
|
+
(taskPatrolTimer as any)?.unref?.();
|
|
2591
|
+
|
|
2592
|
+
// ── Graceful shutdown ───────────────────────────────
|
|
2593
|
+
function shutdown() {
|
|
2594
|
+
console.log("[commhub] shutting down...");
|
|
2595
|
+
clearInterval(retentionSweeperTimer);
|
|
2596
|
+
clearInterval(staleSweeperTimer);
|
|
2597
|
+
clearInterval(rateLimitSweepTimer);
|
|
2598
|
+
clearInterval(taskPatrolTimer);
|
|
2599
|
+
db.close();
|
|
2600
|
+
process.exit(0);
|
|
2601
|
+
}
|
|
2602
|
+
process.on("SIGTERM", shutdown);
|
|
2603
|
+
process.on("SIGINT", shutdown);
|
|
2604
|
+
|
|
2605
|
+
// Banner reports the ACTUAL bound address (matters for port 0).
|
|
2606
|
+
const bHost = server.hostname ?? HOST;
|
|
2607
|
+
const bPort = server.port ?? PORT;
|
|
2608
|
+
console.log(`
|
|
2609
|
+
╔══════════════════════════════════════════════════╗
|
|
2610
|
+
║ CommHub MCP Server v${SERVER_VERSION} ║
|
|
2611
|
+
║ Transport: Streamable HTTP (Bun native) ║
|
|
2612
|
+
║ Security: ${SECURITY_LABEL}${" ".repeat(Math.max(0, 33 - SECURITY_LABEL.length))}║
|
|
2613
|
+
║ Tmux: ${TMUX_ENABLED ? "ENABLED (admin + localhost/allowlist)" : "DISABLED (set COMMHUB_ENABLE_TMUX=1)"}${" ".repeat(Math.max(0, TMUX_ENABLED ? 0 : 2))}║
|
|
2614
|
+
║ ║
|
|
2615
|
+
║ MCP: http://${bHost}:${bPort}/mcp ║
|
|
2616
|
+
║ REST: http://${bHost}:${bPort}/api ║
|
|
2617
|
+
║ Health: http://${bHost}:${bPort}/health ║
|
|
2618
|
+
╚══════════════════════════════════════════════════╝
|
|
2619
|
+
`);
|
|
2620
|
+
|
|
2621
|
+
// RFC-028 P1 boot banner — vault key configuration status.
|
|
2622
|
+
// F2 invariant: hub MUST boot regardless of vault state. Banner-only
|
|
2623
|
+
// (informational), fire-and-forget so startHub stays synchronous;
|
|
2624
|
+
// errors are raised lazily at vault op time. needsKeyToOp=true means
|
|
2625
|
+
// the operator should set ANET_HUB_SECRET_VAULT_KEY before
|
|
2626
|
+
// vault/provider features will work.
|
|
2627
|
+
import("./vault.js").then(({ vaultStatusForBoot }) => {
|
|
2628
|
+
const s = vaultStatusForBoot();
|
|
2629
|
+
if (s.needsKeyToOp) {
|
|
2630
|
+
console.warn("[rfc-028 vault] ⚠️ network_secrets/providers have data BUT ANET_HUB_SECRET_VAULT_KEY is unset — vault ops will throw vault_master_key_missing until you set the env. Generate one: `openssl rand -hex 32` (must match the key used at write time).");
|
|
2631
|
+
} else if (s.configured) {
|
|
2632
|
+
console.log(`[rfc-028 vault] master key configured (tables_have_data=${s.tablesHaveData})`);
|
|
2633
|
+
} else {
|
|
2634
|
+
console.log("[rfc-028 vault] master key not set + no vault rows — vault gating is lazy; boot OK. Set ANET_HUB_SECRET_VAULT_KEY when enabling provider/secret features.");
|
|
2635
|
+
}
|
|
2636
|
+
}).catch((e: any) => {
|
|
2637
|
+
console.warn(`[rfc-028 vault] boot banner skipped (${e?.message || e})`);
|
|
2638
|
+
});
|
|
2639
|
+
|
|
2640
|
+
startedHub = { server, startedAt: new Date().toISOString() };
|
|
2641
|
+
return server;
|
|
2642
|
+
}
|