@sleep2agi/commhub-server 0.9.0-preview.3 → 0.9.0-preview.31

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.
Files changed (102) hide show
  1. package/README.md +125 -7
  2. package/bin/commhub.ts +10 -2
  3. package/package.json +5 -5
  4. package/src/ack-create-request-transport.test.ts +159 -0
  5. package/src/ack-create-request.test.ts +331 -0
  6. package/src/admin-networks-http.test.ts +84 -0
  7. package/src/alias-filter.test.ts +72 -0
  8. package/src/alias-filter.ts +32 -0
  9. package/src/api-host-supervisors-fallback.test.ts +267 -0
  10. package/src/api-nodes-shape.test.ts +188 -0
  11. package/src/auth.ts +93 -14
  12. package/src/avatar-validate.test.ts +163 -0
  13. package/src/avatar-validate.ts +103 -0
  14. package/src/boot-side-effect.test.ts +124 -0
  15. package/src/config-apply-sec1.test.ts +107 -0
  16. package/src/config-apply-validate.test.ts +92 -0
  17. package/src/config-apply-validate.ts +81 -3
  18. package/src/create-node.ts +48 -1
  19. package/src/dashboard-slash-routing-http.test.ts +103 -0
  20. package/src/db-adapter-guard.test.ts +85 -0
  21. package/src/db-adapter.ts +64 -23
  22. package/src/db.ts +522 -32
  23. package/src/ext-token-shared.test.ts +60 -0
  24. package/src/ext-validation-regression.test.ts +131 -0
  25. package/src/external-schedule-edits-http.test.ts +316 -0
  26. package/src/external-schedule-edits.ts +314 -0
  27. package/src/file-download-authz-dev-open.test.ts +135 -0
  28. package/src/file-download-authz.test.ts +403 -0
  29. package/src/file-download-date-bucket.test.ts +436 -0
  30. package/src/file-network-scope-dev-open.test.ts +97 -0
  31. package/src/file-network-scope.test.ts +731 -0
  32. package/src/health-redaction.test.ts +637 -0
  33. package/src/index.ts +15 -2172
  34. package/src/lifecycle-guard.ts +50 -0
  35. package/src/list-host-supervisors.test.ts +314 -0
  36. package/src/mcp-write-network-resolution.test.ts +429 -0
  37. package/src/network-name-http.test.ts +71 -0
  38. package/src/network-scope.ts +101 -0
  39. package/src/node-attrs-validate.ts +88 -0
  40. package/src/node-attrs.test.ts +213 -0
  41. package/src/observer-avatar-http.test.ts +349 -0
  42. package/src/observer-push.test.ts +176 -0
  43. package/src/package-version-consistency.test.ts +25 -0
  44. package/src/peer-reply-atomic.test.ts +455 -0
  45. package/src/peer-reply-capability.test.ts +70 -0
  46. package/src/probe-validate.test.ts +221 -0
  47. package/src/probe-validate.ts +104 -0
  48. package/src/probe.test.ts +80 -0
  49. package/src/probe.ts +200 -0
  50. package/src/push.ts +96 -7
  51. package/src/require-explicit-test-db.ts +9 -0
  52. package/src/resolve-port.test.ts +57 -0
  53. package/src/resolve-port.ts +44 -0
  54. package/src/response-charset.test.ts +130 -0
  55. package/src/rest-explicit-columns-http.test.ts +211 -0
  56. package/src/rest-identity.test.ts +129 -0
  57. package/src/rest-identity.ts +100 -0
  58. package/src/rest-projections.ts +67 -0
  59. package/src/rest-write-network-resolution.test.ts +133 -0
  60. package/src/runtime-label.test.ts +22 -0
  61. package/src/runtime-label.ts +21 -0
  62. package/src/scheduled-run-terminal.test.ts +268 -0
  63. package/src/scheduled-tasks-http.test.ts +479 -0
  64. package/src/scheduled-tasks.ts +549 -0
  65. package/src/send-reply-agent-warning.test.ts +202 -0
  66. package/src/send-reply-attachments.test.ts +437 -0
  67. package/src/server.ts +3508 -0
  68. package/src/shared/external-schedule-contract.ts +60 -0
  69. package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
  70. package/src/shared/probe-host-allowlist.ts +128 -0
  71. package/src/side-thread-command-transport.test.ts +302 -0
  72. package/src/side-thread-command-transport.ts +318 -0
  73. package/src/side-thread-http-integration.test.ts +229 -0
  74. package/src/side-thread-http.ts +504 -0
  75. package/src/side-thread-production.test.ts +72 -0
  76. package/src/side-thread-production.ts +118 -0
  77. package/src/side-thread.test.ts +1067 -0
  78. package/src/side-thread.ts +2495 -0
  79. package/src/skillhub-http.test.ts +88 -0
  80. package/src/stop-delete-node.test.ts +720 -0
  81. package/src/task-auth-origin-http.test.ts +87 -0
  82. package/src/task-auth-origin.test.ts +24 -0
  83. package/src/task-auth-origin.ts +7 -0
  84. package/src/task-consumption.test.ts +398 -0
  85. package/src/task-diagnostic-http.test.ts +145 -0
  86. package/src/task-diagnostic.test.ts +115 -0
  87. package/src/task-diagnostic.ts +118 -0
  88. package/src/task-idempotency-mcp.test.ts +142 -0
  89. package/src/task-idempotency.test.ts +60 -0
  90. package/src/task-idempotency.ts +75 -0
  91. package/src/task-lifecycle-watcher.test.ts +200 -0
  92. package/src/task-lifecycle-watcher.ts +49 -0
  93. package/src/task-network-resolution-http.test.ts +137 -0
  94. package/src/tasks-pagination-http.test.ts +137 -0
  95. package/src/tools.ts +2312 -169
  96. package/src/update-provider.test.ts +234 -0
  97. package/src/upload-early-reject-guard.test.ts +155 -0
  98. package/src/uploads-http.test.ts +92 -14
  99. package/src/uploads.test.ts +66 -0
  100. package/src/uploads.ts +130 -5
  101. package/src/vault.test.ts +200 -0
  102. package/src/vault.ts +156 -0
package/src/server.ts ADDED
@@ -0,0 +1,3508 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { resolvePort } from "./resolve-port.js";
3
+ import { normalizeSessionRuntime } from "./runtime-label.js";
4
+ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
5
+ import { z } from "zod/v4";
6
+ import { registerTools } from "./tools.js";
7
+ import { db, logTaskEvent, logAudit, syncScheduledRunForTask } from "./db.js";
8
+ import { createSSEStream, createNetworkObserverStream, pushEvent, pushNetworkObserverEvent, getSSEStats, PRINTABLE_OBSERVER_KEY_PREFIX } from "./push.js";
9
+ import { assertNodeActive } from "./lifecycle-guard.js";
10
+ import { addNetworkScope, canRestWriteNetwork, getUserNetworkIds, resolveRestNetworkScope, resolveRestWriteNetworkId, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
11
+ import { validateAvatarUrl } from "./avatar-validate.js";
12
+ import { narrowTags, parseStoredTags, validateScalarAttr } from "./node-attrs-validate.js";
13
+ 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";
14
+ import { abortRename, cleanupCommittedRenameSessions, commitRename, prepareRename, resolveCanonicalAlias } from "./rename.js";
15
+ import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
16
+ import { getLoginClientIp, sharedLoginFailureLockout, sharedLoginIpRateLimiter } from "./auth_login_guard.js";
17
+ import {
18
+ FILE_ID_REGEX,
19
+ MAX_REQUEST_CONTENT_LENGTH,
20
+ MAX_UPLOAD_BYTES,
21
+ buildStoragePath,
22
+ pathForExistingBlob,
23
+ isValidCalendarBucket,
24
+ generateFileId,
25
+ getUploadsRoot,
26
+ indexEntryPath,
27
+ isPathInsideUploadsRoot,
28
+ sanitizeExt,
29
+ sharedUploadRateLimiter,
30
+ stripHostLocalPathsForCrossHostSafe,
31
+ validateAttachments,
32
+ validateIndexEntry,
33
+ } from "./uploads.js";
34
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, statSync, unlinkSync, renameSync } from "fs";
35
+ import { dirname as pathDirname } from "path";
36
+ import { startRetentionSweeper } from "./retention.js";
37
+ import { startStaleSessionSweeper } from "./stale-sweeper.js";
38
+ import {
39
+ AUDIT_LOG_REST_SELECT,
40
+ COMPLETION_REST_SELECT,
41
+ NETWORK_REST_SELECT,
42
+ SESSION_REST_SELECT,
43
+ TASK_EVENT_REST_SELECT,
44
+ TASK_REST_SELECT,
45
+ } from "./rest-projections.js";
46
+ import { resolveRestFromSession } from "./rest-identity.js";
47
+ import { stampTaskAuthOrigin, type TaskAuthOrigin } from "./task-auth-origin.js";
48
+ import { diagnoseTask } from "./task-diagnostic.js";
49
+ import { assertScheduledTaskBackendSupported, handleScheduledTaskRequest, startScheduledTaskScheduler } from "./scheduled-tasks.js";
50
+ import { handleExternalScheduleEditRequest } from "./external-schedule-edits.js";
51
+ import { recordDeliveredStaleEvents } from "./task-lifecycle-watcher.js";
52
+ import { SIDE_THREAD_FEATURE_FLAG, SideThreadCoordinator, SideThreadPortRegistry, SideThreadStore, type SideThreadActor, type SideThreadAttachmentRef, type SideThreadExecutionPort } from "./side-thread.js";
53
+ import { handleSideThreadHttpRequest } from "./side-thread-http.js";
54
+ import { createProductionSideThreadTransport } from "./side-thread-production.js";
55
+
56
+ const PORT = resolvePort(process.env.PORT);
57
+ const HOST = process.env.HOST || "127.0.0.1";
58
+ const AUTH_TOKEN = process.env.COMMHUB_AUTH_TOKEN;
59
+ const DEV_OPEN = process.argv.includes("--dev-open") || process.env.COMMHUB_DEV_OPEN === "1";
60
+ const TMUX_ENABLED = process.env.COMMHUB_ENABLE_TMUX === "1";
61
+ const SECURITY_LABEL = DEV_OPEN ? "⚠️ DEV OPEN MODE" : "🔒 secured";
62
+ const TMUX_ALLOWLIST = new Set(
63
+ (process.env.COMMHUB_TMUX_ALLOWLIST || "")
64
+ .split(",")
65
+ .map((s) => s.trim())
66
+ .filter(Boolean)
67
+ );
68
+ let masterTokenDeprecationLogged = false;
69
+
70
+ // #175 PR2 starts fail-closed. Only a verified native adapter can be installed;
71
+ // the execution port deliberately has no ordinary task/FIFO delivery method.
72
+ export const sideThreadPortRegistry = new SideThreadPortRegistry();
73
+ export function installSideThreadExecutionPort(port: SideThreadExecutionPort): () => void {
74
+ return sideThreadPortRegistry.install(port);
75
+ }
76
+ const sideThreadCoordinator = new SideThreadCoordinator(new SideThreadStore(db), sideThreadPortRegistry, {
77
+ enabled: process.env[SIDE_THREAD_FEATURE_FLAG] === "1",
78
+ authorizeNode: (actor, networkId, nodeId) => authorizeSideThreadNode(actor, networkId, nodeId),
79
+ authorizeAttachment: (actor, networkId, ref) => authorizeSideThreadAttachment(actor, networkId, ref),
80
+ });
81
+ const productionSideThreadTransport = process.env[SIDE_THREAD_FEATURE_FLAG] === "1"
82
+ ? createProductionSideThreadTransport(db)
83
+ : null;
84
+ if (productionSideThreadTransport) installSideThreadExecutionPort(productionSideThreadTransport.port);
85
+
86
+ if (AUTH_TOKEN) {
87
+ console.warn("[commhub] COMMHUB_AUTH_TOKEN is deprecated and will be removed in v1.0. See RFC-001.");
88
+ }
89
+
90
+ // Read version from package.json so banners and /health stay in sync.
91
+ const SERVER_VERSION = (() => {
92
+ try {
93
+ const url = new URL("../package.json", import.meta.url);
94
+ return JSON.parse(require("fs").readFileSync(url, "utf8")).version || "?";
95
+ } catch { return "?"; }
96
+ })();
97
+
98
+ // In-memory log ring buffer — last N lines streamed via /api/server-logs.
99
+ // Wraps console.log/info/warn/error so EVERY existing log call lands here
100
+ // without source changes. Dashboard tails this buffer for "hub server log
101
+ // view" feature.
102
+ const LOG_RING_CAP = Number(process.env.COMMHUB_LOG_RING || 500);
103
+ type LogEntry = { ts: string; level: "log" | "info" | "warn" | "error"; line: string };
104
+ const logRing: LogEntry[] = [];
105
+ const _origConsole = { log: console.log.bind(console), info: console.info.bind(console), warn: console.warn.bind(console), error: console.error.bind(console) };
106
+ function pushLog(level: LogEntry["level"], args: any[]) {
107
+ const line = args.map(a => typeof a === "string" ? a : (() => { try { return JSON.stringify(a); } catch { return String(a); } })()).join(" ");
108
+ logRing.push({ ts: new Date().toISOString(), level, line: line.slice(0, 4000) });
109
+ if (logRing.length > LOG_RING_CAP) logRing.splice(0, logRing.length - LOG_RING_CAP);
110
+ }
111
+ console.log = (...args: any[]) => { pushLog("log", args); _origConsole.log(...args); };
112
+ console.info = (...args: any[]) => { pushLog("info", args); _origConsole.info(...args); };
113
+ console.warn = (...args: any[]) => { pushLog("warn", args); _origConsole.warn(...args); };
114
+ console.error = (...args: any[]) => { pushLog("error", args); _origConsole.error(...args); };
115
+
116
+ function normalizeMetaJson(meta: unknown): string | null {
117
+ if (!meta || typeof meta !== "object") return null;
118
+ // #222 cross-host safety — same sanitization as tools.ts so REST
119
+ // /api/task and MCP send_task produce identical meta_json shapes.
120
+ // Conditional: only strips `path` when `file_id` is also present
121
+ // (preserves single-host feishu fallback per team rule — assume-unit-before-threshold: cap is in bytes
122
+ // discipline of not breaking other call sites).
123
+ try { return JSON.stringify(stripHostLocalPathsForCrossHostSafe(meta)); } catch { return null; }
124
+ }
125
+
126
+ // ── Rate limiter (in-memory, per IP) ──
127
+ const rateLimits = new Map<string, { count: number; resetAt: number }>();
128
+ function checkRateLimit(ip: string, maxPerMinute = 60): boolean {
129
+ // Skip rate limiting for localhost/internal/unknown (dev/test)
130
+ if (!ip || ip === "unknown" || ip === "127.0.0.1" || ip === "::1") return true;
131
+ const now = Date.now();
132
+ const entry = rateLimits.get(ip);
133
+ if (!entry || now > entry.resetAt) {
134
+ rateLimits.set(ip, { count: 1, resetAt: now + 60000 });
135
+ return true;
136
+ }
137
+ if (entry.count >= maxPerMinute) return false;
138
+ entry.count++;
139
+ return true;
140
+ }
141
+ // Cleanup stale entries every 5 minutes. Interval is registered in
142
+ // startHub() (#476) — module scope must stay timer-free so importing
143
+ // this module neither holds the event loop open nor does periodic work.
144
+ function sweepStaleRateLimits(): void {
145
+ const now = Date.now();
146
+ for (const [ip, entry] of rateLimits) {
147
+ if (now > entry.resetAt) rateLimits.delete(ip);
148
+ }
149
+ }
150
+
151
+ // ── Factory: 每个请求创建新的 McpServer(stateless 模式)──
152
+ function createServer(clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null, callerTokenIsNetwork = false, callerTokenId?: string | null): McpServer {
153
+ const server = new McpServer({
154
+ name: "commhub",
155
+ version: "0.5.0",
156
+ });
157
+ registerTools(server, clientIP, enforceNetworkId, enforceUserId, callerAlias, callerTokenIsNetwork, callerTokenId);
158
+ return server;
159
+ }
160
+
161
+ // ── Auth helper ─────────────────────────────────────
162
+ type RequestTokenOptions = { allowQueryToken?: boolean };
163
+
164
+ function requestToken(req: Request, options: RequestTokenOptions = {}): string {
165
+ const header = req.headers.get("Authorization")?.replace("Bearer ", "");
166
+ if (options.allowQueryToken === false) return header || "";
167
+ const url = new URL(req.url);
168
+ return header || url.searchParams.get("token") || "";
169
+ }
170
+
171
+ function isLegacyAuthToken(req: Request, options: RequestTokenOptions = {}): boolean {
172
+ const token = requestToken(req, options);
173
+ return !!AUTH_TOKEN && token === AUTH_TOKEN;
174
+ }
175
+
176
+ function requireAuth(req: Request, options: RequestTokenOptions = {}): Response | null {
177
+ const token = requestToken(req, options);
178
+
179
+ // V3: check api_tokens first
180
+ if (token) {
181
+ const resolved = resolveToken(token);
182
+ if (resolved) return null; // valid user token
183
+ }
184
+
185
+ // Legacy: check global COMMHUB_AUTH_TOKEN
186
+ if (!AUTH_TOKEN && DEV_OPEN) return null; // explicit local/dev open mode
187
+ if (token === AUTH_TOKEN) {
188
+ const u = new URL(req.url);
189
+ const readOnlyApi = u.pathname.startsWith("/api/") && (req.method === "GET" || req.method === "HEAD" || req.method === "OPTIONS");
190
+ if (!readOnlyApi) return Response.json({ ok: false, error: "master-token auth is deprecated; use admin utok_" }, { status: 401 });
191
+ if (!masterTokenDeprecationLogged) {
192
+ console.warn("[commhub] master-token auth is deprecated and will be removed in v1.0. See RFC-001.");
193
+ masterTokenDeprecationLogged = true;
194
+ }
195
+ return null;
196
+ }
197
+
198
+ return Response.json({ error: "unauthorized" }, { status: 401 });
199
+ }
200
+
201
+ function getClientIP(req: Request, server?: any): string {
202
+ const direct = server?.requestIP?.(req)?.address;
203
+ if (direct) return direct;
204
+ const fwd = req.headers.get("x-forwarded-for");
205
+ return fwd ? fwd.split(",")[0].trim() : (req.headers.get("x-real-ip") ?? "unknown");
206
+ }
207
+
208
+ function isLocalhostIP(ip: string): boolean {
209
+ return ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1" || ip === "localhost";
210
+ }
211
+
212
+ // Normalize the raw `agent` field into a canonical runtime identifier for the
213
+ // dashboard's Runtime badge. Returns null for unknown/absent agents so the
214
+ // frontend can fall back to a placeholder.
215
+ const normalizeRuntime = normalizeSessionRuntime;
216
+
217
+ function isTmuxAllowedIP(ip: string): boolean {
218
+ return isLocalhostIP(ip) || TMUX_ALLOWLIST.has(ip);
219
+ }
220
+
221
+ function requireAdminAuth(req: Request): Response | null {
222
+ const token = requestToken(req);
223
+ if (!token) return Response.json({ ok: false, error: "auth required" }, { status: 401 });
224
+ const resolved = resolveToken(token);
225
+ if (!resolved) return Response.json({ ok: false, error: "invalid token" }, { status: 401 });
226
+ if (resolved.user.role !== "admin") return Response.json({ ok: false, error: "admin required" }, { status: 403 });
227
+ return null;
228
+ }
229
+
230
+ function requireTmuxAccess(req: Request, server?: any): Response | null {
231
+ if (!TMUX_ENABLED) return Response.json({ ok: false, error: "tmux disabled" }, { status: 404 });
232
+ const ip = getClientIP(req, server);
233
+ if (!isTmuxAllowedIP(ip)) return Response.json({ ok: false, error: "tmux access denied from this ip" }, { status: 403 });
234
+ return requireAdminAuth(req);
235
+ }
236
+
237
+ // Extract user + network + token-binding identity from request token.
238
+ function resolveRequestAuth(req: Request, options: RequestTokenOptions = {}): { userId: string; networkId: string | null; username: string; tokenName: string | null; tokenId: string | null } | null {
239
+ const token = requestToken(req, options);
240
+ if (!token) return null;
241
+ const resolved = resolveToken(token);
242
+ if (!resolved) return null;
243
+ return { userId: resolved.user.user_id, networkId: resolved.networkId, username: resolved.user.username, tokenName: resolved.tokenName, tokenId: resolved.tokenId };
244
+ }
245
+
246
+ function resolveSideThreadActor(req: Request, auth: ReturnType<typeof resolveRequestAuth>, isAdmin: boolean): SideThreadActor | null {
247
+ // Legacy master and DEV_OPEN cannot own durable records because neither
248
+ // supplies a stable per-user identity for later window hydration.
249
+ if (!auth?.userId || !auth.tokenId) return null;
250
+ const nodeCredential = requestToken(req).startsWith("ntok_");
251
+ const tokenRow = nodeCredential
252
+ ? db.get<{ bound_node_id: string | null }>("SELECT bound_node_id FROM api_tokens WHERE token_id = ?1", auth.tokenId)
253
+ : null;
254
+ return {
255
+ userId: auth.userId,
256
+ username: auth.username,
257
+ tokenId: auth.tokenId,
258
+ kind: nodeCredential ? "node" : "user",
259
+ ...(auth.networkId ? { boundNetworkId: auth.networkId } : {}),
260
+ ...(tokenRow?.bound_node_id ? { boundNodeId: tokenRow.bound_node_id } : {}),
261
+ isAdmin,
262
+ };
263
+ }
264
+
265
+ // #503 — the credential kinds that authorization decisions branch on.
266
+ // Resolved once per request at the handler, so the authz helpers stay
267
+ // pure functions of (principal, entry) and every credential kind is a
268
+ // directly constructible test input instead of a hand-assembled Request.
269
+ export type Principal =
270
+ | { kind: "anonymous" }
271
+ | { kind: "dev-open-anon" }
272
+ | { kind: "legacy-master" }
273
+ | { kind: "utok"; userId: string; username: string; isAdmin: false }
274
+ | { kind: "ntok"; userId: string; boundNetworkId: string }
275
+ | { kind: "admin-utok"; userId: string; username: string };
276
+
277
+ export function resolvePrincipal(req: Request, options: RequestTokenOptions = {}): Principal {
278
+ const authCtx = resolveRequestAuth(req, options);
279
+ if (!authCtx) {
280
+ if (isLegacyAuthToken(req, options)) return { kind: "legacy-master" };
281
+ if (DEV_OPEN) return { kind: "dev-open-anon" };
282
+ return { kind: "anonymous" };
283
+ }
284
+ const token = requestToken(req, options);
285
+ const resolved = token ? resolveToken(token) : null;
286
+ const isAdmin = !!resolved && resolved.user.role === "admin";
287
+ // Bound network wins over admin classification: a ntok_ issued BY an
288
+ // admin is still a ntok_, and its bound scope defines its identity.
289
+ // Inverting this order would silently re-grant every production agent
290
+ // node cross-network read, since production node tokens resolve to an
291
+ // admin user (see the 生产影响 note on authorizeFileDownload).
292
+ if (authCtx.networkId) return { kind: "ntok", userId: authCtx.userId, boundNetworkId: authCtx.networkId };
293
+ if (isAdmin) return { kind: "admin-utok", userId: authCtx.userId, username: authCtx.username };
294
+ return { kind: "utok", userId: authCtx.userId, username: authCtx.username, isAdmin: false };
295
+ }
296
+
297
+ // #503 — coerce the on-disk (untrusted, hand-editable) index entry into
298
+ // the shape the authz helper reasons about. Every `typeof` narrowing for
299
+ // these two fields lives here and nowhere else, so the authz rules can be
300
+ // read as rules rather than as string-checks.
301
+ export function normalizeEntry(
302
+ entry: { owner_id?: unknown; network_id?: unknown },
303
+ ): { ownerId: string | null; networkId: string | null } {
304
+ const ownerId = typeof entry.owner_id === "string" && entry.owner_id.length > 0
305
+ ? entry.owner_id
306
+ : null;
307
+ const networkId = typeof entry.network_id === "string" && entry.network_id.length > 0
308
+ ? entry.network_id
309
+ : null;
310
+ return { ownerId, networkId };
311
+ }
312
+
313
+ // #495/#503 — shared authorization gate for /api/files/:file_id downloads.
314
+ // Called from the GET handler and any future HEAD/Range/dashboard-proxy
315
+ // entry so the allow-list can't drift between methods (通信龙 clause 2:
316
+ // "授权若只在 GET 分支实现, HEAD 或 Range 走别路径 = 等于没修").
317
+ //
318
+ // Returns true if the caller may read this entry; false otherwise. The
319
+ // caller emits 404 on false — never 403; 403 would leak that the
320
+ // file_id exists and enables enumeration.
321
+ //
322
+ // Allow-list — network scope enforced by #503:
323
+ // - Legacy master AUTH_TOKEN (RFC-001 read-only) → allow.
324
+ // - admin utok_ → allow (mirrors requireAdminAuth).
325
+ // - entry.network_id present → caller must belong to THAT network:
326
+ // ntok_ must be bound to it; utok_ must hold any role in it
327
+ // (viewer included — viewer is read-only, and a viewer who can see
328
+ // a task but not open its attachment is a broken product).
329
+ // - entry.network_id absent (legacy / unattributed) → the pre-#503
330
+ // rules stand unchanged: owner match, or null owner_id + DEV_OPEN.
331
+ // - Everything else → deny (404). New uploads in production always
332
+ // carry a truthy owner_id (requireAuth blocks the null-owner-
333
+ // producing path when DEV_OPEN=off and no legacy master token is
334
+ // configured), so denying null-owner in production closes the
335
+ // enumeration vector for legacy blobs without breaking new uploads.
336
+ //
337
+ // 生产影响: 新的 ntok 归属文件走 network gate, admin 签发的 ntok 也走 gate
338
+ // (kind='ntok' 分类优先绑定). 98 历史文件无 network_id 走老文件兼容分支
339
+ // (owner/admin 可读, 存量行为不变).
340
+ //
341
+ // 行为变更: admin 的节点令牌 (ntok_) 失去它今天拥有的跨网络读权. 生产今天
342
+ // 所有文件在同一 network → 无实际影响, 但语义变更须明写, 不能悄悄发生.
343
+ export function authorizeFileDownload(
344
+ principal: Principal,
345
+ entry: { ownerId: string | null; networkId: string | null },
346
+ ): boolean {
347
+ if (principal.kind === "legacy-master") return true;
348
+ if (principal.kind === "admin-utok") return true;
349
+
350
+ if (entry.networkId !== null) {
351
+ if (principal.kind === "anonymous" || principal.kind === "dev-open-anon") return false;
352
+ if (principal.kind === "ntok") return principal.boundNetworkId === entry.networkId;
353
+ return !!getUserNetworkRole(principal.userId, entry.networkId);
354
+ }
355
+
356
+ if (principal.kind === "ntok" || principal.kind === "utok") {
357
+ if (entry.ownerId !== null && principal.userId === entry.ownerId) return true;
358
+ }
359
+ // Kept env-gated rather than narrowed to kind==='dev-open-anon': the
360
+ // pre-#503 rule allowed ANY caller to read a null-owner entry while
361
+ // DEV_OPEN is on, and #503 is scoped to adding network scope, not to
362
+ // tightening the local-dev carve-out.
363
+ if (entry.ownerId === null && DEV_OPEN) return true;
364
+ return false;
365
+ }
366
+
367
+ function authorizeSideThreadNode(actor: SideThreadActor, networkId: string, nodeId: string): boolean {
368
+ const node = db.get<{ network_id: string | null; owner_user_id: string | null }>(
369
+ "SELECT network_id, owner_user_id FROM nodes WHERE node_id = ?1", nodeId,
370
+ );
371
+ if (!node || node.network_id !== networkId) return false;
372
+ if (!getUserNetworkRole(actor.userId, networkId)) return false;
373
+ if (!node.owner_user_id || node.owner_user_id !== actor.userId) return false;
374
+ if (actor.boundNetworkId && actor.boundNetworkId !== networkId) return false;
375
+ if (actor.kind === "node" && actor.boundNodeId !== nodeId) return false;
376
+ return true;
377
+ }
378
+
379
+ function resolveSideThreadCapabilityTarget(input: { actor: SideThreadActor; alias?: string; nodeId?: string; networkId?: string }): { nodeId: string; networkId: string } | undefined {
380
+ if ((!input.alias && !input.nodeId) || (input.alias && input.nodeId)) return undefined;
381
+ const networkId = input.actor.boundNetworkId ?? input.networkId;
382
+ const params: unknown[] = [input.nodeId ?? input.alias];
383
+ let sql = input.nodeId
384
+ ? "SELECT node_id,network_id FROM nodes WHERE node_id=?1"
385
+ : "SELECT node_id,network_id FROM nodes WHERE alias=?1";
386
+ if (networkId) { params.push(networkId); sql += " AND network_id=?2"; }
387
+ const authorized = db.all<{ node_id: string; network_id: string | null }>(sql, ...params)
388
+ .filter(row => !!row.network_id && authorizeSideThreadNode(input.actor, row.network_id, row.node_id));
389
+ if (authorized.length !== 1) return undefined;
390
+ return { nodeId: authorized[0].node_id, networkId: authorized[0].network_id! };
391
+ }
392
+
393
+ function authorizeSideThreadAttachment(actor: SideThreadActor, networkId: string, ref: SideThreadAttachmentRef): boolean {
394
+ if (!FILE_ID_REGEX.test(ref.fileId)) return false;
395
+ const path = indexEntryPath(ref.fileId);
396
+ if (!path || !existsSync(path)) return false;
397
+ try {
398
+ const entry = JSON.parse(readFileSync(path, "utf8"));
399
+ if (!validateIndexEntry(entry) || entry.file_id !== ref.fileId) return false;
400
+ if (entry.network_id !== networkId || entry.owner_id !== actor.userId) return false;
401
+ if (!isValidCalendarBucket(entry.date_bucket)) return false;
402
+ const storage = pathForExistingBlob(entry.date_bucket, entry.file_id, entry.ext);
403
+ return isPathInsideUploadsRoot(storage.absolutePath) && existsSync(storage.absolutePath);
404
+ } catch { return false; }
405
+ }
406
+
407
+ type ByteRange =
408
+ | { ok: true; start: number; end: number; length: number }
409
+ | { ok: false };
410
+
411
+ function parseSingleByteRange(rangeHeader: string | null, size: number): ByteRange | null {
412
+ if (rangeHeader === null) return null;
413
+ if (!Number.isSafeInteger(size) || size < 0) return { ok: false };
414
+ const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
415
+ if (!match) return { ok: false };
416
+ const [, rawStart, rawEnd] = match;
417
+ if (rawStart === "" && rawEnd === "") return { ok: false };
418
+
419
+ let start: number;
420
+ let end: number;
421
+ if (rawStart === "") {
422
+ const suffixLength = Number(rawEnd);
423
+ if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0) return { ok: false };
424
+ if (size === 0) return { ok: false };
425
+ start = Math.max(0, size - suffixLength);
426
+ end = size - 1;
427
+ } else {
428
+ start = Number(rawStart);
429
+ if (!Number.isSafeInteger(start) || start < 0) return { ok: false };
430
+ if (start >= size) return { ok: false };
431
+ if (rawEnd === "") {
432
+ end = size - 1;
433
+ } else {
434
+ end = Number(rawEnd);
435
+ if (!Number.isSafeInteger(end) || end < start) return { ok: false };
436
+ end = Math.min(end, size - 1);
437
+ }
438
+ }
439
+
440
+ return { ok: true, start, end, length: end - start + 1 };
441
+ }
442
+
443
+ function sqliteTime(date: Date): string {
444
+ return date.toISOString().replace("T", " ").slice(0, 19);
445
+ }
446
+
447
+ function parseSqliteTime(value: string | null | undefined): number {
448
+ if (!value) return 0;
449
+ const normalized = value.includes("T") ? value : `${value.replace(" ", "T")}Z`;
450
+ const ts = Date.parse(normalized);
451
+ return Number.isFinite(ts) ? ts : 0;
452
+ }
453
+
454
+ function normalizeTaskBeforeCursor(value: string): string | null {
455
+ const cursor = value.trim();
456
+ if (!cursor) return null;
457
+ const sqliteShape = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
458
+ if (sqliteShape.test(cursor)) {
459
+ const parsed = new Date(`${cursor.replace(" ", "T")}Z`);
460
+ if (!Number.isFinite(parsed.getTime()) || sqliteTime(parsed) !== cursor) return null;
461
+ return cursor;
462
+ }
463
+ const timestamp = Date.parse(cursor);
464
+ if (!Number.isFinite(timestamp)) return null;
465
+ return sqliteTime(new Date(timestamp));
466
+ }
467
+
468
+ function normalizeTaskBeforeTaskId(value: string): string | null {
469
+ const taskId = value.trim();
470
+ if (!taskId || taskId.length > 256 || /[\u0000-\u001f\u007f]/.test(taskId)) return null;
471
+ return taskId;
472
+ }
473
+
474
+ function cpuPct(load: number | null | undefined, cores: number | null | undefined): number | null {
475
+ if (typeof load !== "number" || typeof cores !== "number" || cores <= 0) return null;
476
+ return Math.round((load / cores) * 1000) / 10;
477
+ }
478
+
479
+ function serverAlertLevel(row: any): { level: "green" | "yellow" | "red"; alerts: string[] } {
480
+ const alerts: string[] = [];
481
+ const pct = cpuPct(row?.cpu_load_1min, row?.cpu_cores);
482
+ if (pct !== null && pct >= 80) alerts.push(`cpu ${pct}%`);
483
+ if (typeof row?.mem_avail_gb === "number" && row.mem_avail_gb < 0.5) alerts.push(`memory ${row.mem_avail_gb}GB available`);
484
+ if (typeof row?.disk_avail_gb === "number" && row.disk_avail_gb < 1) alerts.push(`disk ${row.disk_avail_gb}GB available`);
485
+ if (alerts.length > 0) return { level: "red", alerts };
486
+
487
+ if (pct !== null && pct >= 60) alerts.push(`cpu ${pct}%`);
488
+ if (typeof row?.mem_avail_gb === "number" && row.mem_avail_gb < 1) alerts.push(`memory ${row.mem_avail_gb}GB available`);
489
+ if (typeof row?.disk_avail_gb === "number" && row.disk_avail_gb < 5) alerts.push(`disk ${row.disk_avail_gb}GB available`);
490
+ return { level: alerts.length > 0 ? "yellow" : "green", alerts };
491
+ }
492
+
493
+ function agentHealthChip(status: unknown, lastSeen: string | null | undefined): "online" | "offline" | "stale" {
494
+ if (String(status || "").toLowerCase() === "offline") return "offline";
495
+ const ts = parseSqliteTime(lastSeen);
496
+ if (!ts || Date.now() - ts > 5 * 60 * 1000) return "stale";
497
+ return "online";
498
+ }
499
+
500
+ function bucketTelemetry(rows: any[], fromMs: number, bucketMs: number) {
501
+ const buckets = new Map<number, {
502
+ ts: number;
503
+ count: number;
504
+ cpu_pct_sum: number;
505
+ cpu_pct_count: number;
506
+ cpu_load_sum: number;
507
+ cpu_load_count: number;
508
+ mem_avail_min: number | null;
509
+ mem_used_max: number | null;
510
+ disk_avail_min: number | null;
511
+ disk_used_max: number | null;
512
+ }>();
513
+
514
+ for (const row of rows) {
515
+ const ts = parseSqliteTime(row.created_at);
516
+ if (!ts || ts < fromMs) continue;
517
+ const bucketTs = Math.floor(ts / bucketMs) * bucketMs;
518
+ const bucket = buckets.get(bucketTs) ?? {
519
+ ts: bucketTs,
520
+ count: 0,
521
+ cpu_pct_sum: 0,
522
+ cpu_pct_count: 0,
523
+ cpu_load_sum: 0,
524
+ cpu_load_count: 0,
525
+ mem_avail_min: null,
526
+ mem_used_max: null,
527
+ disk_avail_min: null,
528
+ disk_used_max: null,
529
+ };
530
+ bucket.count += 1;
531
+ const pct = cpuPct(row.cpu_load_1min, row.cpu_cores);
532
+ if (pct !== null) {
533
+ bucket.cpu_pct_sum += pct;
534
+ bucket.cpu_pct_count += 1;
535
+ }
536
+ if (typeof row.cpu_load_1min === "number") {
537
+ bucket.cpu_load_sum += row.cpu_load_1min;
538
+ bucket.cpu_load_count += 1;
539
+ }
540
+ 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);
541
+ 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);
542
+ 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);
543
+ 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);
544
+ buckets.set(bucketTs, bucket);
545
+ }
546
+
547
+ return Array.from(buckets.values())
548
+ .sort((a, b) => a.ts - b.ts)
549
+ .map((b) => ({
550
+ ts: new Date(b.ts).toISOString(),
551
+ count: b.count,
552
+ cpu_pct: b.cpu_pct_count ? Math.round((b.cpu_pct_sum / b.cpu_pct_count) * 10) / 10 : null,
553
+ cpu_load_1min: b.cpu_load_count ? Math.round((b.cpu_load_sum / b.cpu_load_count) * 100) / 100 : null,
554
+ mem_avail_gb: b.mem_avail_min,
555
+ mem_used_gb: b.mem_used_max,
556
+ disk_avail_gb: b.disk_avail_min,
557
+ disk_used_gb: b.disk_used_max,
558
+ }));
559
+ }
560
+
561
+
562
+ type RestDeliveryTarget =
563
+ | { state: "online"; alias: string; session: any }
564
+ | { state: "offline"; alias: string; session: any; message: string }
565
+ | { state: "not_found"; alias: string; message: string };
566
+
567
+ // Build this only after authorization and a network-scoped lookup succeeded.
568
+ // In particular, not-found and permission-denied responses must not become a
569
+ // cross-network alias/node enumeration oracle.
570
+ function restActualTo(target: Exclude<RestDeliveryTarget, { state: "not_found" }>, networkId: string | null) {
571
+ return {
572
+ alias: target.alias,
573
+ to_node_id: target.session?.node_id ?? null,
574
+ network_id: networkId,
575
+ };
576
+ }
577
+
578
+ function resolveRestDeliveryTarget(alias: string, networkId: string | null): RestDeliveryTarget {
579
+ const params: any[] = [alias];
580
+ let sql = "SELECT status, updated_at, last_seen_at, node_id FROM sessions WHERE alias = ?1";
581
+ if (networkId) {
582
+ sql += " AND network_id = ?2";
583
+ params.push(networkId);
584
+ }
585
+ const session = db.get<any>(sql, ...params);
586
+ if (!session) {
587
+ return { state: "not_found", alias, message: `alias not found: ${alias}` };
588
+ }
589
+ const lastSeen = session.last_seen_at || session.updated_at;
590
+ const lastSeenAt = lastSeen ? new Date(String(lastSeen).replace(" ", "T") + "Z").getTime() : 0;
591
+ const stale = !lastSeenAt || Date.now() - lastSeenAt > 5 * 60 * 1000;
592
+ if (String(session.status || "").toLowerCase() === "offline" || stale) {
593
+ return {
594
+ state: "offline",
595
+ alias,
596
+ session,
597
+ message: `alias is offline; task queued in inbox: ${alias}`,
598
+ };
599
+ }
600
+ return { state: "online", alias, session };
601
+ }
602
+
603
+ function resolveRestNodeIdForAlias(alias: string, networkId: string | null): string | null {
604
+ if (!alias || alias === "hub" || alias === "api") return null;
605
+ const canonical = resolveCanonicalAlias(networkId, alias);
606
+ const target = resolveRestDeliveryTarget(canonical.alias, networkId);
607
+ return target.state === "not_found" ? null : (target.session?.node_id ?? null);
608
+ }
609
+
610
+ function withNetworkNameAlias<T extends { network_name?: unknown }>(row: T): T & { name: string | null } {
611
+ return {
612
+ ...row,
613
+ // `network_name` is the canonical V3 storage/API field and remains for
614
+ // existing CLI/Dashboard clients. `name` is the documented REST-friendly
615
+ // compatibility alias; never let it drift or serialize as null when the
616
+ // canonical NOT NULL field is present.
617
+ name: typeof row.network_name === "string" ? row.network_name : null,
618
+ };
619
+ }
620
+
621
+ // ── REST input schema ───────────────────────────────
622
+ const TaskSchema = z.object({
623
+ alias: z.string().min(1).max(200),
624
+ task: z.string().min(1).max(10000),
625
+ priority: z.enum(["high", "normal", "low"]).default("normal"),
626
+ from: z.string().max(200).optional(),
627
+ network_id: z.string().max(200).optional(),
628
+ parent_task_id: z.string().max(200).optional(),
629
+ ttl_seconds: z.number().min(1).max(86400).optional(),
630
+ // #221 — top-level attachments mirror the MCP send_task tool. They are
631
+ // merged into `meta.attachments` for persistence so the on-disk shape
632
+ // stays unified regardless of transport (REST or MCP).
633
+ attachments: z.any().optional(),
634
+ meta: z.any().optional(),
635
+ });
636
+
637
+ const BroadcastSchema = z.object({
638
+ message: z.string().min(1).max(10000),
639
+ filter_server: z.string().max(200).optional(),
640
+ filter_status: z.string().max(50).optional(),
641
+ });
642
+
643
+ // ── HTTP Server (Bun native) ────────────────────────
644
+ const CORS_ORIGINS = process.env.COMMHUB_CORS_ORIGINS
645
+ ? process.env.COMMHUB_CORS_ORIGINS.split(",").map((s) => s.trim())
646
+ : ["http://localhost:3000", "http://localhost:3001"];
647
+
648
+ function corsHeaders(req: Request): Record<string, string> {
649
+ const origin = req.headers.get("Origin") || "";
650
+ // CORS allowlist is driven entirely by COMMHUB_CORS_ORIGINS env (comma-separated).
651
+ // Default (env unset) allows localhost dev origins only — see CORS_ORIGINS above.
652
+ // No author-specific domains are hardcoded; production deployments must set
653
+ // COMMHUB_CORS_ORIGINS explicitly. See docs/concepts/security.md "CORS 配置".
654
+ const allowed = CORS_ORIGINS.includes(origin) ? origin : "";
655
+ return {
656
+ "Access-Control-Allow-Origin": allowed,
657
+ "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
658
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
659
+ "Access-Control-Max-Age": "86400",
660
+ };
661
+ }
662
+
663
+ function withCors(req: Request, res: Response): Response {
664
+ const headers = corsHeaders(req);
665
+ for (const [k, v] of Object.entries(headers)) {
666
+ res.headers.set(k, v);
667
+ }
668
+ return res;
669
+ }
670
+
671
+ /**
672
+ * #426 — append `charset=utf-8` to text-y Content-Type headers that
673
+ * omit it. Legacy Windows clients (PowerShell 5.1 Invoke-RestMethod /
674
+ * Invoke-WebRequest) default to ISO-8859-1 when the charset is
675
+ * unspecified, which double-encodes our clean UTF-8 payloads on the
676
+ * way in.
677
+ *
678
+ * Applied to Responses whose Content-Type header we can't influence at
679
+ * construction time — notably the MCP SDK transport, which builds its
680
+ * own `text/event-stream` / `application/json` reply. Response headers
681
+ * from `fetch` are mutable, so this rewrites in place rather than
682
+ * reconstructing the Response (which would break streamed bodies).
683
+ *
684
+ * No-ops when: charset is already set, the type is binary
685
+ * (application/octet-stream, image/*, video/*, application/pdf, etc.),
686
+ * or the header is missing entirely.
687
+ */
688
+ function withUtf8CharsetContentType(res: Response): Response {
689
+ const ct = res.headers.get("content-type");
690
+ if (!ct) return res;
691
+ if (/;\s*charset=/i.test(ct)) return res;
692
+ const trimmed = ct.trim();
693
+ // Text-y types that legacy clients decode via charset. Everything
694
+ // else (binary, opaque) is left alone.
695
+ if (
696
+ trimmed === "application/json" ||
697
+ trimmed.startsWith("text/") ||
698
+ trimmed.startsWith("application/json;") ||
699
+ trimmed === "application/ld+json" ||
700
+ trimmed === "application/xml"
701
+ ) {
702
+ res.headers.set("content-type", `${trimmed}; charset=utf-8`);
703
+ }
704
+ return res;
705
+ }
706
+
707
+ // ── WebSocket tmux sessions ────────────────────────
708
+ const wsTmuxIntervals = new Map<object, ReturnType<typeof setInterval>>();
709
+
710
+
711
+ // ── Task expiration patrol (every 5 minutes) ──
712
+ // Registered in startHub() (#476): this WRITES the DB, so it must never
713
+ // run as an import side effect — a tool/test importing this module with
714
+ // COMMHUB_DB unset would patrol the production database.
715
+ export function patrolExpiredTasks(): void {
716
+ try {
717
+ const expired = db.transaction(() => {
718
+ const due = db.all<{ task_id: string; network_id: string | null }>(
719
+ `SELECT task_id, network_id FROM tasks
720
+ WHERE expires_at IS NOT NULL AND expires_at < datetime('now')
721
+ AND status IN ('created', 'delivered')`,
722
+ );
723
+ const changed: Array<{ task_id: string; network_id: string | null }> = [];
724
+ for (const task of due) {
725
+ const result = db.run(
726
+ `UPDATE tasks SET status = 'expired', completed_at = datetime('now')
727
+ WHERE task_id = ?1 AND status IN ('created', 'delivered')`,
728
+ [task.task_id],
729
+ );
730
+ // SQLite may include the AFTER UPDATE terminal-journal trigger write
731
+ // in this count. The WHERE clause still targets one exact guarded
732
+ // task, so any positive count proves the task transition happened.
733
+ if (result.changes < 1) continue;
734
+ syncScheduledRunForTask(task.task_id, task.network_id);
735
+ changed.push(task);
736
+ }
737
+ return changed;
738
+ });
739
+ if (expired.length > 0) {
740
+ console.log(`[patrol] expired ${expired.length} stale task(s)`);
741
+ for (const task of expired) logTaskEvent(task.task_id, null, "expired", "patrol");
742
+ }
743
+ } catch {}
744
+ }
745
+
746
+ function patrolDeliveredStaleTasks(): void {
747
+ try {
748
+ const result = recordDeliveredStaleEvents();
749
+ if (result.inserted > 0) {
750
+ console.warn(`[patrol] recorded ${result.inserted} delivered-stale task warning(s)`);
751
+ }
752
+ } catch (error: any) {
753
+ console.error(`[patrol] delivered-stale watcher failed: ${error?.message || error}`);
754
+ }
755
+ }
756
+
757
+ // #434 — test-safety seam, same signature as PR #438 so the two branches
758
+ // merge cleanly. Wraps the sole Bun.serve config in a factory so
759
+ // integration tests can request an OS-assigned ephemeral port
760
+ // (`bootServer({ port: 0 })`) and read the actual bound port back from
761
+ // the returned server. In an aggregate `bun test` run only the FIRST test
762
+ // file's import boots the default server (module cache) — later files'
763
+ // PORT env is ignored, so a suite that hard-codes its own port gets
764
+ // ConnectionRefused on every fetch while still "running" (审查修复 per
765
+ // 通信龙 #461 review, finding 2). Suites boot a private instance via this
766
+ // seam instead. #438 additionally moves the default boot below under
767
+ // `import.meta.main`; until that lands, import keeps booting (pre-#434
768
+ // behavior) so the not-yet-migrated suites stay green.
769
+ //
770
+ // Note: `opts.port ?? PORT` uses nullish-coalescing on purpose — `||`
771
+ // would swallow a legitimate `0`. Same for hostname.
772
+ export function bootServer(opts?: { port?: number; hostname?: string }): ReturnType<typeof Bun.serve> {
773
+ return Bun.serve({
774
+ port: opts?.port ?? PORT,
775
+ hostname: opts?.hostname ?? HOST,
776
+ idleTimeout: 255, // max value: keep SSE connections alive (seconds)
777
+ // #221 — defense-in-depth cap on the raw request body. The /api/upload
778
+ // handler also pre-checks Content-Length and post-checks parsed size
779
+ // against the documented 12 MiB cap (MAX_UPLOAD_BYTES); this knob
780
+ // simply guarantees Bun's own buffer won't grow past 13 MiB on any
781
+ // route. Default Bun ceiling is 128 MiB which is too permissive for
782
+ // a hub now exposed to the public internet.
783
+ maxRequestBodySize: MAX_REQUEST_CONTENT_LENGTH,
784
+
785
+ async fetch(req, server) {
786
+ const url = new URL(req.url, `http://localhost:${PORT}`);
787
+
788
+ // ── CORS preflight ──
789
+ if (req.method === "OPTIONS") {
790
+ return new Response(null, { status: 204, headers: corsHeaders(req) });
791
+ }
792
+
793
+ // ── WebSocket: tmux terminal ──
794
+ const wsMatch = url.pathname.match(/^\/ws\/tmux\/([a-zA-Z0-9_-]+)$/);
795
+ if (wsMatch) {
796
+ const tmuxErr = requireTmuxAccess(req, server);
797
+ if (tmuxErr) return withCors(req, tmuxErr);
798
+ if (server.upgrade(req, { data: { tmuxName: wsMatch[1] } } as any)) return;
799
+ }
800
+
801
+ // ── MCP Streamable HTTP endpoint ──
802
+ if (url.pathname === "/mcp") {
803
+ const authErr = requireAuth(req);
804
+ if (authErr) return withCors(req, authErr);
805
+ const clientIP = getClientIP(req, server);
806
+ // V3: resolve token → enforce network_id in all MCP tools.
807
+ // utok_ (user token, not network-bound) is allowed — the tool layer
808
+ // scopes to the user's accessible networks. Without this Dashboard
809
+ // (which logs in as a user) cannot call send_task.
810
+ const token = requestToken(req);
811
+ const authCtx = resolveRequestAuth(req);
812
+ const enforceNetId = authCtx?.networkId || null;
813
+ // Derive the calling alias from the token name (e.g., 'node:视频审查')
814
+ // so peer agents see the real sender instead of 'hub' on send_task.
815
+ const callerAlias = authCtx?.tokenName?.startsWith("node:")
816
+ ? authCtx.tokenName.slice("node:".length)
817
+ : (authCtx?.username || null);
818
+ const transport = new WebStandardStreamableHTTPServerTransport({
819
+ sessionIdGenerator: undefined,
820
+ });
821
+ const mcpServer = createServer(clientIP, enforceNetId, authCtx?.userId || null, callerAlias, !!token?.startsWith("ntok_"), authCtx?.tokenId || null);
822
+ await mcpServer.connect(transport);
823
+ const response = await transport.handleRequest(req);
824
+ // Disconnect after response to prevent McpServer leak
825
+ setImmediate(() => mcpServer.close().catch(() => {}));
826
+ // #426: the MCP SDK's WebStandardStreamableHTTPServerTransport builds
827
+ // its own Response with `Content-Type: text/event-stream` (or
828
+ // `application/json` for JSON-mode) but WITHOUT charset. Legacy
829
+ // Windows clients (PowerShell 5.1 Invoke-WebRequest / RestMethod)
830
+ // default to ISO-8859-1 when the charset is unspecified and 双重-
831
+ // 编码 our clean UTF-8 payload on the way in — the reported
832
+ // mojibake is client-side, but the header is what tells them to
833
+ // guess. We can't tell the SDK to add it, so wrap the response and
834
+ // rewrite the header. Streamed bodies (SSE) pass through untouched
835
+ // because Response takes the original ReadableStream verbatim.
836
+ return withUtf8CharsetContentType(response);
837
+ }
838
+
839
+ // ── #461 SSE network observer stream ──
840
+ // GET /events/network/:network_id → 网络级摘要事件(new_task / new_reply
841
+ // routing metadata only, no content)。Dashboard 用它实时感知第三方流量,
842
+ // 退役 15s 软轮询。MUST be matched before the generic /events/(.+) route
843
+ // below, which would otherwise swallow the path as a session name.
844
+ //
845
+ // Auth mirrors /events/:session's three paths:
846
+ // 1. legacy AUTH_TOKEN (master) → any network
847
+ // 2/3. ntok_ / utok_ → must CURRENTLY be a member of the requested
848
+ // network. Membership is checked unconditionally — there is NO
849
+ // token-bound-network shortcut. removeNetworkMember deletes the
850
+ // membership row but does NOT revoke the user's ntok, so the
851
+ // network_members lookup IS the revocation mechanism: an ntok
852
+ // bound to this network whose owner was removed must lose the
853
+ // stream (审查修复 per 通信龙 #461 review, finding 1).
854
+ const netEventsMatch = url.pathname.match(/^\/events\/network\/(.+)$/);
855
+ if (netEventsMatch && req.method === "GET") {
856
+ const authErr = requireAuth(req);
857
+ if (authErr) return authErr;
858
+ const observedNetId = decodeURIComponent(netEventsMatch[1]);
859
+ const authCtx = resolveRequestAuth(req);
860
+ if (!authCtx && isLegacyAuthToken(req)) {
861
+ return createNetworkObserverStream(observedNetId);
862
+ }
863
+ if (!authCtx) {
864
+ return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 403 }));
865
+ }
866
+ const observerRole = getUserNetworkRole(authCtx.userId, observedNetId);
867
+ if (!observerRole) {
868
+ return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
869
+ }
870
+ return createNetworkObserverStream(observedNetId);
871
+ }
872
+
873
+ // ── SSE push: Agent 实时接收任务推送 ──
874
+ // GET /events/知识哥 → 保持长连接,send_task 时秒推
875
+ const eventsMatch = url.pathname.match(/^\/events\/(.+)$/);
876
+ if (eventsMatch && req.method === "GET") {
877
+ const authErr = requireAuth(req);
878
+ if (authErr) return authErr;
879
+ const sessionName = decodeURIComponent(eventsMatch[1]);
880
+ const token = requestToken(req);
881
+ const authCtx = resolveRequestAuth(req);
882
+ const scopedNetId = authCtx?.networkId || url.searchParams.get("network_id");
883
+
884
+ // ── Path 1: legacy AUTH_TOKEN (master token) — unchanged ──
885
+ if (!authCtx && isLegacyAuthToken(req)) {
886
+ if (scopedNetId) {
887
+ const session = db.get<any>(
888
+ "SELECT 1 FROM sessions WHERE alias = ?1 AND network_id = ?2",
889
+ sessionName, scopedNetId
890
+ );
891
+ if (!session) return withCors(req, Response.json({ ok: false, error: "session not in requested network" }, { status: 403 }));
892
+ }
893
+ return createSSEStream(sessionName, scopedNetId);
894
+ }
895
+
896
+ // ── Path 2: ntok_ (network-bound agent token) — pre-#247 behavior preserved ──
897
+ if (token?.startsWith("ntok_")) {
898
+ if (!authCtx || !scopedNetId) {
899
+ return withCors(req, Response.json({ ok: false, error: "network-scoped token required for SSE" }, { status: 403 }));
900
+ }
901
+ const role = getUserNetworkRole(authCtx.userId, scopedNetId);
902
+ if (!role) return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
903
+ const session = db.get<any>(
904
+ "SELECT 1 FROM sessions WHERE alias = ?1 AND network_id = ?2",
905
+ sessionName, scopedNetId
906
+ );
907
+ if (!session && authCtx.networkId !== scopedNetId) {
908
+ return withCors(req, Response.json({ ok: false, error: "session not in requested network" }, { status: 403 }));
909
+ }
910
+ return createSSEStream(sessionName, scopedNetId);
911
+ }
912
+
913
+ // ── Path 3 (#247): utok_ (user token, dashboard SSE path) — 4 gates ──
914
+ //
915
+ // Pre-#247 the only V3 path required `ntok_` and rejected `utok_` outright,
916
+ // which locked the dashboard out of SSE (the dashboard proxy authenticates
917
+ // with the user's `utok_`). That broke real-time chat updates — replies
918
+ // were persisted but never live-pushed to the UI.
919
+ //
920
+ // Auth-scoping rules (all four must pass — see #247 design):
921
+ // gate 1: token resolves to a valid user context (authCtx non-null)
922
+ // gate 2: network scope is explicit — `utok_` has no implicit network,
923
+ // so the caller must pass `?network_id=<id>`
924
+ // gate 3: user is a member of that network (any role: owner / admin / member)
925
+ // gate 4: channel-ownership — one of:
926
+ // (a) sessionName === own username (the dashboard's default channel —
927
+ // `send_reply` pushes to the original sender's alias which equals
928
+ // the dashboard user's username; NO `sessions` row required for
929
+ // this branch since the dashboard user is not a registered agent)
930
+ // (b) sessionName is an existing agent alias in `scopedNetId`
931
+ // (lets members monitor agents within their network)
932
+ //
933
+ // Security: rule 4(a) uses strict equality, so a `utok_` user cannot subscribe
934
+ // to another user's username channel (no cross-user eavesdrop). Rule 4(b) is
935
+ // scoped to `scopedNetId` + gate 3 membership, so cross-network eavesdrop is
936
+ // blocked too. There is intentionally NO org-admin bypass — even users with
937
+ // global `admin` role must be a member of the network they want to listen on.
938
+ if (!authCtx) {
939
+ return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 403 }));
940
+ }
941
+ if (!scopedNetId) {
942
+ return withCors(req, Response.json({
943
+ ok: false,
944
+ error: "network_id required (utok has no implicit network; pass ?network_id=<id>)"
945
+ }, { status: 403 }));
946
+ }
947
+ const utokRole = getUserNetworkRole(authCtx.userId, scopedNetId);
948
+ if (!utokRole) {
949
+ return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
950
+ }
951
+ if (sessionName === authCtx.username) {
952
+ return createSSEStream(sessionName, scopedNetId);
953
+ }
954
+ const utokSession = db.get<any>(
955
+ "SELECT 1 FROM sessions WHERE alias = ?1 AND network_id = ?2",
956
+ sessionName, scopedNetId
957
+ );
958
+ if (!utokSession) {
959
+ return withCors(req, Response.json({
960
+ ok: false,
961
+ error: "channel not allowed: must be your username or an existing agent in your network"
962
+ }, { status: 403 }));
963
+ }
964
+ return createSSEStream(sessionName, scopedNetId);
965
+ }
966
+
967
+ // ── V3: License endpoints ──
968
+ if (url.pathname === "/api/license" && req.method === "GET") {
969
+ const license = db.get<any>(
970
+ "SELECT type, expires_at, max_agents, max_networks, max_tasks_day FROM licenses ORDER BY created_at LIMIT 1",
971
+ );
972
+ if (!license) return withCors(req, Response.json({ ok: true, status: "no_license" }));
973
+ const now = new Date().toISOString().replace("T", " ").slice(0, 19);
974
+ const expired = license.expires_at && license.expires_at < now;
975
+ const daysLeft = license.expires_at
976
+ ? Math.max(0, Math.ceil((new Date(license.expires_at).getTime() - Date.now()) / 86400000))
977
+ : null;
978
+ return withCors(req, Response.json({
979
+ ok: true,
980
+ license: { type: license.type, expires_at: license.expires_at, days_left: daysLeft, expired },
981
+ limits: { max_agents: license.max_agents, max_networks: license.max_networks, max_tasks_day: license.max_tasks_day },
982
+ }));
983
+ }
984
+
985
+ if (url.pathname === "/api/license/activate" && req.method === "POST") {
986
+ try {
987
+ const body = await req.json() as any;
988
+ const key = body.key;
989
+ if (!key) return withCors(req, Response.json({ ok: false, error: "key required" }, { status: 400 }));
990
+ // For now: accept any key starting with "anet-" as valid pro license
991
+ if (!key.startsWith("anet-") || key.length < 16) {
992
+ return withCors(req, Response.json({ ok: false, error: "invalid license key" }, { status: 400 }));
993
+ }
994
+ // Upgrade existing license or create new
995
+ db.run("DELETE FROM licenses");
996
+ const licId = `lic_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
997
+ db.run(
998
+ "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'))",
999
+ [licId, key]
1000
+ );
1001
+ return withCors(req, Response.json({ ok: true, type: "pro", expires_in_days: 365 }));
1002
+ } catch (e: any) {
1003
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
1004
+ }
1005
+ }
1006
+
1007
+ // ── V3: Auth endpoints (public) ──
1008
+ if (url.pathname === "/api/auth/register" && req.method === "POST") {
1009
+ const clientIP = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
1010
+ if (!checkRateLimit(clientIP, 30)) {
1011
+ return withCors(req, Response.json({ ok: false, error: "too many requests, try again later" }, { status: 429 }));
1012
+ }
1013
+ try {
1014
+ const body = await req.json() as any;
1015
+ const result = register(body.username, body.password, body.email, body.display_name);
1016
+ if (result.ok) logAudit(result.user!.user_id, body.username, "register", "user", result.user!.user_id);
1017
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
1018
+ } catch (e: any) {
1019
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
1020
+ }
1021
+ }
1022
+
1023
+ if (url.pathname === "/api/auth/login" && req.method === "POST") {
1024
+ const clientIP = getLoginClientIp(req);
1025
+ const ipRate = sharedLoginIpRateLimiter.check(clientIP || "unknown");
1026
+ if (!ipRate.allowed) {
1027
+ logAudit(null, null, "login_rate_limited", "auth", undefined, clientIP);
1028
+ return withCors(req, Response.json(
1029
+ { ok: false, error: "rate_limited", message: "Too many login attempts. Try again later.", retry_after_ms: ipRate.retryAfterMs },
1030
+ { status: 429, headers: { "Retry-After": String(Math.ceil((ipRate.retryAfterMs ?? 1000) / 1000)) } }
1031
+ ));
1032
+ }
1033
+ try {
1034
+ const body = await req.json() as any;
1035
+ const lock = sharedLoginFailureLockout.check(body.username);
1036
+ if (lock.locked) {
1037
+ logAudit(null, body.username, "login_locked", "auth", undefined, `retry_after_ms=${lock.retryAfterMs ?? 0}`);
1038
+ return withCors(req, Response.json(
1039
+ { ok: false, error: "login_locked", message: "Too many failed login attempts. Try again later.", retry_after_ms: lock.retryAfterMs },
1040
+ { status: 429, headers: { "Retry-After": String(Math.ceil((lock.retryAfterMs ?? 1000) / 1000)) } }
1041
+ ));
1042
+ }
1043
+ const result = login(body.username, body.password);
1044
+ if (result.ok) {
1045
+ sharedLoginFailureLockout.recordSuccess(body.username);
1046
+ logAudit(result.user!.user_id, body.username, "login", "user", result.user!.user_id);
1047
+ } else {
1048
+ const failure = sharedLoginFailureLockout.recordFailure(body.username);
1049
+ if (failure.locked) {
1050
+ const safeUsername = String(body.username ?? "").replace(/[\r\n\t]/g, " ").slice(0, 80);
1051
+ console.warn(`[auth] login locked for username=${safeUsername} failures=${failure.failures} retry_after_ms=${failure.lockMs}`);
1052
+ }
1053
+ logAudit(null, body.username, "login_failed", "user", undefined, "invalid credentials");
1054
+ }
1055
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 401 }));
1056
+ } catch (e: any) {
1057
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
1058
+ }
1059
+ }
1060
+
1061
+ if (url.pathname === "/api/auth/me" && req.method === "GET") {
1062
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
1063
+ if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
1064
+ const resolved = resolveToken(token);
1065
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1066
+ const networks = getUserAllNetworks(resolved.user.user_id);
1067
+ return withCors(req, Response.json({ ok: true, user: resolved.user, networks, current_network: resolved.networkId }));
1068
+ }
1069
+
1070
+ if (url.pathname === "/api/auth/me" && req.method === "PUT") {
1071
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "");
1072
+ if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
1073
+ const resolved = resolveToken(token);
1074
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1075
+ try {
1076
+ const body = await req.json() as any;
1077
+ const updates: string[] = [];
1078
+ const params: any[] = [];
1079
+ if (body.display_name) { updates.push(`display_name = ?${params.length + 1}`); params.push(body.display_name); }
1080
+ if (body.email) { updates.push(`email = ?${params.length + 1}`); params.push(body.email); }
1081
+ if (updates.length > 0) {
1082
+ updates.push(`updated_at = datetime('now')`);
1083
+ params.push(resolved.user.user_id);
1084
+ db.run(`UPDATE users SET ${updates.join(", ")} WHERE user_id = ?${params.length}`, params);
1085
+ }
1086
+ // Re-fetch
1087
+ const user = db.get<any>("SELECT user_id, username, display_name, email, role FROM users WHERE user_id = ?1", resolved.user.user_id);
1088
+ return withCors(req, Response.json({ ok: true, user }));
1089
+ } catch (e: any) {
1090
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
1091
+ }
1092
+ }
1093
+
1094
+ if (url.pathname === "/api/auth/password" && req.method === "POST") {
1095
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "");
1096
+ if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
1097
+ const resolved = resolveToken(token);
1098
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1099
+ try {
1100
+ const body = await req.json() as any;
1101
+ const result = changePassword(resolved.user.user_id, body.old_password, body.new_password, resolved.tokenId);
1102
+ if (result.ok) {
1103
+ const issued = issueUserToken(resolved.user.user_id, "password-change");
1104
+ if (resolved.tokenId) revokeToken(resolved.user.user_id, resolved.tokenId);
1105
+ logAudit(resolved.user.user_id, resolved.user.username, "password_changed", "user", resolved.user.user_id);
1106
+ return withCors(req, Response.json({ ...result, token: issued.token, token_id: issued.token_id }));
1107
+ }
1108
+ return withCors(req, Response.json(result, { status: 400 }));
1109
+ } catch (e: any) {
1110
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
1111
+ }
1112
+ }
1113
+
1114
+ // ── V3.13: Create network token for a node ──
1115
+ if (url.pathname === "/api/auth/node-token" && req.method === "POST") {
1116
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "");
1117
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1118
+ const resolved = resolveToken(token);
1119
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1120
+ try {
1121
+ const body = await req.json() as any;
1122
+ if (!body.network_id || !body.node_name) return withCors(req, Response.json({ ok: false, error: "network_id and node_name required" }, { status: 400 }));
1123
+ const result = createNetworkTokenForNode(resolved.user.user_id, body.network_id, body.node_name, body.node_id);
1124
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "node_token_created", "network", body.network_id, body.node_name);
1125
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
1126
+ } catch (e: any) {
1127
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
1128
+ }
1129
+ }
1130
+
1131
+ // ── #84: node rename 2PC — Server surface (RFC-010 §4) ──
1132
+ // The CLI orchestrates the 2PC; these endpoints are the Server steps:
1133
+ // prepare = PHASE 1 P3, commit = PHASE 2 C1, abort = PHASE 1 rollback.
1134
+ if (url.pathname === "/api/node-rename/prepare" && req.method === "POST") {
1135
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "");
1136
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1137
+ const resolved = resolveToken(token);
1138
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1139
+ try {
1140
+ const body = await req.json() as any;
1141
+ if (!body.network_id || !body.old_alias || !body.new_alias) {
1142
+ return withCors(req, Response.json({ ok: false, error: "network_id, old_alias, new_alias required" }, { status: 400 }));
1143
+ }
1144
+ const result = prepareRename(resolved.user.user_id, body.network_id, body.old_alias, body.new_alias);
1145
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "node_rename_prepared", "node", body.old_alias, body.new_alias);
1146
+ return withCors(req, Response.json(result, { status: result.ok || result.code === "node_local_only" ? 200 : 400 }));
1147
+ } catch (e: any) {
1148
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
1149
+ }
1150
+ }
1151
+
1152
+ if (url.pathname === "/api/node-rename/commit" && req.method === "POST") {
1153
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "");
1154
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1155
+ const resolved = resolveToken(token);
1156
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1157
+ try {
1158
+ const body = await req.json() as any;
1159
+ if (!body.txn_id) return withCors(req, Response.json({ ok: false, error: "txn_id required" }, { status: 400 }));
1160
+ const result = commitRename(resolved.user.user_id, body.txn_id);
1161
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "node_rename_committed", "node", body.txn_id);
1162
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
1163
+ } catch (e: any) {
1164
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
1165
+ }
1166
+ }
1167
+
1168
+ if (url.pathname === "/api/node-rename/abort" && req.method === "POST") {
1169
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "");
1170
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1171
+ const resolved = resolveToken(token);
1172
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1173
+ try {
1174
+ const body = await req.json() as any;
1175
+ if (!body.txn_id) return withCors(req, Response.json({ ok: false, error: "txn_id required" }, { status: 400 }));
1176
+ const result = abortRename(resolved.user.user_id, body.txn_id);
1177
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "node_rename_aborted", "node", body.txn_id);
1178
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
1179
+ } catch (e: any) {
1180
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
1181
+ }
1182
+ }
1183
+
1184
+ // ── V3: Token management ──
1185
+ if (url.pathname === "/api/auth/tokens" && req.method === "GET") {
1186
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "");
1187
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1188
+ const resolved = resolveToken(token);
1189
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1190
+ const tokens = listTokens(resolved.user.user_id);
1191
+ return withCors(req, Response.json({ ok: true, tokens }));
1192
+ }
1193
+
1194
+ if (url.pathname === "/api/auth/tokens" && req.method === "POST") {
1195
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "");
1196
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1197
+ const resolved = resolveToken(token);
1198
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1199
+ try {
1200
+ const body = await req.json() as any;
1201
+ const result = createToken(resolved.user.user_id, body.name || "api-token", body.network_id);
1202
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "token_created", "token", result.token_id);
1203
+ return withCors(req, Response.json(result));
1204
+ } catch (e: any) {
1205
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
1206
+ }
1207
+ }
1208
+
1209
+ const tokenDeleteMatch = url.pathname.match(/^\/api\/auth\/tokens\/([^/]+)$/);
1210
+ if (tokenDeleteMatch && req.method === "DELETE") {
1211
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "");
1212
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1213
+ const resolved = resolveToken(token);
1214
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1215
+ const result = revokeToken(resolved.user.user_id, tokenDeleteMatch[1]);
1216
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "token_revoked", "token", tokenDeleteMatch[1]);
1217
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 404 }));
1218
+ }
1219
+
1220
+ // ── V3: Network management ──
1221
+ if (url.pathname === "/api/networks" && req.method === "GET") {
1222
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
1223
+ if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
1224
+ const resolved = resolveToken(token);
1225
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1226
+ // V3.13: ntok_ can only see its bound network; utok_ sees all member networks
1227
+ if (resolved.networkId) {
1228
+ // ntok_ — only return the bound network
1229
+ const net = db.get<any>(`SELECT ${NETWORK_REST_SELECT} FROM networks WHERE network_id = ?1`, resolved.networkId);
1230
+ return withCors(req, Response.json({ ok: true, networks: net ? [withNetworkNameAlias(net)] : [] }));
1231
+ }
1232
+ // Global Hub admins have cross-network authority throughout the REST
1233
+ // surface, so limiting this discovery endpoint to their membership rows
1234
+ // made otherwise-authorized networks impossible to select (#94). Keep
1235
+ // ordinary utok_ callers membership-scoped; ntok_ returned above stays
1236
+ // bound to exactly one network.
1237
+ const networks = resolved.user.role === "admin"
1238
+ ? db.all<any>(
1239
+ `SELECT ${NETWORK_REST_SELECT},
1240
+ COALESCE((SELECT nm.role FROM network_members nm
1241
+ WHERE nm.network_id = networks.network_id
1242
+ AND nm.user_id = ?1), 'admin') AS member_role
1243
+ FROM networks ORDER BY created_at`,
1244
+ resolved.user.user_id,
1245
+ )
1246
+ : getUserAllNetworks(resolved.user.user_id);
1247
+ const visibleNetworks = networks.map(withNetworkNameAlias);
1248
+ return withCors(req, Response.json({ ok: true, networks: visibleNetworks }));
1249
+ }
1250
+
1251
+ if (url.pathname === "/api/networks" && req.method === "POST") {
1252
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
1253
+ if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
1254
+ const resolved = resolveToken(token);
1255
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1256
+ try {
1257
+ const body = await req.json() as any;
1258
+ const result = createNetwork(resolved.user.user_id, body.name, body.description);
1259
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
1260
+ } catch (e: any) {
1261
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
1262
+ }
1263
+ }
1264
+
1265
+ // ── V3.13: Network members + invites ──
1266
+ const membersMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/members(?:\/([^/]+))?$/);
1267
+ if (membersMatch) {
1268
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
1269
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1270
+ const resolved = resolveToken(token);
1271
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1272
+ const netId = membersMatch[1];
1273
+ const targetUid = membersMatch[2];
1274
+ const callerRole = getUserNetworkRole(resolved.user.user_id, netId);
1275
+ if (!callerRole) return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
1276
+
1277
+ if (req.method === "GET") {
1278
+ if (!["owner", "admin"].includes(callerRole)) return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
1279
+ const members = getNetworkMembers(netId);
1280
+ return withCors(req, Response.json({ ok: true, members }));
1281
+ }
1282
+ if (req.method === "POST") {
1283
+ if (!["owner", "admin"].includes(callerRole)) return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
1284
+ const body = await req.json() as any;
1285
+ const result = addNetworkMember(netId, body.user_id, body.role || "member", resolved.user.user_id);
1286
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "member_added", "network", netId, `${body.user_id} as ${body.role || "member"}`);
1287
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
1288
+ }
1289
+ if (req.method === "PUT" && targetUid) {
1290
+ if (callerRole !== "owner") return withCors(req, Response.json({ ok: false, error: "owner required" }, { status: 403 }));
1291
+ const body = await req.json() as any;
1292
+ const result = updateMemberRole(netId, targetUid, body.role);
1293
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "member_role_changed", "network", netId, `${targetUid} → ${body.role}`);
1294
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
1295
+ }
1296
+ if (req.method === "DELETE" && targetUid) {
1297
+ if (!["owner", "admin"].includes(callerRole)) return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
1298
+ const result = removeNetworkMember(netId, targetUid);
1299
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "member_removed", "network", netId, targetUid);
1300
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
1301
+ }
1302
+ }
1303
+
1304
+ if (url.pathname.match(/^\/api\/networks\/([^/]+)\/invite$/) && req.method === "POST") {
1305
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
1306
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1307
+ const resolved = resolveToken(token);
1308
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1309
+ const netId = url.pathname.split("/")[3];
1310
+ const callerRole = getUserNetworkRole(resolved.user.user_id, netId);
1311
+ if (!callerRole || !["owner", "admin"].includes(callerRole)) {
1312
+ return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
1313
+ }
1314
+ const body = await req.json() as any;
1315
+ const result = createInvite(netId, resolved.user.user_id, body.role || "member", body.max_uses || 1, body.expires_days);
1316
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "invite_created", "network", netId, result.invite_code);
1317
+ return withCors(req, Response.json(result));
1318
+ }
1319
+
1320
+ if (url.pathname === "/api/networks/join" && req.method === "POST") {
1321
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
1322
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1323
+ const resolved = resolveToken(token);
1324
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1325
+ const body = await req.json() as any;
1326
+ const result = joinByInvite(body.invite_code, resolved.user.user_id);
1327
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "network_joined", "network", result.network_id, `via invite, role=${result.role}`);
1328
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
1329
+ }
1330
+
1331
+ // ── V3: Admin APIs (require auth) ──
1332
+ if (url.pathname === "/api/users" && req.method === "GET") {
1333
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "");
1334
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1335
+ const resolved = resolveToken(token);
1336
+ if (!resolved || resolved.user.role !== "admin") {
1337
+ return withCors(req, Response.json({ ok: false, error: "admin required" }, { status: 403 }));
1338
+ }
1339
+ const users = db.all("SELECT user_id, username, display_name, email, role, created_at FROM users ORDER BY created_at");
1340
+ return withCors(req, Response.json({ ok: true, users }));
1341
+ }
1342
+
1343
+ const netDetailMatch = url.pathname.match(/^\/api\/networks\/([^/]+)$/);
1344
+ if (netDetailMatch && req.method === "GET") {
1345
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
1346
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1347
+ const resolved = resolveToken(token);
1348
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1349
+ const networkId = netDetailMatch[1];
1350
+ const network = db.get<any>(`SELECT ${NETWORK_REST_SELECT} FROM networks WHERE network_id = ?1`, networkId);
1351
+ if (!network) return withCors(req, Response.json({ ok: false, error: "network not found" }, { status: 404 }));
1352
+ // Membership check: must be a member or system admin
1353
+ const viewerRole = getUserNetworkRole(resolved.user.user_id, networkId);
1354
+ if (!viewerRole && resolved.user.role !== "admin") {
1355
+ return withCors(req, Response.json({ ok: false, error: "access denied" }, { status: 403 }));
1356
+ }
1357
+ // Get network stats
1358
+ const nodeCount = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM nodes WHERE network_id = ?1", networkId);
1359
+ const sessionCount = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM sessions WHERE network_id = ?1", networkId);
1360
+ const taskStats = db.all<any>("SELECT status, COUNT(*) as count FROM tasks WHERE network_id = ?1 GROUP BY status", networkId);
1361
+ return withCors(req, Response.json({
1362
+ ok: true, network,
1363
+ stats: { nodes: nodeCount?.cnt || 0, sessions: sessionCount?.cnt || 0, tasks: taskStats },
1364
+ }));
1365
+ }
1366
+
1367
+ if (netDetailMatch && req.method === "DELETE") {
1368
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
1369
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1370
+ const resolved = resolveToken(token);
1371
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1372
+ const result = deleteNetwork(resolved.user.user_id, netDetailMatch[1]);
1373
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "network_deleted", "network", netDetailMatch[1]);
1374
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
1375
+ }
1376
+
1377
+ if (netDetailMatch && req.method === "PUT") {
1378
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
1379
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1380
+ const resolved = resolveToken(token);
1381
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1382
+ try {
1383
+ const body = await req.json() as any;
1384
+ if (body.name) {
1385
+ const result = renameNetwork(resolved.user.user_id, netDetailMatch[1], body.name);
1386
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "network_renamed", "network", netDetailMatch[1], body.name);
1387
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
1388
+ }
1389
+ return withCors(req, Response.json({ ok: false, error: "name required" }, { status: 400 }));
1390
+ } catch (e: any) {
1391
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
1392
+ }
1393
+ }
1394
+
1395
+ // ── REST: health (public, no auth) ──
1396
+ if (url.pathname === "/health") {
1397
+ // #473 — /health is ANONYMOUS BY CONTRACT (the central watchdog
1398
+ // cron curls it every minute to decide dead-or-alive; adding auth
1399
+ // here would blind it — do NOT add requireAuth, do NOT change the
1400
+ // 200-with-JSON shape without telling the watchdog owner first).
1401
+ // Anonymous callers get AGGREGATES ONLY. The per-key SSE detail
1402
+ // (`{networkId}:{alias}` + observer keys) used to be inlined here,
1403
+ // which handed any internet stranger the full network id + all
1404
+ // agent aliases (95 on the public hub when audited) — that detail
1405
+ // now lives behind auth at GET /api/stats/sse.
1406
+ // `version` stays: it does help fingerprinting, but ops needs it
1407
+ // to verify deploys land, and the tradeoff was accepted in review.
1408
+ //
1409
+ // #495-followup / f28a6c1b — auth-gated `sse_sessions` restored so
1410
+ // the dashboard's own "online" widget can compute per-agent status
1411
+ // in one round-trip. Rules:
1412
+ // - anonymous (no valid token): NO sse_sessions field → watchdog
1413
+ // contract untouched, no topology leak (mutation removing this
1414
+ // gate turns the anonymous test RED — see health-redaction.test)
1415
+ // - admin utok_ / legacy master / DEV_OPEN: full sessions map
1416
+ // (parity with /api/stats/sse ops path)
1417
+ // - regular utok_ member: sessions map FILTERED to network_ids
1418
+ // the caller is a member of — safe against the "any account
1419
+ // sees all-network topology" regression the #473 fix closed;
1420
+ // dashboard user proxies with the logged-in user's utok_ so
1421
+ // their own network's aliases surface; strangers' networks do
1422
+ // not
1423
+ const count = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM sessions");
1424
+ const sse = getSSEStats();
1425
+ const license = db.get<any>("SELECT type, expires_at FROM licenses LIMIT 1");
1426
+
1427
+ // Non-fatal auth probe (no 401 — /health must stay 200 anonymous).
1428
+ const healthAuth = resolveRequestAuth(req);
1429
+ const healthIsMaster = !healthAuth && isLegacyAuthToken(req);
1430
+ const healthIsDevOpen = !healthAuth && !AUTH_TOKEN && DEV_OPEN;
1431
+ const healthIsAdmin = !!(healthAuth?.username && db.get<any>(
1432
+ "SELECT role FROM users WHERE username = ?1", healthAuth.username,
1433
+ )?.role === "admin");
1434
+
1435
+ let scopedSessions: Record<string, number> | undefined;
1436
+ if (healthIsMaster || healthIsDevOpen || healthIsAdmin) {
1437
+ // Ops parity with /api/stats/sse — full map.
1438
+ scopedSessions = sse.sessions;
1439
+ } else if (healthAuth?.userId) {
1440
+ // Regular authenticated member — filter to networks they belong
1441
+ // to. Keys are `{networkId}:{alias}` (or observer keys shaped
1442
+ // `\0netobs:{networkId}`); parse the network prefix and keep
1443
+ // only entries whose network is in the member's set.
1444
+ //
1445
+ // 🔴 ntok_ (network-scoped token) MUST be forced to its single
1446
+ // bound network (parity with resolveRestNetworkScope L277:
1447
+ // `if (authCtx.networkId) return { networkId: authCtx.networkId }`).
1448
+ // Falling through to the utok_ union query would return
1449
+ // sessions from every network the underlying user belongs to,
1450
+ // silently escalating a network-scoped token to a user-scoped
1451
+ // token — the exact "认证 ≠ 已授权" mistake this whole change
1452
+ // is meant to prevent. Same shape as f28a6c1b root cause; see
1453
+ // CR3 audit 4703b0e7.
1454
+ const memberNets = new Set<string>(
1455
+ healthAuth.networkId
1456
+ ? [healthAuth.networkId] // ntok_ single-network enforce
1457
+ : db.all<{ network_id: string }>(
1458
+ "SELECT network_id FROM network_members WHERE user_id = ?1",
1459
+ healthAuth.userId,
1460
+ ).map((r) => r.network_id),
1461
+ );
1462
+ // If member has no networks at all, keep field present but empty
1463
+ // so the dashboard can distinguish "authenticated + none active"
1464
+ // from "not authenticated at all".
1465
+ const filtered: Record<string, number> = {};
1466
+ for (const [key, n] of Object.entries(sse.sessions)) {
1467
+ // Observer keys are shape `\0netobs:<networkId>` raw; keyed
1468
+ // through `printableKey()` in push.ts, they surface here as
1469
+ // the literal string `\\0netobs:<networkId>` (backslash-zero,
1470
+ // not NUL byte). Use the shared `PRINTABLE_OBSERVER_KEY_PREFIX`
1471
+ // export so this side and the emitter side stay locked to the
1472
+ // same literal — CR3 audit 4703b0e7 caught prior check
1473
+ // `startsWith("netobs:")` was always false → observer keys
1474
+ // silently over-filtered out of member views.
1475
+ const isObserver = key.startsWith(PRINTABLE_OBSERVER_KEY_PREFIX);
1476
+ const netId = isObserver
1477
+ ? key.slice(PRINTABLE_OBSERVER_KEY_PREFIX.length)
1478
+ : key.split(":")[0];
1479
+ if (memberNets.has(netId)) filtered[key] = n;
1480
+ }
1481
+ scopedSessions = filtered;
1482
+ }
1483
+ // healthAuth == null AND no legacy master AND no DEV_OPEN → leave
1484
+ // scopedSessions undefined → sse_sessions field omitted entirely
1485
+ // (watchdog + arbitrary internet strangers see aggregates only).
1486
+
1487
+ const body: Record<string, unknown> = {
1488
+ ok: true,
1489
+ version: SERVER_VERSION,
1490
+ api_version: "v3",
1491
+ transport: "streamable-http",
1492
+ sessions_count: count?.cnt ?? 0,
1493
+ sse_connections: sse.total,
1494
+ auth: DEV_OPEN ? "dev-open" : "user-token",
1495
+ security: DEV_OPEN ? "dev-open" : "secured",
1496
+ tmux: TMUX_ENABLED ? "enabled" : "disabled",
1497
+ v3_auth: true,
1498
+ multi_network: true,
1499
+ license: license?.type || "none",
1500
+ uptime: Math.floor(process.uptime()),
1501
+ // #496 — public, stable capability data. Dashboard upload proxies
1502
+ // need the Hub's real limits before opening a streaming request;
1503
+ // duplicating these constants client-side creates a silent
1504
+ // lower-limit drift that the Hub can never observe.
1505
+ limits: {
1506
+ max_upload_bytes: MAX_UPLOAD_BYTES,
1507
+ max_request_content_length: MAX_REQUEST_CONTENT_LENGTH,
1508
+ },
1509
+ };
1510
+ if (scopedSessions !== undefined) body.sse_sessions = scopedSessions;
1511
+ return withCors(req, Response.json(body));
1512
+ }
1513
+
1514
+ // ── All REST /api endpoints require auth (if token configured) ──
1515
+ const authErr = requireAuth(req);
1516
+ if (authErr) return withCors(req, authErr);
1517
+
1518
+ // Resolve network scope for REST queries — enforce isolation
1519
+ // Token-bound networkId takes precedence (ntok_ → forced), then query param
1520
+ const restAuth = resolveRequestAuth(req);
1521
+ const isAdmin = !!(restAuth?.username && db.get<any>("SELECT role FROM users WHERE username = ?1", restAuth.username)?.role === "admin");
1522
+ const restScope = resolveRestNetworkScope(url.searchParams.get("network_id"), restAuth, isAdmin);
1523
+ if (restScope.denied) {
1524
+ return withCors(req, Response.json({ ok: false, error: restScope.denied }, { status: 403 }));
1525
+ }
1526
+
1527
+ const sideActor = resolveSideThreadActor(req, resolveRequestAuth(req, { allowQueryToken: false }), isAdmin);
1528
+ const nodeCommandActor = sideActor?.kind === "node" && sideActor.boundNodeId && sideActor.boundNetworkId
1529
+ ? { tokenId: sideActor.tokenId, networkId: sideActor.boundNetworkId, nodeId: sideActor.boundNodeId }
1530
+ : null;
1531
+ if (productionSideThreadTransport) {
1532
+ const attachmentResponse = await productionSideThreadTransport.attachment(req, url, nodeCommandActor);
1533
+ if (attachmentResponse) return withCors(req, attachmentResponse);
1534
+ const commandResponse = await productionSideThreadTransport.handle({ req, url, actor: nodeCommandActor });
1535
+ if (commandResponse) return withCors(req, commandResponse);
1536
+ }
1537
+
1538
+ const sideThreadResponse = await handleSideThreadHttpRequest({
1539
+ req,
1540
+ url,
1541
+ // Durable prompts/results never accept query-string credentials.
1542
+ actor: sideActor,
1543
+ coordinator: sideThreadCoordinator,
1544
+ resolveCapabilityTarget: resolveSideThreadCapabilityTarget,
1545
+ });
1546
+ if (sideThreadResponse) return withCors(req, sideThreadResponse);
1547
+
1548
+ // RFC-036 owner-gated host schedule edits. This handler independently
1549
+ // verifies exact node ownership and node-token binding; network admin is
1550
+ // deliberately not an override.
1551
+ const requestCredential = requestToken(req);
1552
+ const externalScheduleEditResponse = await handleExternalScheduleEditRequest({
1553
+ req,
1554
+ url,
1555
+ auth: restAuth,
1556
+ isUserToken: requestCredential.startsWith("utok_"),
1557
+ isNodeToken: requestCredential.startsWith("ntok_"),
1558
+ });
1559
+ if (externalScheduleEditResponse) return withCors(req, externalScheduleEditResponse);
1560
+
1561
+ // Hub-owned scheduled tasks. Dashboard and mobile are management
1562
+ // clients only; each occurrence is dispatched as an ordinary task by the
1563
+ // scheduler started in startHub(). Node tokens are rejected in the
1564
+ // handler so an agent cannot grant itself a persistent execution loop.
1565
+ const scheduledResponse = await handleScheduledTaskRequest({
1566
+ req,
1567
+ url,
1568
+ auth: restAuth,
1569
+ isAdmin,
1570
+ isNodeToken: requestToken(req).startsWith("ntok_"),
1571
+ scope: restScope,
1572
+ });
1573
+ if (scheduledResponse) return withCors(req, scheduledResponse);
1574
+
1575
+ // ── #473 REST: SSE connection detail (ops-only) ──
1576
+ // The per-key breakdown /health used to expose anonymously: keys are
1577
+ // `{networkId}:{alias}` (+ `\0netobs:{networkId}` observer keys via
1578
+ // printableKey), i.e. the full network/agent topology. Ops-scoped:
1579
+ // legacy master token (restAuth null once requireAuth passed) or an
1580
+ // admin user. Regular members get 403 — the detail spans EVERY
1581
+ // network, so per-network filtering would be required to open it
1582
+ // wider; no consumer needs that today.
1583
+ if (url.pathname === "/api/stats/sse") {
1584
+ // restAuth === null here means the caller passed the legacy master
1585
+ // token (requireAuth already accepted it above) OR the hub runs in
1586
+ // DEV_OPEN mode. Both are treated as ops: master token is root, and
1587
+ // under DEV_OPEN this endpoint is anonymously readable BY DESIGN
1588
+ // (dev convenience — production never sets COMMHUB_DEV_OPEN, so the
1589
+ // topology detail stays gated in every real deployment).
1590
+ if (restAuth && !isAdmin) {
1591
+ return withCors(req, Response.json({ ok: false, error: "admin or master token required" }, { status: 403 }));
1592
+ }
1593
+ const sse = getSSEStats();
1594
+ return withCors(req, Response.json({ ok: true, total: sse.total, sessions: sse.sessions }));
1595
+ }
1596
+
1597
+ // ── REST: all sessions status ──
1598
+ if (url.pathname === "/api/status") {
1599
+ // Round-2/4 review ③: stale-marking moved to startStaleSessionSweeper()
1600
+ // (background timer, ~60s cadence). Read paths no longer fire UPDATE.
1601
+ // Per-request UPDATE was a write-amp vector — see stale-sweeper.ts.
1602
+ cleanupCommittedRenameSessions(restScope.networkId ? [restScope.networkId] : restScope.networkIds ?? null);
1603
+ // `?light=1` returns a narrow projection (alias / status / agent / task /
1604
+ // server / updated_at + runtime + network_id) — used by the mobile APP
1605
+ // list view, where pulling 30+ fields × 150 agents was making cold open
1606
+ // take 12s+ on flaky cellular (Vincent tg, #220 PWA). Default response
1607
+ // is unchanged so the dashboard / scripts that read full telemetry are
1608
+ // unaffected.
1609
+ const isLight = url.searchParams.get("light") === "1";
1610
+ const params: any[] = [];
1611
+ let sql = isLight
1612
+ ? "SELECT alias, status, agent, task, server, updated_at, network_id FROM sessions WHERE 1=1"
1613
+ : `SELECT ${SESSION_REST_SELECT} FROM sessions WHERE 1=1`;
1614
+ sql = addNetworkScope(sql, params, restScope);
1615
+ sql += " ORDER BY updated_at DESC";
1616
+ // `model` comes straight from the explicit sessions projection; `runtime` is
1617
+ // derived from the raw `agent` field. Both default to null for old nodes
1618
+ // that never reported a model — the dashboard falls back to a placeholder.
1619
+ const sessions = db.all(sql, ...params).map((s: any) => {
1620
+ if (isLight) {
1621
+ return {
1622
+ alias: s.alias,
1623
+ status: s.status,
1624
+ agent: s.agent ?? null,
1625
+ task: s.task ?? null,
1626
+ server: s.server ?? null,
1627
+ updated_at: s.updated_at ?? null,
1628
+ runtime: normalizeRuntime(s.agent),
1629
+ network_id: s.network_id ?? null,
1630
+ };
1631
+ }
1632
+ const externalSchedules = (() => {
1633
+ if (typeof s.external_schedules !== "string") return null;
1634
+ try { return JSON.parse(s.external_schedules); } catch { return null; }
1635
+ })();
1636
+ const { external_schedules: _externalSchedulesJson, ...publicSession } = s;
1637
+ return {
1638
+ ...publicSession,
1639
+ external_schedules: externalSchedules,
1640
+ model: s.model ?? null,
1641
+ runtime: normalizeRuntime(s.agent),
1642
+ host: {
1643
+ hostname: s.hostname ?? null,
1644
+ ip: s.ip ?? null,
1645
+ cpu_load_1min: s.cpu_load_1min ?? null,
1646
+ cpu_cores: s.cpu_cores ?? null,
1647
+ mem_total_gb: s.mem_total_gb ?? null,
1648
+ mem_used_gb: s.mem_used_gb ?? null,
1649
+ mem_avail_gb: s.mem_avail_gb ?? null,
1650
+ disk_total_gb: s.disk_total_gb ?? null,
1651
+ disk_used_gb: s.disk_used_gb ?? null,
1652
+ disk_avail_gb: s.disk_avail_gb ?? null,
1653
+ },
1654
+ process_telemetry: {
1655
+ rss_bytes: s.process_rss_bytes ?? null,
1656
+ rss_mb: s.process_rss_mb ?? null,
1657
+ cpu_pct: s.process_cpu_pct ?? null,
1658
+ uptime_seconds: s.process_uptime_seconds ?? null,
1659
+ in_flight_count: s.process_in_flight_count ?? null,
1660
+ },
1661
+ };
1662
+ });
1663
+ const summary = sessions.reduce((acc: any, session: any) => {
1664
+ const raw = String(session.status || "").toLowerCase();
1665
+ if (raw === "offline") acc.offline++;
1666
+ else if (["working", "blocked", "error", "waiting_input", "running", "busy"].includes(raw)) acc.working++;
1667
+ else acc.idle++;
1668
+ return acc;
1669
+ }, { idle: 0, working: 0, offline: 0, total: sessions.length });
1670
+ return withCors(req, Response.json({ ok: true, sessions, summary }));
1671
+ }
1672
+
1673
+ // ── REST: aggregate agents by physical server ──
1674
+ if (url.pathname === "/api/servers") {
1675
+ // Round-2/4 review ③: stale-marking moved to background sweeper.
1676
+ const params: any[] = [];
1677
+ let sql = `
1678
+ SELECT hostname, ip, cpu_load_1min, cpu_cores, mem_avail_gb, mem_used_gb,
1679
+ COALESCE(last_seen_at, updated_at) AS last_seen
1680
+ FROM sessions
1681
+ WHERE 1=1
1682
+ `;
1683
+ sql = addNetworkScope(sql, params, restScope);
1684
+ sql += " ORDER BY COALESCE(last_seen_at, updated_at) DESC";
1685
+
1686
+ const grouped = new Map<string, {
1687
+ hostname: string;
1688
+ ip: string;
1689
+ agent_count: number;
1690
+ cpu_load_1min: number | null;
1691
+ cpu_cores: number | null;
1692
+ mem_avail_gb: number | null;
1693
+ mem_used_gb: number | null;
1694
+ last_seen: string | null;
1695
+ }>();
1696
+
1697
+ const preferDisplayIp = (current: string, next: string) => {
1698
+ const isWeak = (ip: string) => !ip || ip === "unknown" || ip === "127.0.0.1" || ip === "::1";
1699
+ if (isWeak(current) && !isWeak(next)) return next;
1700
+ return current;
1701
+ };
1702
+ const hasHostTelemetry = (row: any) =>
1703
+ row.cpu_load_1min != null || row.cpu_cores != null || row.mem_avail_gb != null || row.mem_used_gb != null;
1704
+
1705
+ for (const row of db.all<any>(sql, ...params)) {
1706
+ const hostname = row.hostname || "unknown";
1707
+ const ip = row.ip || "unknown";
1708
+ // Group primarily by hostname. A single host can report both a
1709
+ // routable/container IP and loopback (127.0.0.1); splitting those
1710
+ // into separate cards makes the dashboard show one useful load row
1711
+ // plus one "n/a" duplicate. Unknown hostnames still fall back to IP.
1712
+ const key = hostname !== "unknown" ? `host:${hostname}` : `ip:${ip}`;
1713
+ const existing = grouped.get(key);
1714
+ if (existing) {
1715
+ existing.agent_count += 1;
1716
+ existing.ip = preferDisplayIp(existing.ip, ip);
1717
+ if (parseSqliteTime(row.last_seen) > parseSqliteTime(existing.last_seen)) existing.last_seen = row.last_seen ?? existing.last_seen;
1718
+ if (hasHostTelemetry(row) && (
1719
+ existing.cpu_load_1min == null ||
1720
+ existing.cpu_cores == null ||
1721
+ existing.mem_avail_gb == null ||
1722
+ existing.mem_used_gb == null
1723
+ )) {
1724
+ existing.cpu_load_1min = row.cpu_load_1min ?? existing.cpu_load_1min;
1725
+ existing.cpu_cores = row.cpu_cores ?? existing.cpu_cores;
1726
+ existing.mem_avail_gb = row.mem_avail_gb ?? existing.mem_avail_gb;
1727
+ existing.mem_used_gb = row.mem_used_gb ?? existing.mem_used_gb;
1728
+ }
1729
+ continue;
1730
+ }
1731
+ grouped.set(key, {
1732
+ hostname,
1733
+ ip,
1734
+ agent_count: 1,
1735
+ cpu_load_1min: row.cpu_load_1min ?? null,
1736
+ cpu_cores: row.cpu_cores ?? null,
1737
+ mem_avail_gb: row.mem_avail_gb ?? null,
1738
+ mem_used_gb: row.mem_used_gb ?? null,
1739
+ last_seen: row.last_seen ?? null,
1740
+ });
1741
+ }
1742
+
1743
+ return withCors(req, Response.json(Array.from(grouped.values())));
1744
+ }
1745
+
1746
+ const serverDetailMatch = url.pathname.match(/^\/api\/server\/([^/]+)\/(health|agents)$/);
1747
+ if (serverDetailMatch && req.method === "GET") {
1748
+ const host = decodeURIComponent(serverDetailMatch[1]);
1749
+ const detailKind = serverDetailMatch[2];
1750
+ if (!host) return withCors(req, Response.json({ ok: false, error: "host required" }, { status: 400 }));
1751
+
1752
+ // Round-2/4 review ③: stale-marking moved to background sweeper.
1753
+ if (detailKind === "agents") {
1754
+ const params: any[] = [host, host];
1755
+ let sql = `
1756
+ SELECT alias, agent, status, task, progress, model, hostname, ip, external_schedules,
1757
+ cpu_load_1min, cpu_cores, mem_avail_gb, mem_used_gb, mem_total_gb,
1758
+ disk_avail_gb, disk_used_gb, disk_total_gb,
1759
+ process_rss_bytes, process_rss_mb, process_cpu_pct, process_uptime_seconds, process_in_flight_count,
1760
+ COALESCE(last_seen_at, updated_at) AS last_seen
1761
+ FROM sessions
1762
+ WHERE (hostname = ?1 OR ip = ?2)
1763
+ `;
1764
+ sql = addNetworkScope(sql, params, restScope);
1765
+ sql += " ORDER BY alias";
1766
+ const agents = db.all<any>(sql, ...params).map((s) => ({
1767
+ alias: s.alias,
1768
+ runtime: normalizeRuntime(s.agent),
1769
+ raw_agent: s.agent ?? null,
1770
+ model: s.model ?? null,
1771
+ status: s.status ?? "offline",
1772
+ task: s.task ?? null,
1773
+ progress: s.progress ?? 0,
1774
+ external_schedules: (() => {
1775
+ if (typeof s.external_schedules !== "string") return null;
1776
+ try { return JSON.parse(s.external_schedules); } catch { return null; }
1777
+ })(),
1778
+ last_seen: s.last_seen ?? null,
1779
+ health: agentHealthChip(s.status, s.last_seen),
1780
+ hostname: s.hostname ?? null,
1781
+ ip: s.ip ?? null,
1782
+ telemetry: {
1783
+ cpu_load_1min: s.cpu_load_1min ?? null,
1784
+ cpu_cores: s.cpu_cores ?? null,
1785
+ cpu_pct: cpuPct(s.cpu_load_1min, s.cpu_cores),
1786
+ mem_total_gb: s.mem_total_gb ?? null,
1787
+ mem_used_gb: s.mem_used_gb ?? null,
1788
+ mem_avail_gb: s.mem_avail_gb ?? null,
1789
+ disk_total_gb: s.disk_total_gb ?? null,
1790
+ disk_used_gb: s.disk_used_gb ?? null,
1791
+ disk_avail_gb: s.disk_avail_gb ?? null,
1792
+ process_rss_bytes: s.process_rss_bytes ?? null,
1793
+ process_rss_mb: s.process_rss_mb ?? null,
1794
+ process_cpu_pct: s.process_cpu_pct ?? null,
1795
+ process_uptime_seconds: s.process_uptime_seconds ?? null,
1796
+ process_in_flight_count: s.process_in_flight_count ?? null,
1797
+ },
1798
+ process_telemetry: {
1799
+ rss_bytes: s.process_rss_bytes ?? null,
1800
+ rss_mb: s.process_rss_mb ?? null,
1801
+ cpu_pct: s.process_cpu_pct ?? null,
1802
+ uptime_seconds: s.process_uptime_seconds ?? null,
1803
+ in_flight_count: s.process_in_flight_count ?? null,
1804
+ },
1805
+ }));
1806
+ if (agents.length === 0) return withCors(req, Response.json({ ok: false, error: "server not found" }, { status: 404 }));
1807
+ return withCors(req, Response.json({ ok: true, host, agent_count: agents.length, agents }));
1808
+ }
1809
+
1810
+ const latestParams: any[] = [host, host];
1811
+ let latestSql = `
1812
+ SELECT hostname, ip, COUNT(*) OVER () AS agent_count,
1813
+ cpu_load_1min, cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb,
1814
+ disk_total_gb, disk_used_gb, disk_avail_gb,
1815
+ COALESCE(last_seen_at, updated_at) AS last_seen
1816
+ FROM sessions
1817
+ WHERE (hostname = ?1 OR ip = ?2)
1818
+ `;
1819
+ latestSql = addNetworkScope(latestSql, latestParams, restScope);
1820
+ latestSql += " ORDER BY COALESCE(last_seen_at, updated_at) DESC LIMIT 1";
1821
+ const latest = db.get<any>(latestSql, ...latestParams);
1822
+ if (!latest) return withCors(req, Response.json({ ok: false, error: "server not found" }, { status: 404 }));
1823
+
1824
+ const since24h = sqliteTime(new Date(Date.now() - 24 * 60 * 60 * 1000));
1825
+ const histParams: any[] = [host, host, since24h];
1826
+ let histSql = `
1827
+ SELECT created_at, cpu_load_1min, cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb,
1828
+ disk_total_gb, disk_used_gb, disk_avail_gb
1829
+ FROM agent_telemetry
1830
+ WHERE (hostname = ?1 OR ip = ?2) AND created_at >= ?3
1831
+ `;
1832
+ histSql = addNetworkScope(histSql, histParams, restScope);
1833
+ histSql += " ORDER BY created_at ASC";
1834
+ const historyRows = db.all<any>(histSql, ...histParams);
1835
+ const now = Date.now();
1836
+ const alert = serverAlertLevel(latest);
1837
+
1838
+ return withCors(req, Response.json({
1839
+ ok: true,
1840
+ host,
1841
+ hostname: latest.hostname ?? null,
1842
+ ip: latest.ip ?? null,
1843
+ agent_count: latest.agent_count ?? 0,
1844
+ alert_level: alert.level,
1845
+ alerts: alert.alerts,
1846
+ latest: {
1847
+ cpu_load_1min: latest.cpu_load_1min ?? null,
1848
+ cpu_cores: latest.cpu_cores ?? null,
1849
+ cpu_pct: cpuPct(latest.cpu_load_1min, latest.cpu_cores),
1850
+ mem_total_gb: latest.mem_total_gb ?? null,
1851
+ mem_used_gb: latest.mem_used_gb ?? null,
1852
+ mem_avail_gb: latest.mem_avail_gb ?? null,
1853
+ disk_total_gb: latest.disk_total_gb ?? null,
1854
+ disk_used_gb: latest.disk_used_gb ?? null,
1855
+ disk_avail_gb: latest.disk_avail_gb ?? null,
1856
+ last_seen: latest.last_seen ?? null,
1857
+ },
1858
+ history: {
1859
+ "5m": bucketTelemetry(historyRows, now - 5 * 60 * 1000, 60 * 1000),
1860
+ "1h": bucketTelemetry(historyRows, now - 60 * 60 * 1000, 5 * 60 * 1000),
1861
+ "24h": bucketTelemetry(historyRows, now - 24 * 60 * 60 * 1000, 60 * 60 * 1000),
1862
+ },
1863
+ }));
1864
+ }
1865
+
1866
+ // ── REST: file upload (#221) ──
1867
+ // POST multipart/form-data with a single `file` field. Bearer auth.
1868
+ // 12 MiB cap (two-stage: Content-Length pre-check + post-parse size
1869
+ // verify). Returns { ok, file_id, path, url, size, mime }. The
1870
+ // file_id is server-generated (crypto.randomUUID with hyphens
1871
+ // stripped) so a client-supplied filename never enters the storage
1872
+ // path. The download URL `/api/files/<file_id>` requires the same
1873
+ // Bearer auth and forces Content-Disposition: attachment +
1874
+ // X-Content-Type-Options: nosniff so uploaded files can never be
1875
+ // executed or served as HTML.
1876
+ if (url.pathname === "/api/upload" && req.method === "POST") {
1877
+ // #503 Finding 3 = Option A (lead 40be9845): every 4xx returned
1878
+ // BEFORE `req.formData()` drains the request body MUST include
1879
+ // `Connection: close`. Otherwise the client's still-inbound bytes
1880
+ // land on a keepalive connection the server already answered on,
1881
+ // poisoning the next pooled request. Bug is pre-existing (411/
1882
+ // 413/415/429/401 all have this shape) — the #503 authz block
1883
+ // just made it visible under aggregate `bun test` with a 12MiB
1884
+ // upload immediately followed by another request. Per-file green
1885
+ // hides this class of bug.
1886
+ const earlyReject = (res: Response): Response => {
1887
+ const wrapped = withCors(req, res);
1888
+ wrapped.headers.set("Connection", "close");
1889
+ return wrapped;
1890
+ };
1891
+ const authErr = requireAuth(req);
1892
+ if (authErr) return earlyReject(authErr);
1893
+
1894
+ // Rate-limit key prefers the token id (so 60/h is per-token, not
1895
+ // per-IP); falls back to IP for legacy or anonymous-but-dev-open.
1896
+ const authCtx = resolveRequestAuth(req);
1897
+ const rateKey = authCtx?.tokenId ?? `ip:${getClientIP(req, server)}`;
1898
+ const rate = sharedUploadRateLimiter.check(rateKey);
1899
+ if (!rate.allowed) {
1900
+ const headers: Record<string, string> = {
1901
+ "X-RateLimit-Limit": String(60),
1902
+ "X-RateLimit-Remaining": "0",
1903
+ "X-RateLimit-Reset": String(Math.floor(rate.resetAt / 1000)),
1904
+ };
1905
+ if (rate.retryAfterMs) headers["Retry-After"] = String(Math.ceil(rate.retryAfterMs / 1000));
1906
+ return earlyReject(new Response(
1907
+ JSON.stringify({ ok: false, error: "rate_limited", message: "Upload rate limit exceeded (60/hour). Try again later.", retry_after_ms: rate.retryAfterMs }),
1908
+ { status: 429, headers: { ...headers, "Content-Type": "application/json; charset=utf-8" } }, // #426 — legacy clients default to ISO-8859-1 without charset
1909
+ ));
1910
+ }
1911
+
1912
+ // Two-stage size cap per 通信龙 dispatch c38de7a9:
1913
+ // stage 1 — reject before reading body when Content-Length
1914
+ // exceeds the cap (saves bandwidth, fails fast)
1915
+ // stage 2 — re-verify the parsed File.size after Bun has parsed
1916
+ // the multipart envelope (defends against a missing
1917
+ // or lied Content-Length header)
1918
+ const contentLength = req.headers.get("Content-Length");
1919
+ if (!contentLength) {
1920
+ return earlyReject(Response.json(
1921
+ { ok: false, error: "length_required", message: "Content-Length header is required for /api/upload" },
1922
+ { status: 411 },
1923
+ ));
1924
+ }
1925
+ const declaredBytes = Number(contentLength);
1926
+ if (!Number.isFinite(declaredBytes) || declaredBytes < 0) {
1927
+ return earlyReject(Response.json({ ok: false, error: "bad_content_length" }, { status: 400 }));
1928
+ }
1929
+ if (declaredBytes > MAX_REQUEST_CONTENT_LENGTH) {
1930
+ return earlyReject(Response.json(
1931
+ { ok: false, error: "payload_too_large", message: `Upload exceeds the ${MAX_UPLOAD_BYTES} byte limit`, limit_bytes: MAX_UPLOAD_BYTES },
1932
+ { status: 413 },
1933
+ ));
1934
+ }
1935
+
1936
+ // #503 — decide which network this upload is attributed to, then
1937
+ // check the caller may write there. Attribution and authorization
1938
+ // are two questions; neither substitutes for the other. Placed
1939
+ // after the in-memory rate limiter (above) and before the body is
1940
+ // read, so an unauthorized caller never gets a DB query per byte
1941
+ // uploaded and never gets their multipart envelope parsed.
1942
+ const principal = resolvePrincipal(req);
1943
+ const requestedNetId = url.searchParams.get("network_id");
1944
+ let uploadNetId: string | null = null;
1945
+
1946
+ switch (principal.kind) {
1947
+ case "anonymous":
1948
+ // Unreachable: requireAuth above already 401s. Kept so the
1949
+ // switch stays exhaustive and a future auth change fails loud.
1950
+ return earlyReject(Response.json({ ok: false, error: "auth_required" }, { status: 401 }));
1951
+ case "dev-open-anon":
1952
+ // Local dev only. Deliberately does NOT claim a real network,
1953
+ // even if one was requested — an unauthenticated caller must
1954
+ // not be able to file blobs into a tenant's network.
1955
+ uploadNetId = null;
1956
+ break;
1957
+ case "legacy-master":
1958
+ // Unreachable today: requireAuth 401s master tokens on every
1959
+ // non-GET /api/ request (RFC-001 made them read-only). Kept
1960
+ // fail-closed so relaxing that rule cannot silently start
1961
+ // producing unattributed blobs.
1962
+ if (!requestedNetId) {
1963
+ return earlyReject(Response.json({
1964
+ ok: false, error: "network_id_required",
1965
+ message: "legacy master uploads must specify a network_id query param",
1966
+ }, { status: 400 }));
1967
+ }
1968
+ uploadNetId = requestedNetId;
1969
+ break;
1970
+ case "ntok":
1971
+ if (requestedNetId && requestedNetId !== principal.boundNetworkId) {
1972
+ return earlyReject(Response.json({
1973
+ ok: false, error: "network_id_conflict",
1974
+ message: "network_id query param conflicts with the token-bound network",
1975
+ }, { status: 400 }));
1976
+ }
1977
+ uploadNetId = principal.boundNetworkId;
1978
+ break;
1979
+ case "admin-utok": {
1980
+ // #503 Finding 2 = Option F (lead 40be9845): admin follows the same
1981
+ // "auto-derive when unambiguous / require param when ambiguous" rule
1982
+ // as utok_. Rule 4's REASON (no unowned files) is preserved — the
1983
+ // derived network is a real membership. Strict on genuine ambiguity:
1984
+ // 0 or ≥2 memberships → 400. Preserves prod upload contract (in
1985
+ // prod admin is in exactly 1 network; auto-derive succeeds).
1986
+ if (requestedNetId) {
1987
+ // Admin bypasses membership; network existence validated below.
1988
+ uploadNetId = requestedNetId;
1989
+ break;
1990
+ }
1991
+ const adminNetworks = getUserNetworkIds(principal.userId);
1992
+ if (adminNetworks.length === 1) {
1993
+ uploadNetId = adminNetworks[0];
1994
+ } else {
1995
+ return earlyReject(Response.json({
1996
+ ok: false, error: "network_id_required",
1997
+ message: "admin has 0 or ≥2 network memberships; network_id query param required",
1998
+ }, { status: 400 }));
1999
+ }
2000
+ break;
2001
+ }
2002
+ case "utok": {
2003
+ if (requestedNetId) {
2004
+ // A non-member asking for someone else's network never gets
2005
+ // here: the REST scope guard (`restScope.denied`) already
2006
+ // 403'd, with the same body whether the network exists or
2007
+ // not. Re-checking membership here would be a second, drifting
2008
+ // copy of a decision this codebase makes in one place.
2009
+ uploadNetId = requestedNetId;
2010
+ break;
2011
+ }
2012
+ uploadNetId = singleNetworkId(resolveRestNetworkScope(url.searchParams.get("network_id"), authCtx, false));
2013
+ if (!uploadNetId) {
2014
+ return earlyReject(Response.json({
2015
+ ok: false, error: "network_id_required",
2016
+ message: "network_id query param required for a multi-network user token; 'first network' is not assumed",
2017
+ }, { status: 400 }));
2018
+ }
2019
+ break;
2020
+ }
2021
+ }
2022
+
2023
+ if (uploadNetId !== null) {
2024
+ const networkRow = db.get<any>("SELECT network_id FROM networks WHERE network_id = ?1", uploadNetId);
2025
+ if (!networkRow) {
2026
+ if (principal.kind === "admin-utok" || principal.kind === "legacy-master") {
2027
+ return earlyReject(Response.json({
2028
+ ok: false, error: "unknown_network",
2029
+ message: "network_id does not exist",
2030
+ }, { status: 400 }));
2031
+ }
2032
+ // Byte-identical to the non-member 404 above: distinguishing
2033
+ // the two would hand an unprivileged caller a network-existence
2034
+ // oracle.
2035
+ return earlyReject(Response.json({ ok: false, error: "not_found" }, { status: 404 }));
2036
+ }
2037
+ }
2038
+
2039
+ if (!canRestWriteNetwork(authCtx, uploadNetId, principal.kind === "admin-utok")) {
2040
+ return earlyReject(Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
2041
+ }
2042
+
2043
+ const contentType = req.headers.get("Content-Type") ?? "";
2044
+ if (!/^multipart\/form-data/i.test(contentType)) {
2045
+ return earlyReject(Response.json(
2046
+ { ok: false, error: "unsupported_media_type", message: "Use multipart/form-data with a 'file' field" },
2047
+ { status: 415 },
2048
+ ));
2049
+ }
2050
+
2051
+ let form: FormData;
2052
+ try {
2053
+ form = await req.formData();
2054
+ } catch (e: any) {
2055
+ return withCors(req, Response.json(
2056
+ { ok: false, error: "multipart_parse_failed", message: e?.message?.slice(0, 200) ?? "multipart parse failed" },
2057
+ { status: 400 },
2058
+ ));
2059
+ }
2060
+ const file = form.get("file");
2061
+ if (!file || typeof file === "string") {
2062
+ return withCors(req, Response.json(
2063
+ { ok: false, error: "missing_file", message: "Expected a 'file' field with binary content" },
2064
+ { status: 400 },
2065
+ ));
2066
+ }
2067
+
2068
+ // Stage 2 — re-verify parsed size against the documented cap.
2069
+ if (file.size > MAX_UPLOAD_BYTES) {
2070
+ return withCors(req, Response.json(
2071
+ { ok: false, error: "payload_too_large", message: `Upload exceeds the ${MAX_UPLOAD_BYTES} byte limit`, limit_bytes: MAX_UPLOAD_BYTES, observed_bytes: file.size },
2072
+ { status: 413 },
2073
+ ));
2074
+ }
2075
+
2076
+ const fileId = generateFileId();
2077
+ const ext = sanitizeExt(file.name);
2078
+ let storage;
2079
+ try {
2080
+ storage = buildStoragePath(fileId, ext);
2081
+ } catch (e: any) {
2082
+ // Should be unreachable — we generated the id ourselves — but
2083
+ // surface as 500 if our own primitives reject our own input.
2084
+ console.error("[/api/upload] buildStoragePath rejected:", e?.message);
2085
+ return withCors(req, Response.json({ ok: false, error: "internal_path_error" }, { status: 500 }));
2086
+ }
2087
+
2088
+ try {
2089
+ mkdirSync(pathDirname(storage.absolutePath), { recursive: true });
2090
+ const buf = Buffer.from(await file.arrayBuffer());
2091
+ // #693 — write blob to .tmp then rename for crash-safe durability.
2092
+ const tmpPath = storage.absolutePath + ".tmp";
2093
+ writeFileSync(tmpPath, buf);
2094
+ // Defense-in-depth: after writing, confirm the path still sits
2095
+ // inside the uploads root (catches a symlink swap or root env
2096
+ // poisoning across processes).
2097
+ if (!isPathInsideUploadsRoot(tmpPath) || !isPathInsideUploadsRoot(storage.absolutePath)) {
2098
+ try { unlinkSync(tmpPath); } catch { /* best-effort */ }
2099
+ console.error("[/api/upload] write escaped uploads root:", storage.absolutePath);
2100
+ return withCors(req, Response.json({ ok: false, error: "internal_path_escape" }, { status: 500 }));
2101
+ }
2102
+
2103
+ // Persist the index entry so /api/files/<file_id> can do an
2104
+ // O(1) lookup without scanning every date bucket.
2105
+ const idxPath = indexEntryPath(fileId);
2106
+ if (idxPath) {
2107
+ mkdirSync(pathDirname(idxPath), { recursive: true });
2108
+ writeFileSync(idxPath, JSON.stringify({
2109
+ file_id: fileId,
2110
+ date_bucket: storage.dateBucket,
2111
+ ext: storage.ext,
2112
+ name: typeof file.name === "string" ? file.name.slice(0, 255) : "",
2113
+ mime: typeof file.type === "string" ? file.type.slice(0, 100) : "application/octet-stream",
2114
+ size: file.size,
2115
+ owner: authCtx?.username ?? null,
2116
+ owner_id: authCtx?.userId ?? null,
2117
+ // Key omitted (not null) when unattributed — see UploadIndexEntry.
2118
+ ...(uploadNetId ? { network_id: uploadNetId } : {}),
2119
+ uploaded_at: new Date().toISOString(),
2120
+ }, null, 2));
2121
+ } else {
2122
+ // No index path → refuse to leave an unindexed blob.
2123
+ try { unlinkSync(tmpPath); } catch { /* best-effort */ }
2124
+ return withCors(req, Response.json({ ok: false, error: "index_path_error" }, { status: 500 }));
2125
+ }
2126
+ // Index first, then publish blob via rename — if rename fails, drop index.
2127
+ try {
2128
+ renameSync(tmpPath, storage.absolutePath);
2129
+ } catch (renameErr: any) {
2130
+ try { unlinkSync(tmpPath); } catch { /* best-effort */ }
2131
+ try { if (idxPath) unlinkSync(idxPath); } catch { /* best-effort */ }
2132
+ throw renameErr;
2133
+ }
2134
+ } catch (e: any) {
2135
+ console.error("[/api/upload] write failed:", e?.message);
2136
+ return withCors(req, Response.json({ ok: false, error: "write_failed", message: e?.message?.slice(0, 200) ?? "write failed" }, { status: 500 }));
2137
+ }
2138
+
2139
+ return withCors(req, Response.json({
2140
+ ok: true,
2141
+ file_id: fileId,
2142
+ path: storage.absolutePath,
2143
+ url: `/api/files/${fileId}`,
2144
+ size: file.size,
2145
+ mime: typeof file.type === "string" ? file.type : "application/octet-stream",
2146
+ }));
2147
+ }
2148
+
2149
+ // ── REST: file download (#221 + #495 + #500 CR2 HEAD) ──
2150
+ // GET or HEAD /api/files/:file_id with Bearer auth + ownership
2151
+ // check. Always forces Content-Disposition: attachment +
2152
+ // X-Content-Type-Options: nosniff so the served file can never be
2153
+ // executed or rendered as HTML. The file_id is validated against
2154
+ // the same strict regex used at generation time before any
2155
+ // filesystem access.
2156
+ //
2157
+ // Ownership gate is factored into `authorizeFileDownload` (see
2158
+ // src/server.ts near resolveRequestAuth). HEAD MUST route through
2159
+ // the same helper — pre-#500-CR2, HEAD fell through to a fallback
2160
+ // 200 page whose body happened to be byte-identical to the
2161
+ // "unknown file" case, so no enumeration oracle formed in practice.
2162
+ // That was coincidence, not a gate. A change to the fallback (e.g.
2163
+ // adding "Requested resource: <hint>") would materialize the oracle
2164
+ // with zero failing tests. Any future Range, conditional-request,
2165
+ // or dashboard-proxy path for /api/files MUST route through this
2166
+ // handler (via authorizeFileDownload) — do not duplicate the
2167
+ // allow-list inline, or the branches will drift.
2168
+ const fileMatch = url.pathname.match(/^\/api\/files\/(.+)$/);
2169
+ if (fileMatch && (req.method === "GET" || req.method === "HEAD")) {
2170
+ // #501: file credentials must never travel in the URL. Query tokens
2171
+ // leak through browser history, access/proxy logs, copied links, and
2172
+ // Referer. SSE/EventSource compatibility keeps the global query-token
2173
+ // fallback for now; file GET/HEAD deliberately opt out here.
2174
+ const authErr = requireAuth(req, { allowQueryToken: false });
2175
+ if (authErr) return withCors(req, authErr);
2176
+
2177
+ const fileId = decodeURIComponent(fileMatch[1]);
2178
+ if (!FILE_ID_REGEX.test(fileId)) {
2179
+ return withCors(req, Response.json({ ok: false, error: "bad_file_id" }, { status: 400 }));
2180
+ }
2181
+
2182
+ const idxPath = indexEntryPath(fileId);
2183
+ if (!idxPath || !existsSync(idxPath)) {
2184
+ return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
2185
+ }
2186
+
2187
+ let entry: any;
2188
+ try { entry = JSON.parse(readFileSync(idxPath, "utf-8")); } catch {
2189
+ // #509 副指挥 66983a19: corrupted index must not leak "server
2190
+ // failed" signal — 404 with same shape as unknown file_id
2191
+ // (input refused at boundary, no enumeration).
2192
+ return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
2193
+ }
2194
+ if (!validateIndexEntry(entry)) {
2195
+ // #509 副指挥 66983a19: schema-invalid index (bucket "../etc",
2196
+ // missing fields, wrong types) is also 404 — same discipline.
2197
+ return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
2198
+ }
2199
+
2200
+ // #495 ownership gate — placed AFTER file-exists so denied
2201
+ // callers cannot distinguish "you don't own this" from
2202
+ // "no such file". Both branches return the same 404 shape
2203
+ // for BOTH GET and HEAD (HEAD honours body-omission but the
2204
+ // status code and headers are the authoritative signal).
2205
+ if (!authorizeFileDownload(resolvePrincipal(req, { allowQueryToken: false }), normalizeEntry(entry))) {
2206
+ return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
2207
+ }
2208
+
2209
+ // #509 defence-in-depth: pre-check date_bucket against BOTH
2210
+ // shape regex AND real-calendar semantics. A poisoned index (e.g.
2211
+ // "../etc", "2026-02-30", "2026-13-01") must return 404 not 500
2212
+ // — 500 says "server failed"; 404 says "input refused at boundary".
2213
+ // 副指挥 b1082017 / 66983a19 hard doors:
2214
+ // • 稳定 404 非 500 for invalid/missing/poisoned date_bucket
2215
+ // • 真日历语义校验, 不仅 regex 形状
2216
+ if (!isValidCalendarBucket(entry.date_bucket)) {
2217
+ return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
2218
+ }
2219
+
2220
+ let storage;
2221
+ try {
2222
+ // #509 fix: use the date_bucket recorded in the index at upload
2223
+ // time, NOT today's date. buildStoragePath computes today; that is
2224
+ // correct for new uploads only. Yesterday's file lives in
2225
+ // yesterday's bucket forever — read path must respect that.
2226
+ storage = pathForExistingBlob(entry.date_bucket, entry.file_id, entry.ext);
2227
+ } catch {
2228
+ // Defence-in-depth: unreachable given the pre-check above, but
2229
+ // keep as fallback. Return 404 not 500 to match the pre-check
2230
+ // shape — caller sees the same code for all invalid-bucket cases.
2231
+ return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
2232
+ }
2233
+ if (!isPathInsideUploadsRoot(storage.absolutePath)) {
2234
+ // Defence: should be unreachable since buildStoragePath rejected
2235
+ // malformed inputs, but a poisoned index could try to fool us.
2236
+ return withCors(req, Response.json({ ok: false, error: "path_escape" }, { status: 500 }));
2237
+ }
2238
+ if (!existsSync(storage.absolutePath)) {
2239
+ return withCors(req, Response.json({ ok: false, error: "blob_missing" }, { status: 404 }));
2240
+ }
2241
+
2242
+ // Defence: verify the on-disk size matches what the index claims
2243
+ // before serving — a tampered index could otherwise mislead the
2244
+ // caller about file integrity.
2245
+ const st = statSync(storage.absolutePath);
2246
+ if (st.size !== entry.size) {
2247
+ console.error("[/api/files] size mismatch:", { fileId, indexed: entry.size, onDisk: st.size });
2248
+ return withCors(req, Response.json({ ok: false, error: "size_mismatch" }, { status: 500 }));
2249
+ }
2250
+
2251
+ const safeFilename = (entry.name && /^[\x20-\x7e]+$/.test(entry.name))
2252
+ ? entry.name
2253
+ : `${fileId}${entry.ext}`;
2254
+ const responseHeaders: Record<string, string> = {
2255
+ ...corsHeaders(req),
2256
+ "Content-Type": "application/octet-stream", // always opaque; nosniff prevents the client from re-deciding
2257
+ "Content-Length": String(entry.size),
2258
+ "Content-Disposition": `attachment; filename="${safeFilename.replace(/"/g, "")}"`,
2259
+ "X-Content-Type-Options": "nosniff",
2260
+ "Cache-Control": "private, no-store",
2261
+ "Accept-Ranges": "bytes",
2262
+ };
2263
+ const range = parseSingleByteRange(req.headers.get("Range"), entry.size);
2264
+ if (range && !range.ok) {
2265
+ return new Response(null, {
2266
+ status: 416,
2267
+ headers: {
2268
+ ...responseHeaders,
2269
+ "Content-Length": "0",
2270
+ "Content-Range": `bytes */${entry.size}`,
2271
+ },
2272
+ });
2273
+ }
2274
+ if (range?.ok) {
2275
+ const headers = {
2276
+ ...responseHeaders,
2277
+ "Content-Length": String(range.length),
2278
+ "Content-Range": `bytes ${range.start}-${range.end}/${entry.size}`,
2279
+ };
2280
+ if (req.method === "HEAD") {
2281
+ return new Response(null, { status: 206, headers });
2282
+ }
2283
+ const blob = Bun.file(storage.absolutePath).slice(range.start, range.end + 1);
2284
+ return new Response(blob, { status: 206, headers });
2285
+ }
2286
+ // HEAD: return the same status and headers as GET, but no body,
2287
+ // per RFC 9110 §9.3.2. This ensures HEAD passes through the same
2288
+ // authorization helper — a cross-owner HEAD is 404 (same as GET),
2289
+ // not a fallback 200. Status/headers are the authoritative
2290
+ // signal; body omission is HTTP-standard for HEAD.
2291
+ if (req.method === "HEAD") {
2292
+ return new Response(null, { status: 200, headers: responseHeaders });
2293
+ }
2294
+ const blob = Bun.file(storage.absolutePath);
2295
+ return new Response(blob, { status: 200, headers: responseHeaders });
2296
+ }
2297
+
2298
+ // ── REST: send task ──
2299
+ if (url.pathname === "/api/task" && req.method === "POST") {
2300
+ let raw: unknown;
2301
+ try {
2302
+ raw = await req.json();
2303
+ } catch {
2304
+ return withCors(req, Response.json({ error: "invalid JSON" }, { status: 400 }));
2305
+ }
2306
+ const parsed = TaskSchema.safeParse(raw);
2307
+ if (!parsed.success) {
2308
+ return withCors(req, Response.json({ error: "invalid input", details: parsed.error.format() }, { status: 400 }));
2309
+ }
2310
+ const body = parsed.data;
2311
+ let taskNetId: string | null = null;
2312
+ if (restAuth?.networkId) {
2313
+ taskNetId = restAuth.networkId;
2314
+ } else if (body.network_id) {
2315
+ if (restAuth && !isAdmin && !getUserNetworkRole(restAuth.userId, body.network_id)) {
2316
+ return withCors(req, Response.json({ ok: false, error: "access denied to requested network" }, { status: 403 }));
2317
+ }
2318
+ taskNetId = body.network_id;
2319
+ } else {
2320
+ taskNetId = restAuth
2321
+ ? resolveRestWriteNetworkId(restScope, restAuth, isAdmin)
2322
+ : null;
2323
+ }
2324
+ if (restAuth && !taskNetId) {
2325
+ return withCors(req, Response.json({
2326
+ ok: false,
2327
+ error: "network_id_required",
2328
+ message: "network_id is required when the user token has zero or multiple network memberships",
2329
+ }, { status: 400 }));
2330
+ }
2331
+ if (!canRestWriteNetwork(restAuth, taskNetId, isAdmin)) {
2332
+ return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
2333
+ }
2334
+ const canonical = resolveCanonicalAlias(taskNetId, body.alias);
2335
+ const targetAlias = canonical.alias;
2336
+ const target = resolveRestDeliveryTarget(targetAlias, taskNetId);
2337
+ if (target.state === "not_found") {
2338
+ return withCors(req, Response.json({
2339
+ ok: false,
2340
+ error: "alias_not_found",
2341
+ message: target.message,
2342
+ alias: targetAlias,
2343
+ queued: false,
2344
+ ...(canonical.renamed ? { renamed_from: body.alias, renamed_to: targetAlias } : {}),
2345
+ }, { status: 404 }));
2346
+ }
2347
+ const id = crypto.randomUUID();
2348
+ // Identity binding, mirroring the MCP transport (tools.ts defaultFrom /
2349
+ // fromIdentityMismatchReply). Both transports resolve the same
2350
+ // api_tokens row through resolveToken, and auth.ts already documents the
2351
+ // invariant: "Network-bound node tokens are an identity boundary: they
2352
+ // must not spoof another node via from_session."
2353
+ //
2354
+ // MCP enforced it; this REST handler did not — it took `body.from`
2355
+ // verbatim, so any node could POST /api/task with another node's alias
2356
+ // and be recorded (and, once sender labels ship, *rendered in a human's
2357
+ // TUI*) as that node. Same table, same token, one transport hardened and
2358
+ // its sibling not.
2359
+ //
2360
+ // User tokens keep the previous behaviour: the Dashboard legitimately
2361
+ // posts as its logged-in user, and tightening that is a separate change
2362
+ // with its own compatibility surface.
2363
+ // The decision itself lives in rest-identity.ts as a pure function so the
2364
+ // shipped path and the tested path are the same code.
2365
+ const identity = resolveRestFromSession({
2366
+ token: requestToken(req),
2367
+ tokenName: restAuth?.tokenName,
2368
+ authenticatedUsername: restAuth?.username,
2369
+ requestedFrom: body.from,
2370
+ });
2371
+ if (!identity.ok) {
2372
+ return withCors(req, Response.json({
2373
+ ok: false,
2374
+ error: identity.error,
2375
+ message: identity.message,
2376
+ token_alias: identity.tokenAlias,
2377
+ requested_from_session: identity.requestedFromSession,
2378
+ }, { status: 403 }));
2379
+ }
2380
+ const fromSession = identity.fromSession;
2381
+ const ttlSeconds = (body as any).ttl_seconds || 3600;
2382
+ const fromNodeId = resolveRestNodeIdForAlias(fromSession, taskNetId);
2383
+ const targetNodeId = target.session?.node_id ?? null;
2384
+ const actualTo = restActualTo(target, taskNetId);
2385
+ // #221 — fold top-level `attachments` into `meta.attachments` so
2386
+ // the REST and MCP send_task transports produce identical
2387
+ // tasks.meta_json shape downstream. Top-level wins over any
2388
+ // duplicate `meta.attachments` the client supplied. Validation
2389
+ // matches the MCP-side schema (max 20 entries, file_id regex,
2390
+ // size cap, etc.).
2391
+ const attachmentsResult = validateAttachments((body as any).attachments ?? (body as any).meta?.attachments);
2392
+ if (!attachmentsResult.ok) {
2393
+ return withCors(req, Response.json({ ok: false, error: "bad_attachments", message: attachmentsResult.error }, { status: 400 }));
2394
+ }
2395
+ const mergedMeta = attachmentsResult.attachments.length
2396
+ ? { ...((body as any).meta && typeof (body as any).meta === "object" ? (body as any).meta : {}), attachments: attachmentsResult.attachments }
2397
+ : (body as any).meta;
2398
+ const rawToken = requestToken(req);
2399
+ const authOrigin: TaskAuthOrigin = rawToken.startsWith("ntok_")
2400
+ ? "node"
2401
+ : restAuth
2402
+ ? "user"
2403
+ : "legacy";
2404
+ const metaJson = normalizeMetaJson(stampTaskAuthOrigin(mergedMeta, authOrigin));
2405
+
2406
+ // #212 dedup guardrail. Mirrors the MCP `send_task` tool: same
2407
+ // (from_session, target_alias, task) within COMMHUB_SEND_DEDUP_WINDOW_MS
2408
+ // is rejected with a structured `duplicate_send` error so dashboard
2409
+ // dispatch buttons and scripted REST callers get the same guarantee
2410
+ // as agent-driven MCP traffic.
2411
+ const dedup = sharedSendDedup.check(fromSession, targetAlias, body.task);
2412
+ if (dedup.duplicate) {
2413
+ const payload = buildDuplicateSendPayload({
2414
+ from: fromSession,
2415
+ to: targetAlias,
2416
+ ageMs: dedup.ageMs,
2417
+ windowMs: sharedSendDedup.windowMs,
2418
+ });
2419
+ console.log(`[/api/task] ${fromSession} → ${targetAlias}: DROPPED duplicate (age=${dedup.ageMs}ms, window=${sharedSendDedup.windowMs}ms)`);
2420
+ return withCors(req, Response.json(payload, { status: 429 }));
2421
+ }
2422
+
2423
+ // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.2a REST site
2424
+ // 1/2 — paired with the 6 MCP sites guarded in tools.ts at PR1.1).
2425
+ // Dashboard's "Dispatch" button hits this endpoint, so without
2426
+ // the guard a stopping/stopped/deleting node still got tasks
2427
+ // routed through REST. Returns 409 with the same structured
2428
+ // payload the MCP handlers use.
2429
+ {
2430
+ const lc = assertNodeActive(targetAlias, taskNetId ?? null);
2431
+ if (!lc.ok) {
2432
+ return withCors(req, Response.json(lc, { status: 409 }));
2433
+ }
2434
+ }
2435
+ // Mirror send_task MCP: write inbox + tasks rows in a single
2436
+ // transaction so the dispatch is visible to dashboard's Tasks page
2437
+ // and the parent_task_id lineage chain. Previously this endpoint
2438
+ // only wrote inbox, leaving GET /api/tasks empty for any task
2439
+ // dispatched via REST (anet demo, dashboard Dispatch button, etc.).
2440
+ db.transaction(() => {
2441
+ db.run(
2442
+ `INSERT INTO inbox (id, task_id, session_name, node_id, type, priority, content, from_session, requires_response, network_id, meta_json)
2443
+ VALUES (?1, ?1, ?2, ?3, 'task', ?4, ?5, ?6, 'reply', ?7, ?8)`,
2444
+ [id, targetAlias, targetNodeId, body.priority, body.task, fromSession, taskNetId, metaJson]
2445
+ );
2446
+ db.run(
2447
+ `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)
2448
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'delivered', ?7, 'reply', datetime('now'), datetime('now'), datetime('now', ?8), ?9, ?10, ?11)`,
2449
+ [id, fromNodeId, fromSession, targetNodeId, targetAlias, body.priority, body.task, `+${ttlSeconds} seconds`, taskNetId, body.parent_task_id ?? null, metaJson]
2450
+ );
2451
+ // Touch session row so the dashboard reflects "task in flight"
2452
+ // immediately, without waiting for the agent's report_status to
2453
+ // arrive. Updating both `task` and `updated_at` is enough — we
2454
+ // leave `status` to the agent (idle → working → idle).
2455
+ const touchParams: any[] = [body.task.slice(0, 200), targetAlias];
2456
+ let touchSql = "UPDATE sessions SET task = ?1, updated_at = datetime('now') WHERE alias = ?2";
2457
+ if (taskNetId) { touchSql += " AND network_id = ?3"; touchParams.push(taskNetId); }
2458
+ db.run(touchSql, touchParams);
2459
+ });
2460
+ // SSE push: 秒达
2461
+ const pendingParams: any[] = [targetAlias];
2462
+ let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
2463
+ if (taskNetId) { pendingSql += " AND network_id = ?2"; pendingParams.push(taskNetId); }
2464
+ const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
2465
+ if (target.state === "online") {
2466
+ pushEvent(targetAlias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority: body.priority, from: fromSession, ...(canonical.renamed ? { renamed_from: body.alias } : {}) }, taskNetId);
2467
+ }
2468
+ // #461 network observer summary — unconditional (the task row
2469
+ // exists even when the target is offline/queued), metadata only.
2470
+ pushNetworkObserverEvent(taskNetId, { type: "new_task", task_id: id, from: fromSession, to: targetAlias, status: target.state === "online" ? "delivered" : "queued", priority: body.priority });
2471
+ // #212 — stamp the dedup index only after the inbox/tasks insert
2472
+ // succeeds. Mirrors the MCP `send_task` path so a failed write
2473
+ // never shadows a legitimate retry.
2474
+ sharedSendDedup.record(fromSession, targetAlias, body.task);
2475
+ if (target.state === "offline") {
2476
+ return withCors(req, Response.json({
2477
+ ok: false,
2478
+ error: "alias_offline",
2479
+ message: target.message,
2480
+ alias: targetAlias,
2481
+ queued: true,
2482
+ task_id: id,
2483
+ message_id: id,
2484
+ session_status: target.session.status ?? "offline",
2485
+ actual_to: actualTo,
2486
+ ...(canonical.renamed ? { renamed_from: body.alias, renamed_to: targetAlias } : {}),
2487
+ }, { status: 202 }));
2488
+ }
2489
+ return withCors(req, Response.json({
2490
+ ok: true,
2491
+ task_id: id,
2492
+ message_id: id,
2493
+ actual_to: actualTo,
2494
+ ...(canonical.renamed ? { renamed_from: body.alias, renamed_to: targetAlias } : {}),
2495
+ }));
2496
+ }
2497
+
2498
+ // ── REST: broadcast ──
2499
+ if (url.pathname === "/api/broadcast" && req.method === "POST") {
2500
+ let raw: unknown;
2501
+ try {
2502
+ raw = await req.json();
2503
+ } catch {
2504
+ return withCors(req, Response.json({ error: "invalid JSON" }, { status: 400 }));
2505
+ }
2506
+ const parsed = BroadcastSchema.safeParse(raw);
2507
+ if (!parsed.success) {
2508
+ return withCors(req, Response.json({ error: "invalid input", details: parsed.error.format() }, { status: 400 }));
2509
+ }
2510
+ const body = parsed.data;
2511
+ if (restAuth && !restScope.networkId && !isAdmin) {
2512
+ return withCors(req, Response.json({ ok: false, error: "network_id required for user token when broadcasting" }, { status: 400 }));
2513
+ }
2514
+ if (!canRestWriteNetwork(restAuth, restScope.networkId, isAdmin)) {
2515
+ return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
2516
+ }
2517
+ let sql = "SELECT alias, node_id, network_id FROM sessions WHERE alias IS NOT NULL";
2518
+ const params: any[] = [];
2519
+ sql = addNetworkScope(sql, params, restScope);
2520
+ if (body.filter_server) { sql += " AND server = ?"; params.push(body.filter_server); }
2521
+ if (body.filter_status) { sql += " AND status = ?"; params.push(body.filter_status); }
2522
+ const targets = db.all<{ alias: string; node_id: string | null; network_id: string | null }>(sql, ...params);
2523
+ const ids: string[] = [];
2524
+ for (const t of targets) {
2525
+ // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.2a REST site
2526
+ // 2/2). Broadcast skips non-active recipients silently per its
2527
+ // best-effort semantics — mirrors the MCP broadcast handler
2528
+ // in tools.ts (PR1.1 site 6/6).
2529
+ const lc = assertNodeActive(t.alias, t.network_id ?? null);
2530
+ if (!lc.ok) continue;
2531
+ const id = crypto.randomUUID();
2532
+ db.run(
2533
+ `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id)
2534
+ VALUES (?1, ?2, ?3, 'broadcast', 'normal', ?4, 'api', ?5)`,
2535
+ [id, t.alias, t.node_id ?? null, body.message, t.network_id]
2536
+ );
2537
+ ids.push(id);
2538
+ }
2539
+ for (const t of targets) {
2540
+ pushEvent(t.alias, { type: "broadcast", inbox_count: 1 }, t.network_id);
2541
+ }
2542
+ return withCors(req, Response.json({ ok: true, recipients: targets.length, message_ids: ids }));
2543
+ }
2544
+
2545
+ // ── REST: tmux capture-pane ──
2546
+ const tmuxCapture = url.pathname.match(/^\/api\/tmux\/([a-zA-Z0-9_-]+)$/);
2547
+ if (tmuxCapture && req.method === "GET") {
2548
+ const tmuxErr = requireTmuxAccess(req, server);
2549
+ if (tmuxErr) return withCors(req, tmuxErr);
2550
+ const name = tmuxCapture[1];
2551
+ const lines = Number(url.searchParams.get("lines")) || 30;
2552
+ try {
2553
+ const proc = Bun.spawn(["tmux", "capture-pane", "-t", name, "-p"], {
2554
+ stdout: "pipe", stderr: "pipe",
2555
+ });
2556
+ const text = await new Response(proc.stdout).text();
2557
+ const err = await new Response(proc.stderr).text();
2558
+ const code = await proc.exited;
2559
+ if (code !== 0) {
2560
+ return withCors(req, Response.json({ ok: false, error: err.trim() || `exit ${code}` }, { status: 400 }));
2561
+ }
2562
+ const trimmed = text.split("\n").slice(-lines).join("\n");
2563
+ return withCors(req, Response.json({ ok: true, tmux_name: name, lines: lines, output: trimmed }));
2564
+ } catch (e) {
2565
+ return withCors(req, Response.json({ ok: false, error: (e as Error).message }, { status: 500 }));
2566
+ }
2567
+ }
2568
+
2569
+ // ── REST: tmux send-keys ──
2570
+ const tmuxSend = url.pathname.match(/^\/api\/tmux\/([a-zA-Z0-9_-]+)\/send$/);
2571
+ if (tmuxSend && req.method === "POST") {
2572
+ const tmuxErr = requireTmuxAccess(req, server);
2573
+ if (tmuxErr) return withCors(req, tmuxErr);
2574
+ const name = tmuxSend[1];
2575
+ let body: { text?: string; enter?: boolean };
2576
+ try { body = await req.json(); } catch {
2577
+ return withCors(req, Response.json({ error: "invalid JSON" }, { status: 400 }));
2578
+ }
2579
+ if (!body.text || typeof body.text !== "string") {
2580
+ return withCors(req, Response.json({ error: "text is required" }, { status: 400 }));
2581
+ }
2582
+ const args = ["tmux", "send-keys", "-t", name, body.text];
2583
+ if (body.enter !== false) args.push("Enter");
2584
+ try {
2585
+ const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });
2586
+ const err = await new Response(proc.stderr).text();
2587
+ const code = await proc.exited;
2588
+ if (code !== 0) {
2589
+ return withCors(req, Response.json({ ok: false, error: err.trim() || `exit ${code}` }, { status: 400 }));
2590
+ }
2591
+ return withCors(req, Response.json({ ok: true, sent: body.text }));
2592
+ } catch (e) {
2593
+ return withCors(req, Response.json({ ok: false, error: (e as Error).message }, { status: 500 }));
2594
+ }
2595
+ }
2596
+
2597
+ // ── REST: recent messages (for Dashboard communication graph) ──
2598
+ if (url.pathname === "/api/messages") {
2599
+ const limit = Math.min(Number(url.searchParams.get("limit")) || 100, 500);
2600
+ const since = url.searchParams.get("since") ?? new Date(Date.now() - 3600000).toISOString().replace("T", " ").slice(0, 19);
2601
+ const params: any[] = [since];
2602
+ 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";
2603
+ sql = addNetworkScope(sql, params, restScope);
2604
+ sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
2605
+ params.push(limit);
2606
+ const rows = db.all(sql, ...params);
2607
+ return withCors(req, Response.json({ ok: true, messages: rows }));
2608
+ }
2609
+
2610
+ // ── REST: stats summary ──
2611
+ if (url.pathname === "/api/stats") {
2612
+ const taskStatsParams: any[] = [];
2613
+ let taskStatsSql = "SELECT status, COUNT(*) as count FROM tasks WHERE 1=1";
2614
+ taskStatsSql = addNetworkScope(taskStatsSql, taskStatsParams, restScope);
2615
+ taskStatsSql += " GROUP BY status";
2616
+ const taskStats = db.all<any>(taskStatsSql, ...taskStatsParams);
2617
+
2618
+ const sessionStatsParams: any[] = [];
2619
+ let sessionStatsSql = "SELECT status, COUNT(*) as count FROM sessions WHERE 1=1";
2620
+ sessionStatsSql = addNetworkScope(sessionStatsSql, sessionStatsParams, restScope);
2621
+ sessionStatsSql += " GROUP BY status";
2622
+ const sessionStats = db.all<any>(sessionStatsSql, ...sessionStatsParams);
2623
+
2624
+ const totalTasksParams: any[] = [];
2625
+ let totalTasksSql = "SELECT COUNT(*) as cnt FROM tasks WHERE 1=1";
2626
+ totalTasksSql = addNetworkScope(totalTasksSql, totalTasksParams, restScope);
2627
+ const totalTasks = db.get<{ cnt: number }>(totalTasksSql, ...totalTasksParams);
2628
+
2629
+ const totalNodesParams: any[] = [];
2630
+ let totalNodesSql = "SELECT COUNT(*) as cnt FROM nodes WHERE 1=1";
2631
+ totalNodesSql = addNetworkScope(totalNodesSql, totalNodesParams, restScope);
2632
+ const totalNodes = db.get<{ cnt: number }>(totalNodesSql, ...totalNodesParams);
2633
+
2634
+ const recentTasksParams: any[] = [];
2635
+ let recentTasksSql = "SELECT task_id, from_name, to_name, status, created_at FROM tasks WHERE 1=1";
2636
+ recentTasksSql = addNetworkScope(recentTasksSql, recentTasksParams, restScope);
2637
+ recentTasksSql += " ORDER BY created_at DESC LIMIT 5";
2638
+ const recentTasks = db.all<any>(recentTasksSql, ...recentTasksParams);
2639
+ return withCors(req, Response.json({
2640
+ ok: true,
2641
+ network_id: restScope.networkId || null,
2642
+ tasks: { total: totalTasks?.cnt || 0, by_status: taskStats },
2643
+ sessions: { by_status: sessionStats },
2644
+ nodes: { total: totalNodes?.cnt || 0 },
2645
+ recent_tasks: recentTasks,
2646
+ }));
2647
+ }
2648
+
2649
+ // ── REST: server log tail (in-memory ring buffer, last LOG_RING_CAP lines) ──
2650
+ // Admin-only because logs may include user names + task content.
2651
+ if (url.pathname === "/api/server-logs") {
2652
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
2653
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
2654
+ const resolved = resolveToken(token);
2655
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
2656
+ if (resolved.user.role !== "admin") return withCors(req, Response.json({ ok: false, error: "admin only" }, { status: 403 }));
2657
+ const limit = Math.min(Number(url.searchParams.get("limit")) || 200, LOG_RING_CAP);
2658
+ const since = url.searchParams.get("since"); // ISO timestamp; only return logs newer
2659
+ let entries = logRing.slice(-limit);
2660
+ if (since) entries = entries.filter(e => e.ts > since);
2661
+ // Newest first
2662
+ entries = entries.slice().reverse();
2663
+ return withCors(req, Response.json({ ok: true, logs: entries, capacity: LOG_RING_CAP }));
2664
+ }
2665
+
2666
+ // ── REST: audit log (V3) ──
2667
+ if (url.pathname === "/api/audit-log") {
2668
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
2669
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
2670
+ const resolved = resolveToken(token);
2671
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
2672
+ const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 200);
2673
+ const action = url.searchParams.get("action");
2674
+ const userId = url.searchParams.get("user_id");
2675
+ let sql = `SELECT ${AUDIT_LOG_REST_SELECT} FROM audit_log WHERE 1=1`;
2676
+ const params: any[] = [];
2677
+ // Non-admin can only see own logs
2678
+ if (resolved.user.role !== "admin") { sql += ` AND user_id = ?${params.length + 1}`; params.push(resolved.user.user_id); }
2679
+ if (action) { sql += ` AND action = ?${params.length + 1}`; params.push(action); }
2680
+ if (userId && resolved.user.role === "admin") { sql += ` AND user_id = ?${params.length + 1}`; params.push(userId); }
2681
+ sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
2682
+ params.push(limit);
2683
+ const logs = db.all(sql, ...params);
2684
+ return withCors(req, Response.json({ ok: true, logs, count: logs.length }));
2685
+ }
2686
+
2687
+ // ── REST: task events (V2 Sprint 2) ──
2688
+ if (url.pathname === "/api/task_events") {
2689
+ const taskId = url.searchParams.get("task_id");
2690
+ const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 500);
2691
+ let sql = `SELECT ${TASK_EVENT_REST_SELECT} FROM task_events WHERE 1=1`;
2692
+ const params: any[] = [];
2693
+ sql = addNetworkScope(sql, params, restScope);
2694
+ if (taskId) { sql += ` AND task_id = ?${params.length + 1}`; params.push(taskId); }
2695
+ sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
2696
+ params.push(limit);
2697
+ const rows = db.all(sql, ...params);
2698
+ return withCors(req, Response.json({ ok: true, events: rows, count: rows.length }));
2699
+ }
2700
+
2701
+ // ── REST: delete node (Dashboard/CLI remote cleanup) ──
2702
+ const nodeDeleteMatch = url.pathname.match(/^\/api\/nodes\/([^/]+)$/);
2703
+ if (nodeDeleteMatch && req.method === "DELETE") {
2704
+ const ref = decodeURIComponent(nodeDeleteMatch[1]);
2705
+ const params: any[] = [ref, ref, ref];
2706
+ let sql = "SELECT node_id, node_name, alias, network_id FROM nodes WHERE (node_id = ?1 OR node_name = ?2 OR alias = ?3)";
2707
+ sql = addNetworkScope(sql, params, restScope);
2708
+ sql += " ORDER BY updated_at DESC LIMIT 1";
2709
+ const node = db.get<any>(sql, ...params);
2710
+ if (!node) return withCors(req, Response.json({ ok: false, error: "node not found" }, { status: 404 }));
2711
+
2712
+ const nodeNetId = node.network_id ?? singleNetworkId(restScope);
2713
+ if (!canRestWriteNetwork(restAuth, nodeNetId, isAdmin)) {
2714
+ return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
2715
+ }
2716
+
2717
+ db.transaction(() => {
2718
+ db.run("DELETE FROM nodes WHERE node_id = ?1", [node.node_id]);
2719
+ if (node.alias) {
2720
+ db.run(
2721
+ "DELETE FROM sessions WHERE alias = ?1 AND (network_id = ?2 OR (?2 IS NULL AND network_id IS NULL))",
2722
+ [node.alias, node.network_id ?? null]
2723
+ );
2724
+ }
2725
+ });
2726
+
2727
+ if (node.alias) {
2728
+ pushEvent(node.alias, {
2729
+ type: "node_deleted",
2730
+ node_id: node.node_id,
2731
+ node_name: node.node_name,
2732
+ alias: node.alias,
2733
+ network_id: node.network_id ?? null,
2734
+ }, node.network_id ?? null);
2735
+ }
2736
+
2737
+ return withCors(req, Response.json({
2738
+ ok: true,
2739
+ deleted: true,
2740
+ node_id: node.node_id,
2741
+ node_name: node.node_name,
2742
+ alias: node.alias,
2743
+ network_id: node.network_id ?? null,
2744
+ }));
2745
+ }
2746
+
2747
+ // ── REST: single node config snapshot (RFC-024 B5) ──
2748
+ // Dashboard calls this for the snapshot path. Returns the masked
2749
+ // {model, flags, config_revision} the node last posted via
2750
+ // report_status (B6). NEVER reads per-node files. Network-scoped
2751
+ // via addNetworkScope so a netA viewer can't read a netB node.
2752
+ const nodeConfigMatch = url.pathname.match(/^\/api\/nodes\/([^/]+)\/config$/);
2753
+ if (nodeConfigMatch && req.method === "GET") {
2754
+ const nodeId = decodeURIComponent(nodeConfigMatch[1] ?? "");
2755
+ const params: any[] = [nodeId];
2756
+ let sql = "SELECT node_id, alias, network_id, config_revision, config_snapshot FROM nodes WHERE node_id = ?1";
2757
+ sql = addNetworkScope(sql, params, restScope);
2758
+ sql += " LIMIT 1";
2759
+ const node = db.get<any>(sql, ...params);
2760
+ if (!node) {
2761
+ return withCors(req, Response.json({ ok: false, error: "node_not_found", node_id: nodeId }, { status: 404 }));
2762
+ }
2763
+ let snapshot: any = {};
2764
+ if (node.config_snapshot) {
2765
+ try { snapshot = JSON.parse(node.config_snapshot); } catch { snapshot = {}; }
2766
+ }
2767
+ return withCors(req, Response.json({
2768
+ ok: true,
2769
+ node_id: node.node_id,
2770
+ alias: node.alias,
2771
+ network_id: node.network_id,
2772
+ config_revision: node.config_revision || 0,
2773
+ model: snapshot.model ?? null,
2774
+ flags: snapshot.flags ?? {},
2775
+ config_update_capable: snapshot.config_update_capable ?? false,
2776
+ }));
2777
+ }
2778
+
2779
+ // ── #462 REST: set / clear a node's custom avatar ──
2780
+ // PUT /api/nodes/:ref/avatar body: { "avatar_url": "https://…" | null }
2781
+ //
2782
+ // Deliberately NOT part of the RFC-024 config-apply pipeline: avatar
2783
+ // is a pure display attribute — nothing for agent-node to consume, no
2784
+ // config_revision / ack round-trip, no restart tier. Persisted on the
2785
+ // nodes row and served back via GET /api/nodes (cross-device).
2786
+ //
2787
+ // Auth: same write gate as node delete above — network-scoped lookup
2788
+ // + canRestWriteNetwork (member with role above viewer, or admin, or
2789
+ // legacy master token). Value passes validateAvatarUrl (http/https
2790
+ // only, ≤ 2048 chars, no control chars — XSS boundary, see
2791
+ // avatar-validate.ts).
2792
+ // ── REST: node DISPLAY attributes (display_name / team / tags) ──
2793
+ // Companion to the avatar route below: hub-side presentation fields that
2794
+ // agent-node does not consume, so they deliberately bypass the RFC-024
2795
+ // config-apply pipeline (no doorbell, no ack, no config_revision bump).
2796
+ //
2797
+ // Unlike avatar, these are multi-field and multi-editor, so they carry
2798
+ // their OWN optimistic lock (`attrs_revision`). A stale base revision is
2799
+ // a 409 with the current value, mirroring update_node_config's contract
2800
+ // so the dashboard can reuse its conflict-resolution UX.
2801
+ //
2802
+ // NOT editable here: `alias` (message-routing key) and `node_name`
2803
+ // (addressing key). Renaming those must go through the rename 2PC at
2804
+ // /api/node-rename/* which cascades sessions + api_tokens + SSE cleanup;
2805
+ // a single-table UPDATE would strand the node (see #146).
2806
+ const nodeAttrsMatch = url.pathname.match(/^\/api\/nodes\/([^/]+)\/attrs$/);
2807
+ if (nodeAttrsMatch && req.method === "PUT") {
2808
+ let attrsBody: any;
2809
+ try {
2810
+ attrsBody = await req.json();
2811
+ } catch {
2812
+ return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 }));
2813
+ }
2814
+ const ref = decodeURIComponent(nodeAttrsMatch[1]);
2815
+ const params: any[] = [ref, ref, ref];
2816
+ let sql = "SELECT node_id, alias, network_id, display_name, team, tags, attrs_revision FROM nodes WHERE (node_id = ?1 OR node_name = ?2 OR alias = ?3)";
2817
+ sql = addNetworkScope(sql, params, restScope);
2818
+ sql += " ORDER BY updated_at DESC LIMIT 1";
2819
+ const node = db.get<any>(sql, ...params);
2820
+ if (!node) return withCors(req, Response.json({ ok: false, error: "node not found" }, { status: 404 }));
2821
+
2822
+ const nodeNetId = node.network_id ?? singleNetworkId(restScope);
2823
+ if (!canRestWriteNetwork(restAuth, nodeNetId, isAdmin)) {
2824
+ return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
2825
+ }
2826
+
2827
+ // Optimistic lock BEFORE any narrowing work, so a losing writer never
2828
+ // has side effects.
2829
+ const currentRev = Number(node.attrs_revision ?? 0);
2830
+ const baseRev = attrsBody?.base_attrs_revision;
2831
+ if (typeof baseRev !== "number" || !Number.isInteger(baseRev) || baseRev < 0) {
2832
+ return withCors(req, Response.json({
2833
+ ok: false, error: "base_attrs_revision required (integer >= 0)",
2834
+ }, { status: 400 }));
2835
+ }
2836
+ if (baseRev !== currentRev) {
2837
+ return withCors(req, Response.json({
2838
+ ok: false,
2839
+ error: "attrs_revision_conflict",
2840
+ current_attrs_revision: currentRev,
2841
+ message: "another editor changed this node's attributes; re-read and retry",
2842
+ }, { status: 409 }));
2843
+ }
2844
+
2845
+ // Scalars reject on wrong type (never coerce); tags drop junk per item.
2846
+ const nextDisplay = "display_name" in attrsBody
2847
+ ? validateScalarAttr(attrsBody.display_name, "display_name")
2848
+ : null;
2849
+ if (nextDisplay && !nextDisplay.ok) {
2850
+ return withCors(req, Response.json({ ok: false, error: "invalid_display_name", reason: nextDisplay.reason }, { status: 400 }));
2851
+ }
2852
+ const nextTeam = "team" in attrsBody ? validateScalarAttr(attrsBody.team, "team") : null;
2853
+ if (nextTeam && !nextTeam.ok) {
2854
+ return withCors(req, Response.json({ ok: false, error: "invalid_team", reason: nextTeam.reason }, { status: 400 }));
2855
+ }
2856
+ const nextTags = "tags" in attrsBody ? narrowTags(attrsBody.tags) : null;
2857
+
2858
+ // Only the supplied fields move; the rest keep their stored value.
2859
+ const sets: string[] = [];
2860
+ const vals: any[] = [];
2861
+ if (nextDisplay) { sets.push(`display_name = ?${vals.length + 1}`); vals.push(nextDisplay.value); }
2862
+ if (nextTeam) { sets.push(`team = ?${vals.length + 1}`); vals.push(nextTeam.value); }
2863
+ if (nextTags) { sets.push(`tags = ?${vals.length + 1}`); vals.push(JSON.stringify(nextTags)); }
2864
+ // A no-op patch still bumps the revision: the caller observed a state
2865
+ // and asserted it, so honouring the CAS keeps client counters in step.
2866
+ sets.push(`attrs_revision = ?${vals.length + 1}`); vals.push(currentRev + 1);
2867
+ sets.push(`updated_at = datetime('now')`);
2868
+ vals.push(node.node_id);
2869
+ // Guard the UPDATE with the same revision so two concurrent writers
2870
+ // that both passed the read above cannot both land.
2871
+ vals.push(currentRev);
2872
+ const res = db.run(
2873
+ `UPDATE nodes SET ${sets.join(", ")} WHERE node_id = ?${vals.length - 1} AND attrs_revision = ?${vals.length}`,
2874
+ vals,
2875
+ );
2876
+ if (!res.changes) {
2877
+ const fresh = db.get<any>("SELECT attrs_revision FROM nodes WHERE node_id = ?1", node.node_id);
2878
+ return withCors(req, Response.json({
2879
+ ok: false,
2880
+ error: "attrs_revision_conflict",
2881
+ current_attrs_revision: Number(fresh?.attrs_revision ?? currentRev),
2882
+ message: "another editor changed this node's attributes; re-read and retry",
2883
+ }, { status: 409 }));
2884
+ }
2885
+ logAudit(restAuth?.userId ?? null, restAuth?.username ?? null, "node_attrs_updated", "node", node.node_id,
2886
+ JSON.stringify({ display_name: nextDisplay?.value ?? undefined, team: nextTeam?.value ?? undefined, tags: nextTags ?? undefined }),
2887
+ undefined, nodeNetId ?? undefined);
2888
+ const after = db.get<any>(
2889
+ "SELECT display_name, team, tags, attrs_revision FROM nodes WHERE node_id = ?1", node.node_id);
2890
+ return withCors(req, Response.json({
2891
+ ok: true,
2892
+ node_id: node.node_id,
2893
+ alias: node.alias,
2894
+ display_name: after?.display_name ?? null,
2895
+ team: after?.team ?? null,
2896
+ tags: parseStoredTags(after?.tags),
2897
+ attrs_revision: Number(after?.attrs_revision ?? currentRev + 1),
2898
+ }));
2899
+ }
2900
+
2901
+ const nodeAvatarMatch = url.pathname.match(/^\/api\/nodes\/([^/]+)\/avatar$/);
2902
+ if (nodeAvatarMatch && req.method === "PUT") {
2903
+ let avatarBody: any;
2904
+ try {
2905
+ avatarBody = await req.json();
2906
+ } catch {
2907
+ return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 }));
2908
+ }
2909
+ const validated = validateAvatarUrl(avatarBody?.avatar_url);
2910
+ if (!validated.ok) {
2911
+ return withCors(req, Response.json({ ok: false, error: "invalid_avatar_url", reason: validated.reason }, { status: 400 }));
2912
+ }
2913
+ const ref = decodeURIComponent(nodeAvatarMatch[1]);
2914
+ const params: any[] = [ref, ref, ref];
2915
+ let sql = "SELECT node_id, alias, network_id FROM nodes WHERE (node_id = ?1 OR node_name = ?2 OR alias = ?3)";
2916
+ sql = addNetworkScope(sql, params, restScope);
2917
+ sql += " ORDER BY updated_at DESC LIMIT 1";
2918
+ const node = db.get<any>(sql, ...params);
2919
+ if (!node) return withCors(req, Response.json({ ok: false, error: "node not found" }, { status: 404 }));
2920
+
2921
+ const nodeNetId = node.network_id ?? singleNetworkId(restScope);
2922
+ if (!canRestWriteNetwork(restAuth, nodeNetId, isAdmin)) {
2923
+ return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
2924
+ }
2925
+
2926
+ db.run(
2927
+ "UPDATE nodes SET avatar_url = ?1, updated_at = datetime('now') WHERE node_id = ?2",
2928
+ [validated.value, node.node_id]
2929
+ );
2930
+ return withCors(req, Response.json({ ok: true, node_id: node.node_id, alias: node.alias, avatar_url: validated.value }));
2931
+ }
2932
+
2933
+ // ── REST: nodes table (V2 Sprint 2) ──
2934
+ // Explicit column list — NOT `SELECT *`. Two reasons:
2935
+ // 1. `SELECT *` silently broadcasts any column added later to the
2936
+ // response (RFC-024 added config_revision/config_snapshot which
2937
+ // the dashboard's GET /api/nodes/:id/config endpoint owns;
2938
+ // RFC-028 will add more vault-flavored columns). Tests asserting
2939
+ // response shape break on every ALTER; integrations that key
2940
+ // off Object.keys() can pick up internals.
2941
+ // 2. The list endpoint and the per-node config snapshot endpoint
2942
+ // have different consumer contracts; this list should be the
2943
+ // stable, dashboard-facing fields only.
2944
+ // Add a new column to this list explicitly when a real client needs
2945
+ // it; do NOT switch back to SELECT *.
2946
+ // ── REST: list host_supervisor daemons (RFC-026 §9.2.2 / #338 PR2) ──
2947
+ // Mirror of the `list_host_supervisors` MCP tool for non-MCP callers.
2948
+ // Same SQL + role-extract + member-脱敏 logic; SEC-1 scoped via REST
2949
+ // auth pipeline (restScope). Returns {ok, daemons:[...], count}.
2950
+ if (url.pathname === "/api/host-supervisors") {
2951
+ // #380 — utok callers with exactly one accessible network get a
2952
+ // safe fallback so the create-node wizard doesn't have to bake
2953
+ // network_id into every request (it was hard-4xxing when the
2954
+ // dashboard omitted the query param — that's what "hub 400" was).
2955
+ // `singleNetworkId` returns:
2956
+ // - scope.networkId when it's set (ntok binding, or an
2957
+ // explicitly-requested network the user has verified access to)
2958
+ // - the sole member of scope.networkIds when the utok user
2959
+ // belongs to exactly one network (safe unambiguous fallback)
2960
+ // - null when the user belongs to 0 or 2+ networks (authz
2961
+ // boundary — refuse to guess which one, mirrors 通信龙 spec
2962
+ // "别 fallback 到错 network")
2963
+ // The 400 branch is retained for the ambiguous / no-access cases
2964
+ // and distinguishes them in the error message so the client can
2965
+ // recover (send network_id explicitly).
2966
+ const effectiveNetId = singleNetworkId(restScope);
2967
+ if (!effectiveNetId) {
2968
+ const memberships = restScope.networkIds?.length ?? 0;
2969
+ const error = memberships > 1
2970
+ ? "network_id_required_multi"
2971
+ : "missing_network_id";
2972
+ return withCors(req, Response.json({ ok: false, error, memberships }, { status: 400 }));
2973
+ }
2974
+ // Role for member-脱敏 (admin/owner = full host_telemetry, others = masked)
2975
+ let isPrivileged = false;
2976
+ if (restAuth?.userId) {
2977
+ const role = getUserNetworkRole(restAuth.userId, effectiveNetId);
2978
+ isPrivileged = role === "admin" || role === "owner";
2979
+ } else {
2980
+ // Network-token caller (no user) — treat as privileged within its network
2981
+ isPrivileged = true;
2982
+ }
2983
+
2984
+ // EXISTS subquery handles BOTH revoked_at SET and DELETEd token
2985
+ // rows (revokeToken DELETEs the row in production — PR2 v1
2986
+ // SHOULD-FIX per 通信龙 audit). EXISTS naturally dedupes daemons
2987
+ // with rotated tokens.
2988
+ const sqlRows = db.all<Record<string, any>>(`
2989
+ SELECT
2990
+ n.node_id, n.alias, n.hostname, n.network_id,
2991
+ n.runtimes_supported, n.allowed_secret_keys,
2992
+ n.created_at, n.updated_at,
2993
+ s.last_seen_at AS session_last_seen,
2994
+ s.cpu_cores AS session_cpu_cores,
2995
+ s.mem_total_gb AS session_mem_total_gb,
2996
+ s.ip AS session_ip,
2997
+ n.config_snapshot
2998
+ FROM nodes n
2999
+ LEFT JOIN sessions s ON s.alias = n.alias AND (s.network_id = n.network_id OR s.network_id IS NULL)
3000
+ WHERE n.network_id = ?1
3001
+ AND EXISTS (
3002
+ SELECT 1 FROM api_tokens t
3003
+ WHERE t.network_id = n.network_id
3004
+ AND t.name = 'node:' || n.alias
3005
+ AND t.revoked_at IS NULL
3006
+ )
3007
+ ORDER BY n.updated_at DESC
3008
+ `, effectiveNetId);
3009
+
3010
+ const nowMs = Date.now();
3011
+ const ONLINE_MS = 60_000;
3012
+ const daemons = sqlRows
3013
+ .map(r => {
3014
+ let snapRole: string | null = null;
3015
+ if (r.config_snapshot) {
3016
+ try {
3017
+ const parsed = typeof r.config_snapshot === "string" ? JSON.parse(r.config_snapshot) : r.config_snapshot;
3018
+ snapRole = typeof parsed?.role === "string" ? parsed.role : null;
3019
+ } catch { /* malformed */ }
3020
+ }
3021
+ return { row: r, role: snapRole };
3022
+ })
3023
+ .filter(({ role }) => role === "host_supervisor")
3024
+ .map(({ row: r }) => {
3025
+ let online = false;
3026
+ let lastSeenAt: string | null = null;
3027
+ if (r.session_last_seen) {
3028
+ lastSeenAt = r.session_last_seen;
3029
+ const t = Date.parse(r.session_last_seen);
3030
+ if (!isNaN(t)) online = (nowMs - t) <= ONLINE_MS;
3031
+ }
3032
+ let runtimes: string[] = [];
3033
+ let secrets: string[] = [];
3034
+ try {
3035
+ if (r.runtimes_supported) {
3036
+ const parsed = JSON.parse(r.runtimes_supported);
3037
+ runtimes = Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string") : [];
3038
+ }
3039
+ } catch {}
3040
+ try {
3041
+ if (r.allowed_secret_keys) {
3042
+ const parsed = JSON.parse(r.allowed_secret_keys);
3043
+ secrets = Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string") : [];
3044
+ }
3045
+ } catch {}
3046
+ const telemetry: Record<string, unknown> = {
3047
+ alert_level: online ? "green" : "gray",
3048
+ };
3049
+ if (isPrivileged) {
3050
+ telemetry.cpu_cores = r.session_cpu_cores ?? null;
3051
+ telemetry.mem_gb = r.session_mem_total_gb ?? null;
3052
+ telemetry.ip_internal = r.session_ip ?? null;
3053
+ }
3054
+ return {
3055
+ daemon_node_id: r.node_id,
3056
+ alias: r.alias,
3057
+ hostname: r.hostname,
3058
+ online,
3059
+ last_seen_at: lastSeenAt,
3060
+ runtimes_supported: runtimes,
3061
+ allowed_secret_keys: secrets,
3062
+ host_telemetry: telemetry,
3063
+ };
3064
+ });
3065
+ return withCors(req, Response.json({ ok: true, daemons, count: daemons.length }));
3066
+ }
3067
+
3068
+ if (url.pathname === "/api/nodes") {
3069
+ const nodeId = url.searchParams.get("node_id");
3070
+ const alias = url.searchParams.get("alias");
3071
+ // `config_snapshot` SELECTed ONLY to extract the daemon `role` field
3072
+ // for dashboard discovery — full snapshot is NOT broadcast (would
3073
+ // re-introduce the #312 SELECT * fragility). Mapping step pulls
3074
+ // `role` out then drops `config_snapshot` from the response row.
3075
+ // RFC-027 PR2 dashboard prereq: lifecycle_state is needed
3076
+ // client-side to drive the ⋮ menu (active → "停止/删除";
3077
+ // stopped → "重启/删除"). Hub schema gained the column at PR1
3078
+ // (#345) but /api/nodes never SELECTed it. Following #312
3079
+ // "explicit SELECT" discipline — extend the projection here,
3080
+ // do NOT switch to SELECT *.
3081
+ // #462: avatar_url added to the projection so custom avatars are
3082
+ // cross-device (dashboard hydrates from here; localStorage was the
3083
+ // only store before). Nullable — null means "use default set".
3084
+ let sql = `SELECT node_id, node_name, alias, runtime, model,
3085
+ config_path, channels, server, hostname,
3086
+ network_id, created_at, updated_at,
3087
+ config_snapshot, lifecycle_state, avatar_url,
3088
+ display_name, team, tags, attrs_revision
3089
+ FROM nodes WHERE 1=1`;
3090
+ const params: any[] = [];
3091
+ sql = addNetworkScope(sql, params, restScope);
3092
+ if (nodeId) { sql += ` AND node_id = ?${params.length + 1}`; params.push(nodeId); }
3093
+ if (alias) { sql += ` AND alias = ?${params.length + 1}`; params.push(alias); }
3094
+ sql += " ORDER BY updated_at DESC";
3095
+ const rawRows = db.all<Record<string, any>>(sql, ...params);
3096
+ // Add a single new field `role: string | null` per row, extracted from
3097
+ // config_snapshot JSON. Daemon nodes that posted role='host_supervisor'
3098
+ // via report_status surface here; dashboard uses this for daemon
3099
+ // discovery (replacing the prior `node_daemon_` prefix heuristic).
3100
+ // RFC-026 P1 P2 §9 will supersede this REST proxy with the
3101
+ // `list_host_supervisors` MCP tool.
3102
+ const rows = rawRows.map(r => {
3103
+ let role: string | null = null;
3104
+ const snap = r.config_snapshot;
3105
+ if (snap) {
3106
+ try { role = (typeof snap === "string" ? JSON.parse(snap) : snap)?.role ?? null; }
3107
+ catch { /* malformed snapshot — leave role null */ }
3108
+ }
3109
+ const { config_snapshot, ...rest } = r;
3110
+ // `tags` is stored as JSON text; hand the dashboard a real array so
3111
+ // it can map() without a guard, and normalise legacy/absent values
3112
+ // to [] rather than null. attrs_revision is normalised to a number
3113
+ // so pre-migration rows (NULL) read as 0.
3114
+ return {
3115
+ ...rest,
3116
+ tags: parseStoredTags(r.tags),
3117
+ attrs_revision: Number(r.attrs_revision ?? 0),
3118
+ role,
3119
+ };
3120
+ });
3121
+ return withCors(req, Response.json({ ok: true, nodes: rows, count: rows.length }));
3122
+ }
3123
+
3124
+ // ── REST: single task lookup (V2) ──
3125
+ const taskPathMatch = url.pathname.match(/^\/api\/tasks?\/([^/]+)$/);
3126
+ if (taskPathMatch && req.method === "GET") {
3127
+ const taskId = decodeURIComponent(taskPathMatch[1] ?? "");
3128
+ const params: any[] = [taskId];
3129
+ let sql = `SELECT ${TASK_REST_SELECT} FROM tasks WHERE task_id = ?1`;
3130
+ sql = addNetworkScope(sql, params, restScope);
3131
+ sql += " LIMIT 1";
3132
+ const task = db.get(sql, ...params);
3133
+ if (!task) {
3134
+ return withCors(req, Response.json({ ok: false, error: "task_not_found", task_id: taskId }, { status: 404 }));
3135
+ }
3136
+ const taskNetworkId = typeof task.network_id === "string" ? task.network_id : null;
3137
+ const canonicalTarget = task.to_node_id
3138
+ ? null
3139
+ : resolveCanonicalAlias(taskNetworkId, String(task.to_name ?? "")).alias;
3140
+ const targetParams: unknown[] = [];
3141
+ let targetSql = "SELECT alias, status FROM sessions WHERE ";
3142
+ if (task.to_node_id) {
3143
+ targetSql += "node_id = ?1";
3144
+ targetParams.push(task.to_node_id);
3145
+ } else {
3146
+ targetSql += "alias = ?1";
3147
+ targetParams.push(canonicalTarget);
3148
+ }
3149
+ if (taskNetworkId) {
3150
+ targetSql += " AND network_id = ?2";
3151
+ targetParams.push(taskNetworkId);
3152
+ } else {
3153
+ targetSql += " AND network_id IS NULL";
3154
+ }
3155
+ targetSql += " ORDER BY updated_at DESC LIMIT 1";
3156
+ const targetSession = db.get<{ alias: string; status: string }>(targetSql, ...targetParams);
3157
+ const targetAlias = targetSession?.alias ?? canonicalTarget ?? String(task.to_name ?? "");
3158
+ const liveSseConnections = getSSEStats().sessions[`${taskNetworkId || "global"}:${targetAlias}`] ?? 0;
3159
+ // 🔴 #1083:这个目标【历史上有没有报过任何一次】运行时证据。
3160
+ // 只用 Hub 自己已有的数据算,不需要 agent 侧任何改动。
3161
+ // 为空时 runtime_submitted_at/consumed_at 是「不会走到」而不是「还没走到」,
3162
+ // 两者处置相反(别等去接线 vs 等)。参数编号沿用上面 targetSql 的写法。
3163
+ const evidenceParams: unknown[] = [];
3164
+ let evidenceSql = "SELECT 1 AS seen FROM tasks WHERE runtime_submitted_at IS NOT NULL AND ";
3165
+ if (task.to_node_id) {
3166
+ evidenceSql += "to_node_id = ?1";
3167
+ evidenceParams.push(task.to_node_id);
3168
+ } else {
3169
+ evidenceSql += "to_node_id IS NULL AND to_name = ?1";
3170
+ evidenceParams.push(canonicalTarget ?? String(task.to_name ?? ""));
3171
+ }
3172
+ if (taskNetworkId) {
3173
+ evidenceSql += " AND network_id = ?2";
3174
+ evidenceParams.push(taskNetworkId);
3175
+ } else {
3176
+ evidenceSql += " AND network_id IS NULL";
3177
+ }
3178
+ evidenceSql += " LIMIT 1";
3179
+ const targetEverReportedRuntimeEvidence =
3180
+ Boolean(db.get<{ seen: number }>(evidenceSql, ...evidenceParams));
3181
+ const diagnostic = diagnoseTask({
3182
+ status: String(task.status ?? "unknown"),
3183
+ runtimeSubmittedAt: typeof task.runtime_submitted_at === "string" ? task.runtime_submitted_at : null,
3184
+ consumedAt: typeof task.consumed_at === "string" ? task.consumed_at : null,
3185
+ targetSessionStatus: targetSession?.status ?? null,
3186
+ targetSessionExists: Boolean(targetSession),
3187
+ liveSseConnections,
3188
+ targetEverReportedRuntimeEvidence,
3189
+ });
3190
+ return withCors(req, Response.json({ ok: true, task, diagnostic }));
3191
+ }
3192
+
3193
+ // ── REST: tasks table (V2) ──
3194
+ if (url.pathname === "/api/tasks") {
3195
+ const taskId = url.searchParams.get("task_id");
3196
+ const status = url.searchParams.get("status");
3197
+ const toName = url.searchParams.get("to_name");
3198
+ const fromName = url.searchParams.get("from_name");
3199
+ const rawBefore = url.searchParams.get("before");
3200
+ const before = rawBefore === null ? null : normalizeTaskBeforeCursor(rawBefore);
3201
+ if (rawBefore !== null && before === null) {
3202
+ return withCors(req, Response.json({ ok: false, error: "invalid_before" }, { status: 400 }));
3203
+ }
3204
+ const rawBeforeTaskId = url.searchParams.get("before_task_id");
3205
+ const beforeTaskId = rawBeforeTaskId === null ? null : normalizeTaskBeforeTaskId(rawBeforeTaskId);
3206
+ if (rawBeforeTaskId !== null && (before === null || beforeTaskId === null)) {
3207
+ return withCors(req, Response.json({ ok: false, error: "invalid_before_task_id" }, { status: 400 }));
3208
+ }
3209
+ const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 200);
3210
+ // #248 — opt-out of the stats GROUP-BY scan. The dashboard chat panel
3211
+ // never consumes `stats`; it's a global per-status table scan that
3212
+ // dominates the request time on a large DB (270 MB / many tasks).
3213
+ // Existing callers that don't pass `skip_stats=1` get the same
3214
+ // {ok, tasks, count, stats} shape — backwards-compatible.
3215
+ const skipStats = url.searchParams.get("skip_stats") === "1";
3216
+
3217
+ let sql = `SELECT ${TASK_REST_SELECT} FROM tasks WHERE 1=1`;
3218
+ const params: any[] = [];
3219
+ sql = addNetworkScope(sql, params, restScope);
3220
+ if (taskId) { sql += ` AND task_id = ?${params.length + 1}`; params.push(taskId); }
3221
+ if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
3222
+ if (toName) { sql += ` AND to_name = ?${params.length + 1}`; params.push(toName); }
3223
+ if (fromName) { sql += ` AND from_name = ?${params.length + 1}`; params.push(fromName); }
3224
+ if (before && beforeTaskId) {
3225
+ const createdAtParam = params.length + 1;
3226
+ const taskIdParam = params.length + 2;
3227
+ sql += ` AND (created_at < ?${createdAtParam} OR (created_at = ?${createdAtParam} AND task_id < ?${taskIdParam}))`;
3228
+ params.push(before, beforeTaskId);
3229
+ } else if (before) {
3230
+ sql += ` AND created_at < ?${params.length + 1}`;
3231
+ params.push(before);
3232
+ }
3233
+ sql += ` ORDER BY created_at DESC, task_id DESC LIMIT ?${params.length + 1}`;
3234
+ params.push(limit);
3235
+
3236
+ const rows = db.all(sql, ...params);
3237
+ if (skipStats) {
3238
+ return withCors(req, Response.json({ ok: true, tasks: rows, count: rows.length }));
3239
+ }
3240
+ const statsParams: any[] = [];
3241
+ let statsSql = "SELECT status, COUNT(*) as count FROM tasks WHERE 1=1";
3242
+ statsSql = addNetworkScope(statsSql, statsParams, restScope);
3243
+ statsSql += " GROUP BY status";
3244
+ const stats = db.all<any>(statsSql, ...statsParams);
3245
+ return withCors(req, Response.json({ ok: true, tasks: rows, count: rows.length, stats }));
3246
+ }
3247
+
3248
+ // ── REST: recent completions ──
3249
+ if (url.pathname === "/api/completions") {
3250
+ const since = url.searchParams.get("since") ?? new Date(Date.now() - 86400000).toISOString();
3251
+ const params: any[] = [since];
3252
+ let sql = `SELECT ${COMPLETION_REST_SELECT} FROM completions WHERE completed_at >= ?1`;
3253
+ sql = addNetworkScope(sql, params, restScope);
3254
+ sql += " ORDER BY completed_at DESC LIMIT 100";
3255
+ const rows = db.all(sql, ...params);
3256
+ return withCors(req, Response.json({ ok: true, completions: rows }));
3257
+ }
3258
+
3259
+ return withCors(req, new Response(
3260
+ `CommHub MCP Server v${SERVER_VERSION} (Streamable HTTP + SSE Push)
3261
+
3262
+ Endpoints:
3263
+ POST /mcp - MCP Streamable HTTP (for Claude Code / Codex)
3264
+ GET /events/:session - SSE realtime push (Agent subscribes here)
3265
+ GET /health - Health check
3266
+ GET /api/status - All sessions (auth required)
3267
+ POST /api/task - Send task via REST (auth required)
3268
+ GET /api/tasks - Tasks table (V2) (auth required)
3269
+ GET /api/completions - Recent completions (auth required)
3270
+ GET /api/tmux/:name - Capture tmux pane output (${TMUX_ENABLED ? "admin + localhost/allowlist required" : "disabled"})
3271
+ POST /api/tmux/:name/send - Send keys to tmux (${TMUX_ENABLED ? "admin + localhost/allowlist required" : "disabled"})
3272
+
3273
+ Security: ${SECURITY_LABEL}
3274
+ `,
3275
+ { status: 200, headers: { "Content-Type": "text/plain; charset=utf-8" } } // #426
3276
+ ));
3277
+ },
3278
+
3279
+ // ── WebSocket handler for tmux terminal streaming ──
3280
+ websocket: {
3281
+ open(ws) {
3282
+ const { tmuxName } = ws.data as unknown as { tmuxName: string };
3283
+ console.log(`[ws] tmux terminal opened: ${tmuxName}`);
3284
+ let lastOutput = "";
3285
+
3286
+ // Poll capture-pane every 200ms and send diffs
3287
+ const interval = setInterval(async () => {
3288
+ try {
3289
+ const proc = Bun.spawn(["tmux", "capture-pane", "-t", tmuxName, "-p", "-e"], {
3290
+ stdout: "pipe", stderr: "pipe",
3291
+ });
3292
+ const output = await new Response(proc.stdout).text();
3293
+ const code = await proc.exited;
3294
+ if (code !== 0) return;
3295
+
3296
+ if (output !== lastOutput) {
3297
+ lastOutput = output;
3298
+ ws.send(JSON.stringify({ type: "output", data: output }));
3299
+ }
3300
+ } catch { /* session gone */ }
3301
+ }, 200);
3302
+
3303
+ wsTmuxIntervals.set(ws, interval);
3304
+
3305
+ // Send initial capture immediately
3306
+ (async () => {
3307
+ try {
3308
+ const proc = Bun.spawn(["tmux", "capture-pane", "-t", tmuxName, "-p", "-e"], {
3309
+ stdout: "pipe", stderr: "pipe",
3310
+ });
3311
+ const output = await new Response(proc.stdout).text();
3312
+ const code = await proc.exited;
3313
+ if (code === 0) {
3314
+ lastOutput = output;
3315
+ ws.send(JSON.stringify({ type: "output", data: output }));
3316
+ } else {
3317
+ const err = await new Response(proc.stderr).text();
3318
+ ws.send(JSON.stringify({ type: "error", data: err.trim() || "tmux session not found" }));
3319
+ }
3320
+ } catch (e) {
3321
+ ws.send(JSON.stringify({ type: "error", data: (e as Error).message }));
3322
+ }
3323
+ })();
3324
+ },
3325
+
3326
+ async message(ws, message) {
3327
+ const { tmuxName } = ws.data as unknown as { tmuxName: string };
3328
+ try {
3329
+ const msg = JSON.parse(typeof message === "string" ? message : new TextDecoder().decode(message));
3330
+
3331
+ if (msg.type === "input" && typeof msg.data === "string") {
3332
+ // Send individual characters/sequences via send-keys
3333
+ const proc = Bun.spawn(["tmux", "send-keys", "-t", tmuxName, "-l", msg.data], {
3334
+ stdout: "pipe", stderr: "pipe",
3335
+ });
3336
+ await proc.exited;
3337
+ } else if (msg.type === "key" && typeof msg.data === "string") {
3338
+ // Send special key names (Enter, C-c, etc.)
3339
+ const proc = Bun.spawn(["tmux", "send-keys", "-t", tmuxName, msg.data], {
3340
+ stdout: "pipe", stderr: "pipe",
3341
+ });
3342
+ await proc.exited;
3343
+ } else if (msg.type === "resize" && msg.cols && msg.rows) {
3344
+ // Resize tmux pane
3345
+ Bun.spawn(["tmux", "resize-window", "-t", tmuxName, "-x", String(msg.cols), "-y", String(msg.rows)], {
3346
+ stdout: "pipe", stderr: "pipe",
3347
+ });
3348
+ }
3349
+ } catch { /* ignore malformed messages */ }
3350
+ },
3351
+
3352
+ close(ws) {
3353
+ const { tmuxName } = ws.data as unknown as { tmuxName: string };
3354
+ console.log(`[ws] tmux terminal closed: ${tmuxName}`);
3355
+ const interval = wsTmuxIntervals.get(ws);
3356
+ if (interval) { clearInterval(interval); wsTmuxIntervals.delete(ws); }
3357
+ },
3358
+ },
3359
+ });
3360
+ } // end bootServer
3361
+
3362
+ // ── Explicit startup (#438 corrective / 通信龙派单) ──────────────────
3363
+ //
3364
+ // Importing this module NO LONGER binds a port. The two failure modes
3365
+ // this design dodges, both field-verified:
3366
+ //
3367
+ // 1. `if (import.meta.main)` guard — the published package's process
3368
+ // entry is bin/commhub.ts which DYNAMICALLY imports this module, so
3369
+ // import.meta.main is always false there: the process comes up but
3370
+ // listens on nothing (#438 review finding; would have shipped dead).
3371
+ // 2. Module-level `export const server = bootServer()` side effect —
3372
+ // fixes (1) but makes EVERY import bind a port: tooling scripts,
3373
+ // typechecks, and unrelated tests all grab the port by accident.
3374
+ //
3375
+ // The contract instead: bin/commhub.ts (and anything else that wants a
3376
+ // running hub) calls `startHub()` explicitly. Tests that need a private
3377
+ // throwaway instance keep using `bootServer({ port: 0 })`, which stays
3378
+ // side-effect-free (no sweepers / signal handlers / banner).
3379
+ //
3380
+ // startHub is SINGLE-SHOT and observable: a second call throws with the
3381
+ // first boot's coordinates rather than silently no-op'ing — "I thought I
3382
+ // started it but nothing happened" is the same failure family as (1)
3383
+ // (承 通信测试马 verification design requirement).
3384
+
3385
+ let startedHub: { server: ReturnType<typeof Bun.serve>; startedAt: string } | null = null;
3386
+
3387
+ /** The hub instance startHub() booted, or null before startHub() runs.
3388
+ * Read-only introspection for diagnostics; do not boot through this. */
3389
+ export function getStartedHub(): { server: ReturnType<typeof Bun.serve>; startedAt: string } | null {
3390
+ return startedHub;
3391
+ }
3392
+
3393
+ export function startHub(opts?: { port?: number; hostname?: string }): ReturnType<typeof Bun.serve> {
3394
+ if (startedHub) {
3395
+ throw new Error(
3396
+ `startHub() already ran at ${startedHub.startedAt} (listening on ` +
3397
+ `${startedHub.server.hostname}:${startedHub.server.port}) — it is single-shot ` +
3398
+ `by design (sweepers + signal handlers must register exactly once). ` +
3399
+ `For an additional throwaway instance use bootServer({ port: 0 }).`
3400
+ );
3401
+ }
3402
+ // The current PostgreSQL adapter opens a fresh process/connection for each
3403
+ // statement, so its transaction() cannot provide the occurrence claim +
3404
+ // task creation atomicity this scheduler requires. Refuse before opening a
3405
+ // listening socket instead of silently running an unsafe scheduler.
3406
+ assertScheduledTaskBackendSupported();
3407
+ const server = bootServer(opts);
3408
+
3409
+ // Round-2/4 review ② — periodic retention sweep + incremental VACUUM.
3410
+ // Sweeps every hour by default. Operators can disable any single table
3411
+ // by setting COMMHUB_RETENTION_*_DAYS to a negative value, or shorten
3412
+ // the sweep window via COMMHUB_RETENTION_SWEEP_MINUTES.
3413
+ const sweepIntervalMinutes = Number(process.env.COMMHUB_RETENTION_SWEEP_MINUTES);
3414
+ const sweepIntervalMs = Number.isFinite(sweepIntervalMinutes) && sweepIntervalMinutes > 0
3415
+ ? sweepIntervalMinutes * 60 * 1000
3416
+ : 60 * 60 * 1000;
3417
+ const retentionSweeperTimer = startRetentionSweeper(sweepIntervalMs);
3418
+
3419
+ // Round-2/4 review ③ — stale session sweeper (coexists with the
3420
+ // retention sweeper above; different concerns + different cadences).
3421
+ // Replaces the per-request UPDATE in GET /api/status (+ /api/servers,
3422
+ // /api/server-detail/*, MCP get_all_status). Each of those endpoints
3423
+ // used to run UPDATE on the sessions table just to maintain the
3424
+ // derived `offline` status; with the dashboard polling fast that was
3425
+ // 99% no-op write-amp under hot read paths. Now done globally once
3426
+ // every COMMHUB_STALE_SWEEP_SECONDS (default 60s).
3427
+ const staleSweeperTimer = startStaleSessionSweeper();
3428
+
3429
+ // #476 — the two legacy module-level intervals, now owned by startHub
3430
+ // like every other periodic job: rate-limit map cleanup (memory only)
3431
+ // and task expiration patrol (writes DB — must never run on import).
3432
+ // Periods env-overridable so tests can observe a real firing without
3433
+ // waiting minutes (mirrors COMMHUB_RETENTION_SWEEP_MINUTES above).
3434
+ const rateLimitSweepMs = Number(process.env.COMMHUB_RATELIMIT_SWEEP_MS) > 0
3435
+ ? Number(process.env.COMMHUB_RATELIMIT_SWEEP_MS) : 300000;
3436
+ const taskPatrolMs = Number(process.env.COMMHUB_TASK_PATROL_MS) > 0
3437
+ ? Number(process.env.COMMHUB_TASK_PATROL_MS) : 5 * 60 * 1000;
3438
+ const deliveredStalePatrolMs = Number(process.env.COMMHUB_DELIVERED_STALE_PATROL_MS) > 0
3439
+ ? Number(process.env.COMMHUB_DELIVERED_STALE_PATROL_MS) : 5 * 1000;
3440
+ const rateLimitSweepTimer = setInterval(sweepStaleRateLimits, rateLimitSweepMs);
3441
+ const taskPatrolTimer = setInterval(patrolExpiredTasks, taskPatrolMs);
3442
+ patrolDeliveredStaleTasks();
3443
+ const deliveredStalePatrolTimer = setInterval(patrolDeliveredStaleTasks, deliveredStalePatrolMs);
3444
+ const scheduledTaskTimer = startScheduledTaskScheduler();
3445
+
3446
+ // The Bun.serve socket is what keeps the process alive; the periodic
3447
+ // jobs must not — otherwise a test (or a future caller) that stops the
3448
+ // server would still hang on live intervals it cannot reach.
3449
+ (retentionSweeperTimer as any)?.unref?.();
3450
+ (staleSweeperTimer as any)?.unref?.();
3451
+ (rateLimitSweepTimer as any)?.unref?.();
3452
+ (taskPatrolTimer as any)?.unref?.();
3453
+ (deliveredStalePatrolTimer as any)?.unref?.();
3454
+ (scheduledTaskTimer as any)?.unref?.();
3455
+
3456
+ // ── Graceful shutdown ───────────────────────────────
3457
+ function shutdown() {
3458
+ console.log("[commhub] shutting down...");
3459
+ clearInterval(retentionSweeperTimer);
3460
+ clearInterval(staleSweeperTimer);
3461
+ clearInterval(rateLimitSweepTimer);
3462
+ clearInterval(taskPatrolTimer);
3463
+ clearInterval(deliveredStalePatrolTimer);
3464
+ clearInterval(scheduledTaskTimer);
3465
+ db.close();
3466
+ process.exit(0);
3467
+ }
3468
+ process.on("SIGTERM", shutdown);
3469
+ process.on("SIGINT", shutdown);
3470
+
3471
+ // Banner reports the ACTUAL bound address (matters for port 0).
3472
+ const bHost = server.hostname ?? HOST;
3473
+ const bPort = server.port ?? PORT;
3474
+ console.log(`
3475
+ ╔══════════════════════════════════════════════════╗
3476
+ ║ CommHub MCP Server v${SERVER_VERSION} ║
3477
+ ║ Transport: Streamable HTTP (Bun native) ║
3478
+ ║ Security: ${SECURITY_LABEL}${" ".repeat(Math.max(0, 33 - SECURITY_LABEL.length))}║
3479
+ ║ Tmux: ${TMUX_ENABLED ? "ENABLED (admin + localhost/allowlist)" : "DISABLED (set COMMHUB_ENABLE_TMUX=1)"}${" ".repeat(Math.max(0, TMUX_ENABLED ? 0 : 2))}║
3480
+ ║ ║
3481
+ ║ MCP: http://${bHost}:${bPort}/mcp ║
3482
+ ║ REST: http://${bHost}:${bPort}/api ║
3483
+ ║ Health: http://${bHost}:${bPort}/health ║
3484
+ ╚══════════════════════════════════════════════════╝
3485
+ `);
3486
+
3487
+ // RFC-028 P1 boot banner — vault key configuration status.
3488
+ // F2 invariant: hub MUST boot regardless of vault state. Banner-only
3489
+ // (informational), fire-and-forget so startHub stays synchronous;
3490
+ // errors are raised lazily at vault op time. needsKeyToOp=true means
3491
+ // the operator should set ANET_HUB_SECRET_VAULT_KEY before
3492
+ // vault/provider features will work.
3493
+ import("./vault.js").then(({ vaultStatusForBoot }) => {
3494
+ const s = vaultStatusForBoot();
3495
+ if (s.needsKeyToOp) {
3496
+ 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).");
3497
+ } else if (s.configured) {
3498
+ console.log(`[rfc-028 vault] master key configured (tables_have_data=${s.tablesHaveData})`);
3499
+ } else {
3500
+ 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.");
3501
+ }
3502
+ }).catch((e: any) => {
3503
+ console.warn(`[rfc-028 vault] boot banner skipped (${e?.message || e})`);
3504
+ });
3505
+
3506
+ startedHub = { server, startedAt: new Date().toISOString() };
3507
+ return server;
3508
+ }