@sleep2agi/commhub-server 0.9.0-preview.8 → 0.9.0-preview.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -10
- package/bin/commhub.ts +92 -11
- package/package.json +5 -5
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +5 -5
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/agent-access-batch.test.ts +210 -0
- package/src/agent-access.ts +478 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +520 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +190 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +450 -31
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/commhub-bin-args.test.ts +85 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +89 -1
- package/src/create-node-validate.ts +89 -7
- package/src/create-node-workdir.test.ts +255 -0
- package/src/create-node.ts +46 -1
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +148 -0
- package/src/db-adapter-translate.test.ts +128 -0
- package/src/db-adapter.ts +323 -87
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +805 -33
- package/src/delivered-without-push.test.ts +140 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/dm-file-scope-http.test.ts +178 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +321 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +404 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +732 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +94 -0
- package/src/http-gzip.ts +110 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +215 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2331
- package/src/lifecycle-guard.ts +50 -0
- package/src/mcp-route-label-http.test.ts +120 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +206 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/migrate-sqlite-to-pg.test.ts +47 -0
- package/src/migrate-sqlite-to-pg.ts +263 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +224 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-create-request-status-http.test.ts +92 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-id-alias.test.ts +188 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-privilege-http.test.ts +149 -0
- package/src/observer-avatar-http.test.ts +429 -0
- package/src/observer-push.test.ts +252 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/pg-worker.ts +87 -0
- package/src/push.ts +284 -7
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +7 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-host-schema.test.ts +211 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-events-http.test.ts +257 -0
- package/src/requirement-events.test.ts +45 -0
- package/src/requirement-events.ts +143 -0
- package/src/requirement-notify.ts +134 -0
- package/src/requirement-participant-notify-http.test.ts +309 -0
- package/src/requirement-tag-ops-http.test.ts +266 -0
- package/src/requirement-tags.ts +89 -0
- package/src/requirements-completed-at-migration.test.ts +70 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +709 -0
- package/src/requirements-list-slim-http.test.ts +315 -0
- package/src/requirements-mcp.test.ts +163 -0
- package/src/requirements-migrate.ts +225 -0
- package/src/requirements-people-http.test.ts +114 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-search-http.test.ts +182 -0
- package/src/requirements-search.test.ts +32 -0
- package/src/requirements-search.ts +138 -0
- package/src/requirements-seq-http.test.ts +193 -0
- package/src/requirements-seq-migration.test.ts +63 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-stats-http.test.ts +288 -0
- package/src/requirements-stats.ts +164 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +98 -0
- package/src/requirements.ts +1439 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +216 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +73 -0
- package/src/retention.ts +15 -3
- package/src/route-callers-http.test.ts +120 -0
- package/src/route-timing.test.ts +211 -0
- package/src/route-timing.ts +258 -0
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
- package/src/scheduled-reply-unread-http.test.ts +212 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-tasks-http.test.ts +638 -0
- package/src/scheduled-tasks.ts +597 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +458 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +4522 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/side-thread-command-transport.test.ts +312 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +233 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2499 -0
- package/src/skillhub-http.test.ts +108 -0
- package/src/sqlite-close-checkpoint.test.ts +78 -0
- package/src/start-node.test.ts +109 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/status-node-id-filter-http.test.ts +161 -0
- package/src/stop-delete-node.test.ts +1164 -0
- package/src/task-access-http.test.ts +379 -0
- package/src/task-access.ts +307 -0
- package/src/task-auth-origin-http.test.ts +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +141 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tools-list-json-schema.test.ts +66 -0
- package/src/tools.ts +3534 -343
- package/src/update-provider.test.ts +65 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +138 -5
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
package/src/server.ts
ADDED
|
@@ -0,0 +1,4522 @@
|
|
|
1
|
+
import { buildServeErrorResponse } from "./serve-error.js";
|
|
2
|
+
import { maybeGzipResponse, trimLightTask } from "./http-gzip";
|
|
3
|
+
import { redactMessageRow } from "./redact-tokens.js";
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { parseDbTimestampMs } from "./db-timestamp.js";
|
|
6
|
+
import { buildControllableMap, isLifecycleControllable } from "./node-lifecycle-controllable.js";
|
|
7
|
+
import { resolvePort } from "./resolve-port.js";
|
|
8
|
+
import { normalizeSessionRuntime } from "./runtime-label.js";
|
|
9
|
+
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
10
|
+
import { z } from "zod/v4";
|
|
11
|
+
import { registerTools } from "./tools.js";
|
|
12
|
+
import { db, logTaskEvent, logAudit, syncScheduledRunForTask } from "./db.js";
|
|
13
|
+
import { createSSEStream, createNetworkObserverStream, createUserEventStream, pushEvent, pushNetworkObserverEvent, getSSEStats, PRINTABLE_OBSERVER_KEY_PREFIX, closeUserStreamsInNetwork, getUserPresence, onUserPresenceChange, pushUserEventToNetwork } from "./push.js";
|
|
14
|
+
import { assertNodeActive } from "./lifecycle-guard.js";
|
|
15
|
+
import { pendingInboxCount } from "./inbox-count.js";
|
|
16
|
+
import { addAgentNetworkScope, addHumanNetworkScope, addNetworkScope, addOwnTrafficScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, resolveRestWriteNetworkId, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
17
|
+
import { restrictedMemberSeesFile, restrictedMemberAttachmentsDenied } from "./restricted-files.js";
|
|
18
|
+
import { dmParticipantSeesFile, listDmThread, listDmThreads, sendHumanDm } from "./human-dm.js";
|
|
19
|
+
import { getTaskAccessMode, isTaskScoped, listProjectGrants, replaceTaskGrants, NEW_MEMBER_TASK_ACCESS } from "./task-access.js";
|
|
20
|
+
import { canMessageAgent, isAgentRestricted, listAgentGrants, listGroupGrants, getAgentAccessMode, replaceAgentGrants, restrictedNetworkIds, usernameIsAgentAlias, listAgentGroups, createAgentGroup, updateAgentGroup, replaceAgentGroupMembers, deleteAgentGroup, usersGrantedGroup } from "./agent-access.js";
|
|
21
|
+
import { validateAvatarUrl } from "./avatar-validate.js";
|
|
22
|
+
import { narrowTags, parseStoredTags, validateScalarAttr } from "./node-attrs-validate.js";
|
|
23
|
+
import { daemonDefaultWorkdirRoot } from "./create-node-validate.js";
|
|
24
|
+
import { register, login, resolveToken, isExpiredSessionToken, sessionIdleDays, listSessions, revokeSession, revokeOtherSessions, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, adminDeleteEmptyNetwork, renameNetwork, changePassword, issueUserToken, listTokens, createToken, revokeToken, getNetworkMembers, getNetworkHumans, getUserNetworkRole, addNetworkMember, adminCreateUser, listUsersWithMemberships, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
|
|
25
|
+
import { abortRename, cleanupCommittedRenameSessions, commitRename, prepareRename, resolveCanonicalAlias } from "./rename.js";
|
|
26
|
+
import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
|
|
27
|
+
import { clientRequestIdFromMeta, idempotentTaskId, idempotentTaskMatches, type StoredIdempotentTask } from "./task-idempotency.js";
|
|
28
|
+
import { getLoginClientIp, sharedLoginFailureLockout, sharedLoginIpRateLimiter } from "./auth_login_guard.js";
|
|
29
|
+
import {
|
|
30
|
+
FILE_ID_REGEX,
|
|
31
|
+
MAX_REQUEST_CONTENT_LENGTH,
|
|
32
|
+
MAX_UPLOAD_BYTES,
|
|
33
|
+
buildStoragePath,
|
|
34
|
+
pathForExistingBlob,
|
|
35
|
+
isValidCalendarBucket,
|
|
36
|
+
generateFileId,
|
|
37
|
+
getUploadsRoot,
|
|
38
|
+
indexEntryPath,
|
|
39
|
+
isPathInsideUploadsRoot,
|
|
40
|
+
sanitizeExt,
|
|
41
|
+
sharedUploadRateLimiter,
|
|
42
|
+
stripHostLocalPathsForCrossHostSafe,
|
|
43
|
+
validateAttachments,
|
|
44
|
+
validateIndexEntry,
|
|
45
|
+
DM_FILE_SCOPE,
|
|
46
|
+
} from "./uploads.js";
|
|
47
|
+
import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, statSync, unlinkSync, renameSync } from "fs";
|
|
48
|
+
import { dirname as pathDirname, join as pathJoin } from "path";
|
|
49
|
+
import { startRetentionSweeper } from "./retention.js";
|
|
50
|
+
import { startStaleSessionSweeper } from "./stale-sweeper.js";
|
|
51
|
+
import {
|
|
52
|
+
AUDIT_LOG_REST_SELECT,
|
|
53
|
+
COMPLETION_REST_SELECT,
|
|
54
|
+
NETWORK_REST_SELECT,
|
|
55
|
+
SESSION_REST_SELECT,
|
|
56
|
+
TASK_EVENT_REST_SELECT,
|
|
57
|
+
TASK_REST_SELECT,
|
|
58
|
+
} from "./rest-projections.js";
|
|
59
|
+
import { resolveRestFromSession } from "./rest-identity.js";
|
|
60
|
+
import { stampTaskAuthOrigin, type TaskAuthOrigin } from "./task-auth-origin.js";
|
|
61
|
+
import { diagnoseTask } from "./task-diagnostic.js";
|
|
62
|
+
import { assertScheduledTaskBackendSupported, handleScheduledTaskRequest, startScheduledTaskScheduler } from "./scheduled-tasks.js";
|
|
63
|
+
import { handleRequirementsRequest } from "./requirements.js";
|
|
64
|
+
import { labelRoute, mcpRouteLabel, recordRouteTiming, routeStats, setRouteCallerMasterToken, setRouteCallerTokenReader } from "./route-timing.js";
|
|
65
|
+
import { handleExternalScheduleEditRequest } from "./external-schedule-edits.js";
|
|
66
|
+
import { recordDeliveredStaleEvents } from "./task-lifecycle-watcher.js";
|
|
67
|
+
import { SIDE_THREAD_FEATURE_FLAG, SideThreadCoordinator, SideThreadPortRegistry, SideThreadStore, type SideThreadActor, type SideThreadAttachmentRef, type SideThreadExecutionPort } from "./side-thread.js";
|
|
68
|
+
import { handleSideThreadHttpRequest } from "./side-thread-http.js";
|
|
69
|
+
import { createProductionSideThreadTransport } from "./side-thread-production.js";
|
|
70
|
+
import { parseHubTimestamp } from "./hub-timestamp";
|
|
71
|
+
|
|
72
|
+
const PORT = resolvePort(process.env.PORT);
|
|
73
|
+
const HOST = process.env.HOST || "127.0.0.1";
|
|
74
|
+
const AUTH_TOKEN = process.env.COMMHUB_AUTH_TOKEN;
|
|
75
|
+
setRouteCallerMasterToken(AUTH_TOKEN);
|
|
76
|
+
setRouteCallerTokenReader(req => requestToken(req)); // 函数声明会提升;与鉴权同一个取令牌优先级
|
|
77
|
+
const DEV_OPEN = process.argv.includes("--dev-open") || process.env.COMMHUB_DEV_OPEN === "1";
|
|
78
|
+
const TMUX_ENABLED = process.env.COMMHUB_ENABLE_TMUX === "1";
|
|
79
|
+
const SECURITY_LABEL = DEV_OPEN ? "⚠️ DEV OPEN MODE" : "🔒 secured";
|
|
80
|
+
const TMUX_ALLOWLIST = new Set(
|
|
81
|
+
(process.env.COMMHUB_TMUX_ALLOWLIST || "")
|
|
82
|
+
.split(",")
|
|
83
|
+
.map((s) => s.trim())
|
|
84
|
+
.filter(Boolean)
|
|
85
|
+
);
|
|
86
|
+
let masterTokenDeprecationLogged = false;
|
|
87
|
+
|
|
88
|
+
// #175 PR2 starts fail-closed. Only a verified native adapter can be installed;
|
|
89
|
+
// the execution port deliberately has no ordinary task/FIFO delivery method.
|
|
90
|
+
export const sideThreadPortRegistry = new SideThreadPortRegistry();
|
|
91
|
+
export function installSideThreadExecutionPort(port: SideThreadExecutionPort): () => void {
|
|
92
|
+
return sideThreadPortRegistry.install(port);
|
|
93
|
+
}
|
|
94
|
+
const sideThreadCoordinator = new SideThreadCoordinator(new SideThreadStore(db), sideThreadPortRegistry, {
|
|
95
|
+
enabled: process.env[SIDE_THREAD_FEATURE_FLAG] === "1",
|
|
96
|
+
authorizeNode: (actor, networkId, nodeId) => authorizeSideThreadNode(actor, networkId, nodeId),
|
|
97
|
+
authorizeAttachment: (actor, networkId, ref) => authorizeSideThreadAttachment(actor, networkId, ref),
|
|
98
|
+
});
|
|
99
|
+
const productionSideThreadTransport = process.env[SIDE_THREAD_FEATURE_FLAG] === "1"
|
|
100
|
+
? createProductionSideThreadTransport(db)
|
|
101
|
+
: null;
|
|
102
|
+
if (productionSideThreadTransport) installSideThreadExecutionPort(productionSideThreadTransport.port);
|
|
103
|
+
|
|
104
|
+
if (AUTH_TOKEN) {
|
|
105
|
+
console.warn("[commhub] COMMHUB_AUTH_TOKEN is deprecated and will be removed in v1.0. See RFC-001.");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// POST /mcp bodies up to this size are parsed once up front to label the route (see the /mcp handler).
|
|
109
|
+
const MCP_LABEL_MAX_BYTES = 1024 * 1024;
|
|
110
|
+
|
|
111
|
+
// Read version from package.json so banners and /health stay in sync.
|
|
112
|
+
const SERVER_VERSION = (() => {
|
|
113
|
+
try {
|
|
114
|
+
const url = new URL("../package.json", import.meta.url);
|
|
115
|
+
return JSON.parse(require("fs").readFileSync(url, "utf8")).version || "?";
|
|
116
|
+
} catch { return "?"; }
|
|
117
|
+
})();
|
|
118
|
+
|
|
119
|
+
// Feature flags advertised on /health (see the /health handler). Exported for tests.
|
|
120
|
+
export const HUB_HEALTH_CAPABILITIES = ["status_node_id"] as const;
|
|
121
|
+
|
|
122
|
+
// In-memory log ring buffer — last N lines streamed via /api/server-logs.
|
|
123
|
+
// Wraps console.log/info/warn/error so EVERY existing log call lands here
|
|
124
|
+
// without source changes. Dashboard tails this buffer for "hub server log
|
|
125
|
+
// view" feature.
|
|
126
|
+
const LOG_RING_CAP = Number(process.env.COMMHUB_LOG_RING || 500);
|
|
127
|
+
type LogEntry = { ts: string; level: "log" | "info" | "warn" | "error"; line: string };
|
|
128
|
+
const logRing: LogEntry[] = [];
|
|
129
|
+
const _origConsole = { log: console.log.bind(console), info: console.info.bind(console), warn: console.warn.bind(console), error: console.error.bind(console) };
|
|
130
|
+
function pushLog(level: LogEntry["level"], args: any[]) {
|
|
131
|
+
const line = args.map(a => typeof a === "string" ? a : (() => { try { return JSON.stringify(a); } catch { return String(a); } })()).join(" ");
|
|
132
|
+
logRing.push({ ts: new Date().toISOString(), level, line: line.slice(0, 4000) });
|
|
133
|
+
if (logRing.length > LOG_RING_CAP) logRing.splice(0, logRing.length - LOG_RING_CAP);
|
|
134
|
+
}
|
|
135
|
+
console.log = (...args: any[]) => { pushLog("log", args); _origConsole.log(...args); };
|
|
136
|
+
console.info = (...args: any[]) => { pushLog("info", args); _origConsole.info(...args); };
|
|
137
|
+
console.warn = (...args: any[]) => { pushLog("warn", args); _origConsole.warn(...args); };
|
|
138
|
+
console.error = (...args: any[]) => { pushLog("error", args); _origConsole.error(...args); };
|
|
139
|
+
|
|
140
|
+
function normalizeMetaJson(meta: unknown): string | null {
|
|
141
|
+
if (!meta || typeof meta !== "object") return null;
|
|
142
|
+
// #222 cross-host safety — same sanitization as tools.ts so REST
|
|
143
|
+
// /api/task and MCP send_task produce identical meta_json shapes.
|
|
144
|
+
// Conditional: only strips `path` when `file_id` is also present
|
|
145
|
+
// (preserves single-host feishu fallback per team rule — assume-unit-before-threshold: cap is in bytes
|
|
146
|
+
// discipline of not breaking other call sites).
|
|
147
|
+
try { return JSON.stringify(stripHostLocalPathsForCrossHostSafe(meta)); } catch { return null; }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Rate limiter (in-memory, per IP) ──
|
|
151
|
+
const rateLimits = new Map<string, { count: number; resetAt: number }>();
|
|
152
|
+
function checkRateLimit(ip: string, maxPerMinute = 60): boolean {
|
|
153
|
+
// Skip rate limiting for localhost/internal/unknown (dev/test)
|
|
154
|
+
if (!ip || ip === "unknown" || ip === "127.0.0.1" || ip === "::1") return true;
|
|
155
|
+
const now = Date.now();
|
|
156
|
+
const entry = rateLimits.get(ip);
|
|
157
|
+
if (!entry || now > entry.resetAt) {
|
|
158
|
+
rateLimits.set(ip, { count: 1, resetAt: now + 60000 });
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
if (entry.count >= maxPerMinute) return false;
|
|
162
|
+
entry.count++;
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
// Cleanup stale entries every 5 minutes. Interval is registered in
|
|
166
|
+
// startHub() (#476) — module scope must stay timer-free so importing
|
|
167
|
+
// this module neither holds the event loop open nor does periodic work.
|
|
168
|
+
function sweepStaleRateLimits(): void {
|
|
169
|
+
const now = Date.now();
|
|
170
|
+
for (const [ip, entry] of rateLimits) {
|
|
171
|
+
if (now > entry.resetAt) rateLimits.delete(ip);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── Factory: 每个请求创建新的 McpServer(stateless 模式)──
|
|
176
|
+
function createServer(clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null, callerTokenIsNetwork = false, callerTokenId?: string | null): McpServer {
|
|
177
|
+
const server = new McpServer({
|
|
178
|
+
name: "commhub",
|
|
179
|
+
version: "0.5.0",
|
|
180
|
+
});
|
|
181
|
+
registerTools(server, clientIP, enforceNetworkId, enforceUserId, callerAlias, callerTokenIsNetwork, callerTokenId);
|
|
182
|
+
return server;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── Auth helper ─────────────────────────────────────
|
|
186
|
+
type RequestTokenOptions = { allowQueryToken?: boolean };
|
|
187
|
+
|
|
188
|
+
function requestToken(req: Request, options: RequestTokenOptions = {}): string {
|
|
189
|
+
const header = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
190
|
+
if (options.allowQueryToken === false) return header || "";
|
|
191
|
+
const url = new URL(req.url);
|
|
192
|
+
return header || url.searchParams.get("token") || "";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function isLegacyAuthToken(req: Request, options: RequestTokenOptions = {}): boolean {
|
|
196
|
+
const token = requestToken(req, options);
|
|
197
|
+
return !!AUTH_TOKEN && token === AUTH_TOKEN;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function requireAuth(req: Request, options: RequestTokenOptions = {}): Response | null {
|
|
201
|
+
const token = requestToken(req, options);
|
|
202
|
+
|
|
203
|
+
// V3: check api_tokens first
|
|
204
|
+
if (token) {
|
|
205
|
+
const resolved = resolveToken(token);
|
|
206
|
+
if (resolved) return null; // valid user token
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Legacy: check global COMMHUB_AUTH_TOKEN
|
|
210
|
+
if (!AUTH_TOKEN && DEV_OPEN) return null; // explicit local/dev open mode
|
|
211
|
+
if (token === AUTH_TOKEN) {
|
|
212
|
+
const u = new URL(req.url);
|
|
213
|
+
const readOnlyApi = u.pathname.startsWith("/api/") && (req.method === "GET" || req.method === "HEAD" || req.method === "OPTIONS");
|
|
214
|
+
if (!readOnlyApi) return Response.json({ ok: false, error: "master-token auth is deprecated; use admin utok_" }, { status: 401 });
|
|
215
|
+
if (!masterTokenDeprecationLogged) {
|
|
216
|
+
console.warn("[commhub] master-token auth is deprecated and will be removed in v1.0. See RFC-001.");
|
|
217
|
+
masterTokenDeprecationLogged = true;
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return Response.json({ error: "unauthorized" }, { status: 401 });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function getClientIP(req: Request, server?: any): string {
|
|
226
|
+
const direct = server?.requestIP?.(req)?.address;
|
|
227
|
+
if (direct) return direct;
|
|
228
|
+
const fwd = req.headers.get("x-forwarded-for");
|
|
229
|
+
return fwd ? fwd.split(",")[0].trim() : (req.headers.get("x-real-ip") ?? "unknown");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function isLocalhostIP(ip: string): boolean {
|
|
233
|
+
return ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1" || ip === "localhost";
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Normalize the raw `agent` field into a canonical runtime identifier for the
|
|
237
|
+
// dashboard's Runtime badge. Returns null for unknown/absent agents so the
|
|
238
|
+
// frontend can fall back to a placeholder.
|
|
239
|
+
const normalizeRuntime = normalizeSessionRuntime;
|
|
240
|
+
|
|
241
|
+
function isTmuxAllowedIP(ip: string): boolean {
|
|
242
|
+
return isLocalhostIP(ip) || TMUX_ALLOWLIST.has(ip);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// 节点令牌(ntok_ / 邀请码令牌,api_tokens.network_id 非空)是一个节点在一个网络里的身份。
|
|
246
|
+
// resolveToken 把它解析成「签发它的用户」,但它**不继承**那个用户的账号权限与 Hub 管理员身份:
|
|
247
|
+
// 账号 / 令牌 / 网络与成员管理只接受用户令牌,管理员判定一律要求用户令牌。
|
|
248
|
+
// 生产上几乎所有节点令牌都由管理员签发 —— 不这样做,任何一个节点都等于管理员。
|
|
249
|
+
function isNodeCredential(resolved: { networkId: string | null } | null | undefined): boolean {
|
|
250
|
+
return !!resolved?.networkId;
|
|
251
|
+
}
|
|
252
|
+
// 人员列表的在线点:某人的第一条用户流连上 / 最后一条断开时,推给他所在每个网络里其他连着用户流的成员。
|
|
253
|
+
// 收件人本来就是该网络成员(用户流入口按成员资格鉴权),与 GET /api/networks/:id/humans 同一可见范围。
|
|
254
|
+
// 字段叫 member_user_id:pushUserEvent 会把 user_id 写成**收件人**自己。
|
|
255
|
+
onUserPresenceChange((userId, online, lastSeenAt) => {
|
|
256
|
+
const nets = db.all<{ network_id: string }>("SELECT network_id FROM network_members WHERE user_id = ?1", userId);
|
|
257
|
+
for (const { network_id } of nets) {
|
|
258
|
+
pushUserEventToNetwork(network_id, { type: "member_presence", member_user_id: userId, online, last_seen_at: lastSeenAt }, userId);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
function isHubAdminCredential(resolved: { networkId: string | null; user: { role: string } } | null | undefined): boolean {
|
|
263
|
+
return !!resolved && !resolved.networkId && resolved.user.role === "admin";
|
|
264
|
+
}
|
|
265
|
+
// 节点改名(`anet node rename` 用节点自己的 ntok 驱动 2PC):节点令牌只能改**它自己**——
|
|
266
|
+
// 绑定网络内、old_alias 就是令牌所代表的那个节点。改别的节点、别的网络仍然要用户令牌。
|
|
267
|
+
function nodeTokenAlias(resolved: { tokenName: string | null }): string | null {
|
|
268
|
+
return resolved.tokenName?.startsWith("node:") ? resolved.tokenName.slice("node:".length) : null;
|
|
269
|
+
}
|
|
270
|
+
function nodeMayRename(resolved: { networkId: string | null; tokenName: string | null }, networkId: unknown, oldAlias: unknown): boolean {
|
|
271
|
+
if (!resolved.networkId) return true;
|
|
272
|
+
return networkId === resolved.networkId && typeof oldAlias === "string" && oldAlias === nodeTokenAlias(resolved);
|
|
273
|
+
}
|
|
274
|
+
function nodeMayRenameTxn(resolved: { networkId: string | null; tokenName: string | null }, txnId: unknown): boolean {
|
|
275
|
+
if (!resolved.networkId) return true;
|
|
276
|
+
if (typeof txnId !== "string") return false;
|
|
277
|
+
const txn = db.get<{ network_id: string; old_alias: string; new_alias: string }>("SELECT network_id, old_alias, new_alias FROM rename_txn WHERE txn_id = ?1", txnId);
|
|
278
|
+
if (!txn || txn.network_id !== resolved.networkId) return false;
|
|
279
|
+
// commit 前后令牌名可能已被改成新 alias(api_tokens.name 跟随改名),两个都算它自己。
|
|
280
|
+
const self = nodeTokenAlias(resolved);
|
|
281
|
+
return self === txn.old_alias || self === txn.new_alias;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// 登录会话闲置过期(auth.ts sessionIdleDays):resolveToken 把过期令牌当作不存在,各端点于是回各自的
|
|
285
|
+
// 401("invalid token" / "unauthorized" / "auth required" …)。客户端要据此提示「登录已过期」而不是
|
|
286
|
+
// 「令牌无效」,所以在唯一的出口处统一改写:请求带的令牌恰好是一个已闲置过期的会话令牌时,
|
|
287
|
+
// 401 的正文换成 { error: "token_expired" }。状态码不变;只在已经是 401 时才多查一次库。
|
|
288
|
+
function explainExpiredSession(req: Request, res: Response | undefined): Response | undefined {
|
|
289
|
+
if (!res || res.status !== 401) return res;
|
|
290
|
+
const token = requestToken(req);
|
|
291
|
+
if (!token || !isExpiredSessionToken(token)) return res;
|
|
292
|
+
return withCors(req, Response.json(
|
|
293
|
+
{ ok: false, error: "token_expired", message: "login session expired after inactivity; sign in again" },
|
|
294
|
+
{ status: 401 },
|
|
295
|
+
));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// 上传文件的归属网络记在磁盘索引 <uploadsRoot>/.index/<file_id>.json 的 network_id 里,不在库里。
|
|
299
|
+
// 管理员删空网络前要数它:目录不存在就是 0;目录读不了就抛(不能把「数不了」当成「没有」)。
|
|
300
|
+
function countNetworkUploads(networkId: string): number {
|
|
301
|
+
const dir = pathJoin(getUploadsRoot(), ".index");
|
|
302
|
+
let names: string[];
|
|
303
|
+
try { names = readdirSync(dir); } catch (e: any) { if (e?.code === "ENOENT") return 0; throw e; }
|
|
304
|
+
let count = 0;
|
|
305
|
+
for (const name of names) {
|
|
306
|
+
if (!name.endsWith(".json")) continue;
|
|
307
|
+
try {
|
|
308
|
+
if (JSON.parse(readFileSync(pathJoin(dir, name), "utf8"))?.network_id === networkId) count++;
|
|
309
|
+
} catch {}
|
|
310
|
+
}
|
|
311
|
+
return count;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function userTokenRequired(req: Request): Response {
|
|
315
|
+
return withCors(req, Response.json({ ok: false, error: "user_token_required", message: "this endpoint acts on a user account; node tokens cannot use it" }, { status: 403 }));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function requireAdminAuth(req: Request): Response | null {
|
|
319
|
+
const token = requestToken(req);
|
|
320
|
+
if (!token) return Response.json({ ok: false, error: "auth required" }, { status: 401 });
|
|
321
|
+
const resolved = resolveToken(token);
|
|
322
|
+
if (!resolved) return Response.json({ ok: false, error: "invalid token" }, { status: 401 });
|
|
323
|
+
if (!isHubAdminCredential(resolved)) return Response.json({ ok: false, error: "admin required" }, { status: 403 });
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function requireTmuxAccess(req: Request, server?: any): Response | null {
|
|
328
|
+
if (!TMUX_ENABLED) return Response.json({ ok: false, error: "tmux disabled" }, { status: 404 });
|
|
329
|
+
const ip = getClientIP(req, server);
|
|
330
|
+
if (!isTmuxAllowedIP(ip)) return Response.json({ ok: false, error: "tmux access denied from this ip" }, { status: 403 });
|
|
331
|
+
return requireAdminAuth(req);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Extract user + network + token-binding identity from request token.
|
|
335
|
+
function resolveRequestAuth(req: Request, options: RequestTokenOptions = {}): { userId: string; networkId: string | null; username: string; tokenName: string | null; tokenId: string | null } | null {
|
|
336
|
+
const token = requestToken(req, options);
|
|
337
|
+
if (!token) return null;
|
|
338
|
+
const resolved = resolveToken(token);
|
|
339
|
+
if (!resolved) return null;
|
|
340
|
+
return { userId: resolved.user.user_id, networkId: resolved.networkId, username: resolved.user.username, tokenName: resolved.tokenName, tokenId: resolved.tokenId };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function resolveSideThreadActor(req: Request, auth: ReturnType<typeof resolveRequestAuth>, isAdmin: boolean): SideThreadActor | null {
|
|
344
|
+
// Legacy master and DEV_OPEN cannot own durable records because neither
|
|
345
|
+
// supplies a stable per-user identity for later window hydration.
|
|
346
|
+
if (!auth?.userId || !auth.tokenId) return null;
|
|
347
|
+
const nodeCredential = requestToken(req).startsWith("ntok_");
|
|
348
|
+
const tokenRow = nodeCredential
|
|
349
|
+
? db.get<{ bound_node_id: string | null }>("SELECT bound_node_id FROM api_tokens WHERE token_id = ?1", auth.tokenId)
|
|
350
|
+
: null;
|
|
351
|
+
return {
|
|
352
|
+
userId: auth.userId,
|
|
353
|
+
username: auth.username,
|
|
354
|
+
tokenId: auth.tokenId,
|
|
355
|
+
kind: nodeCredential ? "node" : "user",
|
|
356
|
+
...(auth.networkId ? { boundNetworkId: auth.networkId } : {}),
|
|
357
|
+
...(tokenRow?.bound_node_id ? { boundNodeId: tokenRow.bound_node_id } : {}),
|
|
358
|
+
isAdmin,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// #503 — the credential kinds that authorization decisions branch on.
|
|
363
|
+
// Resolved once per request at the handler, so the authz helpers stay
|
|
364
|
+
// pure functions of (principal, entry) and every credential kind is a
|
|
365
|
+
// directly constructible test input instead of a hand-assembled Request.
|
|
366
|
+
export type Principal =
|
|
367
|
+
| { kind: "anonymous" }
|
|
368
|
+
| { kind: "dev-open-anon" }
|
|
369
|
+
| { kind: "legacy-master" }
|
|
370
|
+
| { kind: "utok"; userId: string; username: string; isAdmin: false }
|
|
371
|
+
| { kind: "ntok"; userId: string; boundNetworkId: string }
|
|
372
|
+
| { kind: "admin-utok"; userId: string; username: string };
|
|
373
|
+
|
|
374
|
+
export function resolvePrincipal(req: Request, options: RequestTokenOptions = {}): Principal {
|
|
375
|
+
const authCtx = resolveRequestAuth(req, options);
|
|
376
|
+
if (!authCtx) {
|
|
377
|
+
if (isLegacyAuthToken(req, options)) return { kind: "legacy-master" };
|
|
378
|
+
if (DEV_OPEN) return { kind: "dev-open-anon" };
|
|
379
|
+
return { kind: "anonymous" };
|
|
380
|
+
}
|
|
381
|
+
const token = requestToken(req, options);
|
|
382
|
+
const resolved = token ? resolveToken(token) : null;
|
|
383
|
+
const isAdmin = !!resolved && resolved.user.role === "admin";
|
|
384
|
+
// Bound network wins over admin classification: a ntok_ issued BY an
|
|
385
|
+
// admin is still a ntok_, and its bound scope defines its identity.
|
|
386
|
+
// Inverting this order would silently re-grant every production agent
|
|
387
|
+
// node cross-network read, since production node tokens resolve to an
|
|
388
|
+
// admin user (see the 生产影响 note on authorizeFileDownload).
|
|
389
|
+
if (authCtx.networkId) return { kind: "ntok", userId: authCtx.userId, boundNetworkId: authCtx.networkId };
|
|
390
|
+
if (isAdmin) return { kind: "admin-utok", userId: authCtx.userId, username: authCtx.username };
|
|
391
|
+
return { kind: "utok", userId: authCtx.userId, username: authCtx.username, isAdmin: false };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// #503 — coerce the on-disk (untrusted, hand-editable) index entry into
|
|
395
|
+
// the shape the authz helper reasons about. Every `typeof` narrowing for
|
|
396
|
+
// these two fields lives here and nowhere else, so the authz rules can be
|
|
397
|
+
// read as rules rather than as string-checks.
|
|
398
|
+
export function normalizeEntry(
|
|
399
|
+
entry: { owner_id?: unknown; network_id?: unknown; scope?: unknown },
|
|
400
|
+
): { ownerId: string | null; networkId: string | null; dmScoped?: true } {
|
|
401
|
+
const ownerId = typeof entry.owner_id === "string" && entry.owner_id.length > 0
|
|
402
|
+
? entry.owner_id
|
|
403
|
+
: null;
|
|
404
|
+
const networkId = typeof entry.network_id === "string" && entry.network_id.length > 0
|
|
405
|
+
? entry.network_id
|
|
406
|
+
: null;
|
|
407
|
+
// dmScoped 只在是私信文件时出现:其余条目的形状与之前逐字相同。
|
|
408
|
+
return { ownerId, networkId, ...(entry.scope === DM_FILE_SCOPE ? { dmScoped: true as const } : {}) };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// #495/#503 — shared authorization gate for /api/files/:file_id downloads.
|
|
412
|
+
// Called from the GET handler and any future HEAD/Range/dashboard-proxy
|
|
413
|
+
// entry so the allow-list can't drift between methods (通信龙 clause 2:
|
|
414
|
+
// "授权若只在 GET 分支实现, HEAD 或 Range 走别路径 = 等于没修").
|
|
415
|
+
//
|
|
416
|
+
// Returns true if the caller may read this entry; false otherwise. The
|
|
417
|
+
// caller emits 404 on false — never 403; 403 would leak that the
|
|
418
|
+
// file_id exists and enables enumeration.
|
|
419
|
+
//
|
|
420
|
+
// Allow-list — network scope enforced by #503:
|
|
421
|
+
// - Legacy master AUTH_TOKEN (RFC-001 read-only) → allow.
|
|
422
|
+
// - admin utok_ → allow (mirrors requireAdminAuth).
|
|
423
|
+
// - entry.network_id present → caller must belong to THAT network:
|
|
424
|
+
// ntok_ must be bound to it; utok_ must hold any role in it
|
|
425
|
+
// (viewer included — viewer is read-only, and a viewer who can see
|
|
426
|
+
// a task but not open its attachment is a broken product).
|
|
427
|
+
// - entry.network_id absent (legacy / unattributed) → the pre-#503
|
|
428
|
+
// rules stand unchanged: owner match, or null owner_id + DEV_OPEN.
|
|
429
|
+
// - entry.dmScoped (uploaded with ?purpose=dm — its first use is a
|
|
430
|
+
// human DM) → only the uploader and the users on either side of a DM
|
|
431
|
+
// that carries it (human-dm.ts dmParticipantSeesFile). Other members of
|
|
432
|
+
// the network and node tokens (agents) are denied. admin-utok and the
|
|
433
|
+
// legacy master token still pass above, as for every other file.
|
|
434
|
+
// Files uploaded any other way never become DM-scoped, including when
|
|
435
|
+
// they are later forwarded into a DM.
|
|
436
|
+
// - Everything else → deny (404). New uploads in production always
|
|
437
|
+
// carry a truthy owner_id (requireAuth blocks the null-owner-
|
|
438
|
+
// producing path when DEV_OPEN=off and no legacy master token is
|
|
439
|
+
// configured), so denying null-owner in production closes the
|
|
440
|
+
// enumeration vector for legacy blobs without breaking new uploads.
|
|
441
|
+
//
|
|
442
|
+
// 生产影响: 新的 ntok 归属文件走 network gate, admin 签发的 ntok 也走 gate
|
|
443
|
+
// (kind='ntok' 分类优先绑定). 98 历史文件无 network_id 走老文件兼容分支
|
|
444
|
+
// (owner/admin 可读, 存量行为不变).
|
|
445
|
+
//
|
|
446
|
+
// 行为变更: admin 的节点令牌 (ntok_) 失去它今天拥有的跨网络读权. 生产今天
|
|
447
|
+
// 所有文件在同一 network → 无实际影响, 但语义变更须明写, 不能悄悄发生.
|
|
448
|
+
export function authorizeFileDownload(
|
|
449
|
+
principal: Principal,
|
|
450
|
+
entry: { ownerId: string | null; networkId: string | null; dmScoped?: boolean },
|
|
451
|
+
fileId?: string,
|
|
452
|
+
): boolean {
|
|
453
|
+
if (principal.kind === "legacy-master") return true;
|
|
454
|
+
if (principal.kind === "admin-utok") return true;
|
|
455
|
+
|
|
456
|
+
if (entry.networkId !== null) {
|
|
457
|
+
if (principal.kind === "anonymous" || principal.kind === "dev-open-anon") return false;
|
|
458
|
+
if (principal.kind === "ntok") return principal.boundNetworkId === entry.networkId && !entry.dmScoped;
|
|
459
|
+
if (!getUserNetworkRole(principal.userId, entry.networkId)) return false;
|
|
460
|
+
// 私信文件:只看是不是这段私信里的人,与受限 / 不受限无关。
|
|
461
|
+
if (entry.dmScoped) {
|
|
462
|
+
if (entry.ownerId !== null && entry.ownerId === principal.userId) return true;
|
|
463
|
+
return !!fileId && dmParticipantSeesFile(principal.userId, entry.networkId, fileId);
|
|
464
|
+
}
|
|
465
|
+
// 多用户 Agent 权限:受限成员只能下载自己传的,或者出现在自己与授权 Agent 往来 / 自己收件箱里的文件。
|
|
466
|
+
if (!isAgentRestricted(principal.userId, entry.networkId)) return true;
|
|
467
|
+
if (entry.ownerId !== null && entry.ownerId === principal.userId) return true;
|
|
468
|
+
return !!fileId && restrictedMemberSeesFile(principal.userId, principal.username, entry.networkId, fileId);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
if (principal.kind === "ntok" || principal.kind === "utok") {
|
|
472
|
+
if (entry.ownerId !== null && principal.userId === entry.ownerId) return true;
|
|
473
|
+
}
|
|
474
|
+
// Kept env-gated rather than narrowed to kind==='dev-open-anon': the
|
|
475
|
+
// pre-#503 rule allowed ANY caller to read a null-owner entry while
|
|
476
|
+
// DEV_OPEN is on, and #503 is scoped to adding network scope, not to
|
|
477
|
+
// tightening the local-dev carve-out.
|
|
478
|
+
if (entry.ownerId === null && DEV_OPEN) return true;
|
|
479
|
+
return false;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function authorizeSideThreadNode(actor: SideThreadActor, networkId: string, nodeId: string): boolean {
|
|
483
|
+
const node = db.get<{ network_id: string | null; owner_user_id: string | null }>(
|
|
484
|
+
"SELECT network_id, owner_user_id FROM nodes WHERE node_id = ?1", nodeId,
|
|
485
|
+
);
|
|
486
|
+
if (!node || node.network_id !== networkId) return false;
|
|
487
|
+
if (!getUserNetworkRole(actor.userId, networkId)) return false;
|
|
488
|
+
if (!node.owner_user_id || node.owner_user_id !== actor.userId) return false;
|
|
489
|
+
if (actor.boundNetworkId && actor.boundNetworkId !== networkId) return false;
|
|
490
|
+
if (actor.kind === "node" && actor.boundNodeId !== nodeId) return false;
|
|
491
|
+
return true;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function resolveSideThreadCapabilityTarget(input: { actor: SideThreadActor; alias?: string; nodeId?: string; networkId?: string }): { nodeId: string; networkId: string } | undefined {
|
|
495
|
+
if ((!input.alias && !input.nodeId) || (input.alias && input.nodeId)) return undefined;
|
|
496
|
+
const networkId = input.actor.boundNetworkId ?? input.networkId;
|
|
497
|
+
const params: unknown[] = [input.nodeId ?? input.alias];
|
|
498
|
+
let sql = input.nodeId
|
|
499
|
+
? "SELECT node_id,network_id FROM nodes WHERE node_id=?1"
|
|
500
|
+
: "SELECT node_id,network_id FROM nodes WHERE alias=?1";
|
|
501
|
+
if (networkId) { params.push(networkId); sql += " AND network_id=?2"; }
|
|
502
|
+
const authorized = db.all<{ node_id: string; network_id: string | null }>(sql, ...params)
|
|
503
|
+
.filter(row => !!row.network_id && authorizeSideThreadNode(input.actor, row.network_id, row.node_id));
|
|
504
|
+
if (authorized.length !== 1) return undefined;
|
|
505
|
+
return { nodeId: authorized[0].node_id, networkId: authorized[0].network_id! };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function authorizeSideThreadAttachment(actor: SideThreadActor, networkId: string, ref: SideThreadAttachmentRef): boolean {
|
|
509
|
+
if (!FILE_ID_REGEX.test(ref.fileId)) return false;
|
|
510
|
+
const path = indexEntryPath(ref.fileId);
|
|
511
|
+
if (!path || !existsSync(path)) return false;
|
|
512
|
+
try {
|
|
513
|
+
const entry = JSON.parse(readFileSync(path, "utf8"));
|
|
514
|
+
if (!validateIndexEntry(entry) || entry.file_id !== ref.fileId) return false;
|
|
515
|
+
if (entry.network_id !== networkId || entry.owner_id !== actor.userId) return false;
|
|
516
|
+
if (!isValidCalendarBucket(entry.date_bucket)) return false;
|
|
517
|
+
const storage = pathForExistingBlob(entry.date_bucket, entry.file_id, entry.ext);
|
|
518
|
+
return isPathInsideUploadsRoot(storage.absolutePath) && existsSync(storage.absolutePath);
|
|
519
|
+
} catch { return false; }
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
type ByteRange =
|
|
523
|
+
| { ok: true; start: number; end: number; length: number }
|
|
524
|
+
| { ok: false };
|
|
525
|
+
|
|
526
|
+
function parseSingleByteRange(rangeHeader: string | null, size: number): ByteRange | null {
|
|
527
|
+
if (rangeHeader === null) return null;
|
|
528
|
+
if (!Number.isSafeInteger(size) || size < 0) return { ok: false };
|
|
529
|
+
const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
|
|
530
|
+
if (!match) return { ok: false };
|
|
531
|
+
const [, rawStart, rawEnd] = match;
|
|
532
|
+
if (rawStart === "" && rawEnd === "") return { ok: false };
|
|
533
|
+
|
|
534
|
+
let start: number;
|
|
535
|
+
let end: number;
|
|
536
|
+
if (rawStart === "") {
|
|
537
|
+
const suffixLength = Number(rawEnd);
|
|
538
|
+
if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0) return { ok: false };
|
|
539
|
+
if (size === 0) return { ok: false };
|
|
540
|
+
start = Math.max(0, size - suffixLength);
|
|
541
|
+
end = size - 1;
|
|
542
|
+
} else {
|
|
543
|
+
start = Number(rawStart);
|
|
544
|
+
if (!Number.isSafeInteger(start) || start < 0) return { ok: false };
|
|
545
|
+
if (start >= size) return { ok: false };
|
|
546
|
+
if (rawEnd === "") {
|
|
547
|
+
end = size - 1;
|
|
548
|
+
} else {
|
|
549
|
+
end = Number(rawEnd);
|
|
550
|
+
if (!Number.isSafeInteger(end) || end < start) return { ok: false };
|
|
551
|
+
end = Math.min(end, size - 1);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return { ok: true, start, end, length: end - start + 1 };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function sqliteTime(date: Date): string {
|
|
559
|
+
return date.toISOString().replace("T", " ").slice(0, 19);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function parseSqliteTime(value: string | null | undefined): number {
|
|
563
|
+
if (!value) return 0;
|
|
564
|
+
const normalized = value.includes("T") ? value : `${value.replace(" ", "T")}Z`;
|
|
565
|
+
const ts = Date.parse(normalized);
|
|
566
|
+
return Number.isFinite(ts) ? ts : 0;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function normalizeTaskBeforeCursor(value: string): string | null {
|
|
570
|
+
const cursor = value.trim();
|
|
571
|
+
if (!cursor) return null;
|
|
572
|
+
const sqliteShape = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
|
|
573
|
+
if (sqliteShape.test(cursor)) {
|
|
574
|
+
const parsed = new Date(`${cursor.replace(" ", "T")}Z`);
|
|
575
|
+
if (!Number.isFinite(parsed.getTime()) || sqliteTime(parsed) !== cursor) return null;
|
|
576
|
+
return cursor;
|
|
577
|
+
}
|
|
578
|
+
const timestamp = Date.parse(cursor);
|
|
579
|
+
if (!Number.isFinite(timestamp)) return null;
|
|
580
|
+
return sqliteTime(new Date(timestamp));
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function normalizeTaskBeforeTaskId(value: string): string | null {
|
|
584
|
+
const taskId = value.trim();
|
|
585
|
+
if (!taskId || taskId.length > 256 || /[\u0000-\u001f\u007f]/.test(taskId)) return null;
|
|
586
|
+
return taskId;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function cpuPct(load: number | null | undefined, cores: number | null | undefined): number | null {
|
|
590
|
+
if (typeof load !== "number" || typeof cores !== "number" || cores <= 0) return null;
|
|
591
|
+
return Math.round((load / cores) * 1000) / 10;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function serverAlertLevel(row: any): { level: "green" | "yellow" | "red"; alerts: string[] } {
|
|
595
|
+
const alerts: string[] = [];
|
|
596
|
+
const pct = cpuPct(row?.cpu_load_1min, row?.cpu_cores);
|
|
597
|
+
if (pct !== null && pct >= 80) alerts.push(`cpu ${pct}%`);
|
|
598
|
+
if (typeof row?.mem_avail_gb === "number" && row.mem_avail_gb < 0.5) alerts.push(`memory ${row.mem_avail_gb}GB available`);
|
|
599
|
+
if (typeof row?.disk_avail_gb === "number" && row.disk_avail_gb < 1) alerts.push(`disk ${row.disk_avail_gb}GB available`);
|
|
600
|
+
if (alerts.length > 0) return { level: "red", alerts };
|
|
601
|
+
|
|
602
|
+
if (pct !== null && pct >= 60) alerts.push(`cpu ${pct}%`);
|
|
603
|
+
if (typeof row?.mem_avail_gb === "number" && row.mem_avail_gb < 1) alerts.push(`memory ${row.mem_avail_gb}GB available`);
|
|
604
|
+
if (typeof row?.disk_avail_gb === "number" && row.disk_avail_gb < 5) alerts.push(`disk ${row.disk_avail_gb}GB available`);
|
|
605
|
+
return { level: alerts.length > 0 ? "yellow" : "green", alerts };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function agentHealthChip(status: unknown, lastSeen: string | null | undefined): "online" | "offline" | "stale" {
|
|
609
|
+
if (String(status || "").toLowerCase() === "offline") return "offline";
|
|
610
|
+
const ts = parseSqliteTime(lastSeen);
|
|
611
|
+
if (!ts || Date.now() - ts > 5 * 60 * 1000) return "stale";
|
|
612
|
+
return "online";
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function bucketTelemetry(rows: any[], fromMs: number, bucketMs: number) {
|
|
616
|
+
const buckets = new Map<number, {
|
|
617
|
+
ts: number;
|
|
618
|
+
count: number;
|
|
619
|
+
cpu_pct_sum: number;
|
|
620
|
+
cpu_pct_count: number;
|
|
621
|
+
cpu_load_sum: number;
|
|
622
|
+
cpu_load_count: number;
|
|
623
|
+
mem_avail_min: number | null;
|
|
624
|
+
mem_used_max: number | null;
|
|
625
|
+
disk_avail_min: number | null;
|
|
626
|
+
disk_used_max: number | null;
|
|
627
|
+
}>();
|
|
628
|
+
|
|
629
|
+
for (const row of rows) {
|
|
630
|
+
const ts = parseSqliteTime(row.created_at);
|
|
631
|
+
if (!ts || ts < fromMs) continue;
|
|
632
|
+
const bucketTs = Math.floor(ts / bucketMs) * bucketMs;
|
|
633
|
+
const bucket = buckets.get(bucketTs) ?? {
|
|
634
|
+
ts: bucketTs,
|
|
635
|
+
count: 0,
|
|
636
|
+
cpu_pct_sum: 0,
|
|
637
|
+
cpu_pct_count: 0,
|
|
638
|
+
cpu_load_sum: 0,
|
|
639
|
+
cpu_load_count: 0,
|
|
640
|
+
mem_avail_min: null,
|
|
641
|
+
mem_used_max: null,
|
|
642
|
+
disk_avail_min: null,
|
|
643
|
+
disk_used_max: null,
|
|
644
|
+
};
|
|
645
|
+
bucket.count += 1;
|
|
646
|
+
const pct = cpuPct(row.cpu_load_1min, row.cpu_cores);
|
|
647
|
+
if (pct !== null) {
|
|
648
|
+
bucket.cpu_pct_sum += pct;
|
|
649
|
+
bucket.cpu_pct_count += 1;
|
|
650
|
+
}
|
|
651
|
+
if (typeof row.cpu_load_1min === "number") {
|
|
652
|
+
bucket.cpu_load_sum += row.cpu_load_1min;
|
|
653
|
+
bucket.cpu_load_count += 1;
|
|
654
|
+
}
|
|
655
|
+
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);
|
|
656
|
+
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);
|
|
657
|
+
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);
|
|
658
|
+
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);
|
|
659
|
+
buckets.set(bucketTs, bucket);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
return Array.from(buckets.values())
|
|
663
|
+
.sort((a, b) => a.ts - b.ts)
|
|
664
|
+
.map((b) => ({
|
|
665
|
+
ts: new Date(b.ts).toISOString(),
|
|
666
|
+
count: b.count,
|
|
667
|
+
cpu_pct: b.cpu_pct_count ? Math.round((b.cpu_pct_sum / b.cpu_pct_count) * 10) / 10 : null,
|
|
668
|
+
cpu_load_1min: b.cpu_load_count ? Math.round((b.cpu_load_sum / b.cpu_load_count) * 100) / 100 : null,
|
|
669
|
+
mem_avail_gb: b.mem_avail_min,
|
|
670
|
+
mem_used_gb: b.mem_used_max,
|
|
671
|
+
disk_avail_gb: b.disk_avail_min,
|
|
672
|
+
disk_used_gb: b.disk_used_max,
|
|
673
|
+
}));
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
type RestDeliveryTarget =
|
|
678
|
+
| { state: "online"; alias: string; session: any }
|
|
679
|
+
| { state: "offline"; alias: string; session: any; message: string }
|
|
680
|
+
| { state: "not_found"; alias: string; message: string };
|
|
681
|
+
|
|
682
|
+
// Build this only after authorization and a network-scoped lookup succeeded.
|
|
683
|
+
// In particular, not-found and permission-denied responses must not become a
|
|
684
|
+
// cross-network alias/node enumeration oracle.
|
|
685
|
+
function restActualTo(target: Exclude<RestDeliveryTarget, { state: "not_found" }>, networkId: string | null) {
|
|
686
|
+
return {
|
|
687
|
+
alias: target.alias,
|
|
688
|
+
to_node_id: target.session?.node_id ?? null,
|
|
689
|
+
network_id: networkId,
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function resolveRestDeliveryTarget(alias: string, networkId: string | null): RestDeliveryTarget {
|
|
694
|
+
const params: any[] = [alias];
|
|
695
|
+
let sql = "SELECT status, updated_at, last_seen_at, node_id FROM sessions WHERE alias = ?1";
|
|
696
|
+
if (networkId) {
|
|
697
|
+
sql += " AND network_id = ?2";
|
|
698
|
+
params.push(networkId);
|
|
699
|
+
}
|
|
700
|
+
const session = db.get<any>(sql, ...params);
|
|
701
|
+
if (!session) {
|
|
702
|
+
return { state: "not_found", alias, message: `alias not found: ${alias}` };
|
|
703
|
+
}
|
|
704
|
+
const lastSeen = session.last_seen_at || session.updated_at;
|
|
705
|
+
const lastSeenAt = lastSeen ? new Date(String(lastSeen).replace(" ", "T") + "Z").getTime() : 0;
|
|
706
|
+
const stale = !lastSeenAt || Date.now() - lastSeenAt > 5 * 60 * 1000;
|
|
707
|
+
if (String(session.status || "").toLowerCase() === "offline" || stale) {
|
|
708
|
+
return {
|
|
709
|
+
state: "offline",
|
|
710
|
+
alias,
|
|
711
|
+
session,
|
|
712
|
+
message: `alias is offline; task queued in inbox: ${alias}`,
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
return { state: "online", alias, session };
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function resolveRestNodeIdForAlias(alias: string, networkId: string | null): string | null {
|
|
719
|
+
if (!alias || alias === "hub" || alias === "api") return null;
|
|
720
|
+
const canonical = resolveCanonicalAlias(networkId, alias);
|
|
721
|
+
const target = resolveRestDeliveryTarget(canonical.alias, networkId);
|
|
722
|
+
return target.state === "not_found" ? null : (target.session?.node_id ?? null);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function withNetworkNameAlias<T extends { network_name?: unknown }>(row: T): T & { name: string | null } {
|
|
726
|
+
return {
|
|
727
|
+
...row,
|
|
728
|
+
// `network_name` is the canonical V3 storage/API field and remains for
|
|
729
|
+
// existing CLI/Dashboard clients. `name` is the documented REST-friendly
|
|
730
|
+
// compatibility alias; never let it drift or serialize as null when the
|
|
731
|
+
// canonical NOT NULL field is present.
|
|
732
|
+
name: typeof row.network_name === "string" ? row.network_name : null,
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// ── REST input schema ───────────────────────────────
|
|
737
|
+
const TaskSchema = z.object({
|
|
738
|
+
alias: z.string().min(1).max(200),
|
|
739
|
+
task: z.string().min(1).max(10000),
|
|
740
|
+
priority: z.enum(["high", "normal", "low"]).default("normal"),
|
|
741
|
+
from: z.string().max(200).optional(),
|
|
742
|
+
network_id: z.string().max(200).optional(),
|
|
743
|
+
parent_task_id: z.string().max(200).optional(),
|
|
744
|
+
ttl_seconds: z.number().min(1).max(86400).optional(),
|
|
745
|
+
// #221 — top-level attachments mirror the MCP send_task tool. They are
|
|
746
|
+
// merged into `meta.attachments` for persistence so the on-disk shape
|
|
747
|
+
// stays unified regardless of transport (REST or MCP).
|
|
748
|
+
attachments: z.any().optional(),
|
|
749
|
+
meta: z.any().optional(),
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
const BroadcastSchema = z.object({
|
|
753
|
+
message: z.string().min(1).max(10000),
|
|
754
|
+
filter_server: z.string().max(200).optional(),
|
|
755
|
+
filter_status: z.string().max(50).optional(),
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
// ── HTTP Server (Bun native) ────────────────────────
|
|
759
|
+
const CORS_ORIGINS = process.env.COMMHUB_CORS_ORIGINS
|
|
760
|
+
? process.env.COMMHUB_CORS_ORIGINS.split(",").map((s) => s.trim())
|
|
761
|
+
: ["http://localhost:3000", "http://localhost:3001"];
|
|
762
|
+
|
|
763
|
+
function corsHeaders(req: Request): Record<string, string> {
|
|
764
|
+
const origin = req.headers.get("Origin") || "";
|
|
765
|
+
// CORS allowlist is driven entirely by COMMHUB_CORS_ORIGINS env (comma-separated).
|
|
766
|
+
// Default (env unset) allows localhost dev origins only — see CORS_ORIGINS above.
|
|
767
|
+
// No author-specific domains are hardcoded; production deployments must set
|
|
768
|
+
// COMMHUB_CORS_ORIGINS explicitly. See docs/concepts/security.md "CORS 配置".
|
|
769
|
+
const allowed = CORS_ORIGINS.includes(origin) ? origin : "";
|
|
770
|
+
return {
|
|
771
|
+
"Access-Control-Allow-Origin": allowed,
|
|
772
|
+
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
|
773
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
774
|
+
"Access-Control-Max-Age": "86400",
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function withCors(req: Request, res: Response): Response {
|
|
779
|
+
const headers = corsHeaders(req);
|
|
780
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
781
|
+
res.headers.set(k, v);
|
|
782
|
+
}
|
|
783
|
+
return res;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* #426 — append `charset=utf-8` to text-y Content-Type headers that
|
|
788
|
+
* omit it. Legacy Windows clients (PowerShell 5.1 Invoke-RestMethod /
|
|
789
|
+
* Invoke-WebRequest) default to ISO-8859-1 when the charset is
|
|
790
|
+
* unspecified, which double-encodes our clean UTF-8 payloads on the
|
|
791
|
+
* way in.
|
|
792
|
+
*
|
|
793
|
+
* Applied to Responses whose Content-Type header we can't influence at
|
|
794
|
+
* construction time — notably the MCP SDK transport, which builds its
|
|
795
|
+
* own `text/event-stream` / `application/json` reply. Response headers
|
|
796
|
+
* from `fetch` are mutable, so this rewrites in place rather than
|
|
797
|
+
* reconstructing the Response (which would break streamed bodies).
|
|
798
|
+
*
|
|
799
|
+
* No-ops when: charset is already set, the type is binary
|
|
800
|
+
* (application/octet-stream, image/*, video/*, application/pdf, etc.),
|
|
801
|
+
* or the header is missing entirely.
|
|
802
|
+
*/
|
|
803
|
+
function withUtf8CharsetContentType(res: Response): Response {
|
|
804
|
+
const ct = res.headers.get("content-type");
|
|
805
|
+
if (!ct) return res;
|
|
806
|
+
if (/;\s*charset=/i.test(ct)) return res;
|
|
807
|
+
const trimmed = ct.trim();
|
|
808
|
+
// Text-y types that legacy clients decode via charset. Everything
|
|
809
|
+
// else (binary, opaque) is left alone.
|
|
810
|
+
if (
|
|
811
|
+
trimmed === "application/json" ||
|
|
812
|
+
trimmed.startsWith("text/") ||
|
|
813
|
+
trimmed.startsWith("application/json;") ||
|
|
814
|
+
trimmed === "application/ld+json" ||
|
|
815
|
+
trimmed === "application/xml"
|
|
816
|
+
) {
|
|
817
|
+
res.headers.set("content-type", `${trimmed}; charset=utf-8`);
|
|
818
|
+
}
|
|
819
|
+
return res;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// ── WebSocket tmux sessions ────────────────────────
|
|
823
|
+
const wsTmuxIntervals = new Map<object, ReturnType<typeof setInterval>>();
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
// ── Task expiration patrol (every 5 minutes) ──
|
|
827
|
+
// Registered in startHub() (#476): this WRITES the DB, so it must never
|
|
828
|
+
// run as an import side effect — a tool/test importing this module with
|
|
829
|
+
// COMMHUB_DB unset would patrol the production database.
|
|
830
|
+
export function patrolExpiredTasks(): void {
|
|
831
|
+
try {
|
|
832
|
+
const expired = db.transaction(() => {
|
|
833
|
+
const due = db.all<{ task_id: string; network_id: string | null }>(
|
|
834
|
+
`SELECT task_id, network_id FROM tasks
|
|
835
|
+
WHERE expires_at IS NOT NULL AND expires_at < datetime('now')
|
|
836
|
+
AND status IN ('created', 'delivered')`,
|
|
837
|
+
);
|
|
838
|
+
const changed: Array<{ task_id: string; network_id: string | null }> = [];
|
|
839
|
+
for (const task of due) {
|
|
840
|
+
const result = db.run(
|
|
841
|
+
`UPDATE tasks SET status = 'expired', completed_at = datetime('now')
|
|
842
|
+
WHERE task_id = ?1 AND status IN ('created', 'delivered')`,
|
|
843
|
+
[task.task_id],
|
|
844
|
+
);
|
|
845
|
+
// SQLite may include the AFTER UPDATE terminal-journal trigger write
|
|
846
|
+
// in this count. The WHERE clause still targets one exact guarded
|
|
847
|
+
// task, so any positive count proves the task transition happened.
|
|
848
|
+
if (result.changes < 1) continue;
|
|
849
|
+
// #2022 — retire the inbox rows in the same transaction. Nothing
|
|
850
|
+
// else acks them: an expired task can never be acked by its target,
|
|
851
|
+
// and retention only sweeps acked=1 rows, so leaving them unacked
|
|
852
|
+
// kept them in every "unacked inbox" consumer (the stop/delete gate,
|
|
853
|
+
// the unread badge) forever. COALESCE(task_id, id) mirrors
|
|
854
|
+
// cancel_task — legacy rows carry the logical task id in `id`.
|
|
855
|
+
db.run(
|
|
856
|
+
`UPDATE inbox SET acked = 1 WHERE COALESCE(task_id, id) = ?1 AND acked = 0`,
|
|
857
|
+
[task.task_id],
|
|
858
|
+
);
|
|
859
|
+
syncScheduledRunForTask(task.task_id, task.network_id);
|
|
860
|
+
changed.push(task);
|
|
861
|
+
}
|
|
862
|
+
return changed;
|
|
863
|
+
});
|
|
864
|
+
if (expired.length > 0) {
|
|
865
|
+
console.log(`[patrol] expired ${expired.length} stale task(s)`);
|
|
866
|
+
for (const task of expired) logTaskEvent(task.task_id, null, "expired", "patrol");
|
|
867
|
+
}
|
|
868
|
+
} catch {}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function patrolDeliveredStaleTasks(): void {
|
|
872
|
+
try {
|
|
873
|
+
const result = recordDeliveredStaleEvents();
|
|
874
|
+
if (result.inserted > 0) {
|
|
875
|
+
console.warn(`[patrol] recorded ${result.inserted} delivered-stale task warning(s)`);
|
|
876
|
+
}
|
|
877
|
+
} catch (error: any) {
|
|
878
|
+
console.error(`[patrol] delivered-stale watcher failed: ${error?.message || error}`);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// #434 — test-safety seam, same signature as PR #438 so the two branches
|
|
883
|
+
// merge cleanly. Wraps the sole Bun.serve config in a factory so
|
|
884
|
+
// integration tests can request an OS-assigned ephemeral port
|
|
885
|
+
// (`bootServer({ port: 0 })`) and read the actual bound port back from
|
|
886
|
+
// the returned server. In an aggregate `bun test` run only the FIRST test
|
|
887
|
+
// file's import boots the default server (module cache) — later files'
|
|
888
|
+
// PORT env is ignored, so a suite that hard-codes its own port gets
|
|
889
|
+
// ConnectionRefused on every fetch while still "running" (审查修复 per
|
|
890
|
+
// 通信龙 #461 review, finding 2). Suites boot a private instance via this
|
|
891
|
+
// seam instead. #438 additionally moves the default boot below under
|
|
892
|
+
// `import.meta.main`; until that lands, import keeps booting (pre-#434
|
|
893
|
+
// behavior) so the not-yet-migrated suites stay green.
|
|
894
|
+
//
|
|
895
|
+
// Note: `opts.port ?? PORT` uses nullish-coalescing on purpose — `||`
|
|
896
|
+
// would swallow a legitimate `0`. Same for hostname.
|
|
897
|
+
export function bootServer(opts?: { port?: number; hostname?: string }): ReturnType<typeof Bun.serve> {
|
|
898
|
+
return Bun.serve({
|
|
899
|
+
port: opts?.port ?? PORT,
|
|
900
|
+
hostname: opts?.hostname ?? HOST,
|
|
901
|
+
idleTimeout: 255, // max value: keep SSE connections alive (seconds)
|
|
902
|
+
// #221 — defense-in-depth cap on the raw request body. The /api/upload
|
|
903
|
+
// handler also pre-checks Content-Length and post-checks parsed size
|
|
904
|
+
// against the documented 12 MiB cap (MAX_UPLOAD_BYTES); this knob
|
|
905
|
+
// simply guarantees Bun's own buffer won't grow past 13 MiB on any
|
|
906
|
+
// route. Default Bun ceiling is 128 MiB which is too permissive for
|
|
907
|
+
// a hub now exposed to the public internet.
|
|
908
|
+
maxRequestBodySize: MAX_REQUEST_CONTENT_LENGTH,
|
|
909
|
+
// #695 —— fetch 处理器抛出时不要让 Bun 回默认的非 JSON 500;带上异常 message,
|
|
910
|
+
// /mcp 客户端按 JSON-RPC 读 error.message,REST 客户端按 { ok:false, message } 读。
|
|
911
|
+
error: (err) => buildServeErrorResponse(err),
|
|
912
|
+
|
|
913
|
+
async fetch(req, server) {
|
|
914
|
+
// 2026-09-16 —— 所有 JSON/文本响应在客户端要求时 gzip(见 http-gzip.ts);SSE/流式/二进制原样。
|
|
915
|
+
// 2026-09-30 —— 每个请求的路由形状 + 耗时进内存环(route-timing.ts),GET /api/stats/routes 看谁在吃 CPU。
|
|
916
|
+
return recordRouteTiming(req, performance.now(), await maybeGzipResponse(req, explainExpiredSession(req, await (async (): Promise<Response | undefined> => {
|
|
917
|
+
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
918
|
+
|
|
919
|
+
// ── CORS preflight ──
|
|
920
|
+
if (req.method === "OPTIONS") {
|
|
921
|
+
return new Response(null, { status: 204, headers: corsHeaders(req) });
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// ── WebSocket: tmux terminal ──
|
|
925
|
+
const wsMatch = url.pathname.match(/^\/ws\/tmux\/([a-zA-Z0-9_-]+)$/);
|
|
926
|
+
if (wsMatch) {
|
|
927
|
+
const tmuxErr = requireTmuxAccess(req, server);
|
|
928
|
+
if (tmuxErr) return withCors(req, tmuxErr);
|
|
929
|
+
if (server.upgrade(req, { data: { tmuxName: wsMatch[1] } } as any)) return;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// ── MCP Streamable HTTP endpoint ──
|
|
933
|
+
if (url.pathname === "/mcp") {
|
|
934
|
+
const authErr = requireAuth(req);
|
|
935
|
+
if (authErr) return withCors(req, authErr);
|
|
936
|
+
const clientIP = getClientIP(req, server);
|
|
937
|
+
// V3: resolve token → enforce network_id in all MCP tools.
|
|
938
|
+
// utok_ (user token, not network-bound) is allowed — the tool layer
|
|
939
|
+
// scopes to the user's accessible networks. Without this Dashboard
|
|
940
|
+
// (which logs in as a user) cannot call send_task.
|
|
941
|
+
const token = requestToken(req);
|
|
942
|
+
const authCtx = resolveRequestAuth(req);
|
|
943
|
+
const enforceNetId = authCtx?.networkId || null;
|
|
944
|
+
// Derive the calling alias from the token name (e.g., 'node:视频审查')
|
|
945
|
+
// so peer agents see the real sender instead of 'hub' on send_task.
|
|
946
|
+
const callerAlias = authCtx?.tokenName?.startsWith("node:")
|
|
947
|
+
? authCtx.tokenName.slice("node:".length)
|
|
948
|
+
: (authCtx?.username || null);
|
|
949
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
950
|
+
sessionIdGenerator: undefined,
|
|
951
|
+
});
|
|
952
|
+
// 2026-10-01 —— /api/stats/routes 里 POST /mcp 是最大的一项(生产 15 req/s),但看不出是哪个工具。
|
|
953
|
+
// 请求体我们自己读一次、解析一次,只取 method / params.name 做路由标签(mcpRouteLabel),
|
|
954
|
+
// 解析结果作为 parsedBody 交给 transport,不重复解析。没有 Content-Length 或超过 MCP_LABEL_MAX_BYTES
|
|
955
|
+
// 的请求体不碰,由 transport 照旧自己读。不是合法 JSON 时不传 parsedBody:transport 读已消费的
|
|
956
|
+
// body 失败,落进它自己的 catch,回的仍是原来那条 -32700「Parse error: Invalid JSON」(测试钉住)。
|
|
957
|
+
let mcpParsedBody: unknown;
|
|
958
|
+
if (req.method === "POST") {
|
|
959
|
+
const declared = Number(req.headers.get("content-length"));
|
|
960
|
+
if (req.headers.has("content-length") && Number.isFinite(declared) && declared <= MCP_LABEL_MAX_BYTES) {
|
|
961
|
+
try { mcpParsedBody = JSON.parse(await req.text()); } catch { mcpParsedBody = undefined; }
|
|
962
|
+
}
|
|
963
|
+
labelRoute(req, mcpParsedBody === undefined ? "?" : mcpRouteLabel(mcpParsedBody));
|
|
964
|
+
}
|
|
965
|
+
const mcpServer = createServer(clientIP, enforceNetId, authCtx?.userId || null, callerAlias, !!token?.startsWith("ntok_"), authCtx?.tokenId || null);
|
|
966
|
+
await mcpServer.connect(transport);
|
|
967
|
+
const response = await transport.handleRequest(req, mcpParsedBody === undefined ? undefined : { parsedBody: mcpParsedBody });
|
|
968
|
+
// Disconnect after response to prevent McpServer leak
|
|
969
|
+
setImmediate(() => mcpServer.close().catch(() => {}));
|
|
970
|
+
// #426: the MCP SDK's WebStandardStreamableHTTPServerTransport builds
|
|
971
|
+
// its own Response with `Content-Type: text/event-stream` (or
|
|
972
|
+
// `application/json` for JSON-mode) but WITHOUT charset. Legacy
|
|
973
|
+
// Windows clients (PowerShell 5.1 Invoke-WebRequest / RestMethod)
|
|
974
|
+
// default to ISO-8859-1 when the charset is unspecified and 双重-
|
|
975
|
+
// 编码 our clean UTF-8 payload on the way in — the reported
|
|
976
|
+
// mojibake is client-side, but the header is what tells them to
|
|
977
|
+
// guess. We can't tell the SDK to add it, so wrap the response and
|
|
978
|
+
// rewrite the header. Streamed bodies (SSE) pass through untouched
|
|
979
|
+
// because Response takes the original ReadableStream verbatim.
|
|
980
|
+
return withUtf8CharsetContentType(response);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// ── #461 SSE network observer stream ──
|
|
984
|
+
// GET /events/network/:network_id → 网络级摘要事件(new_task / new_reply
|
|
985
|
+
// routing metadata only, no content)。Dashboard 用它实时感知第三方流量,
|
|
986
|
+
// 退役 15s 软轮询。MUST be matched before the generic /events/(.+) route
|
|
987
|
+
// below, which would otherwise swallow the path as a session name.
|
|
988
|
+
//
|
|
989
|
+
// Auth mirrors /events/:session's three paths:
|
|
990
|
+
// 1. legacy AUTH_TOKEN (master) → any network
|
|
991
|
+
// 2/3. ntok_ / utok_ → must CURRENTLY be a member of the requested
|
|
992
|
+
// network. Membership is checked unconditionally — there is NO
|
|
993
|
+
// token-bound-network shortcut. removeNetworkMember deletes the
|
|
994
|
+
// membership row but does NOT revoke the user's ntok, so the
|
|
995
|
+
// network_members lookup IS the revocation mechanism: an ntok
|
|
996
|
+
// bound to this network whose owner was removed must lose the
|
|
997
|
+
// stream (审查修复 per 通信龙 #461 review, finding 1).
|
|
998
|
+
const netEventsMatch = url.pathname.match(/^\/events\/network\/(.+)$/);
|
|
999
|
+
if (netEventsMatch && req.method === "GET") {
|
|
1000
|
+
const authErr = requireAuth(req);
|
|
1001
|
+
if (authErr) return authErr;
|
|
1002
|
+
const observedNetId = decodeURIComponent(netEventsMatch[1]);
|
|
1003
|
+
const authCtx = resolveRequestAuth(req);
|
|
1004
|
+
if (!authCtx && isLegacyAuthToken(req)) {
|
|
1005
|
+
return createNetworkObserverStream(observedNetId);
|
|
1006
|
+
}
|
|
1007
|
+
if (!authCtx) {
|
|
1008
|
+
return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 403 }));
|
|
1009
|
+
}
|
|
1010
|
+
const observerRole = getUserNetworkRole(authCtx.userId, observedNetId);
|
|
1011
|
+
if (!observerRole) {
|
|
1012
|
+
return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
|
|
1013
|
+
}
|
|
1014
|
+
// 多用户 Agent 权限:受限成员只收到自己是一端的路由事件(自己发的 / 发给自己的),
|
|
1015
|
+
// 看不到网络里别人和各个 Agent 之间的流量。
|
|
1016
|
+
if (isAgentRestricted(authCtx.userId, observedNetId)) {
|
|
1017
|
+
const me = authCtx.username;
|
|
1018
|
+
return createNetworkObserverStream(observedNetId, {
|
|
1019
|
+
subscriberUserId: authCtx.userId,
|
|
1020
|
+
eventFilter: (event) => event.from === me || event.to === me,
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
return createNetworkObserverStream(observedNetId, { subscriberUserId: authCtx.userId });
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// GET /events/users/me?network_id=... → Desktop/user-client stream.
|
|
1027
|
+
// This is intentionally keyed by authenticated user_id, not alias.
|
|
1028
|
+
// Desktop must use its utok_; ntok_ is a node credential and cannot
|
|
1029
|
+
// subscribe to user channels.
|
|
1030
|
+
if (url.pathname === "/events/users/me" && req.method === "GET") {
|
|
1031
|
+
const authErr = requireAuth(req);
|
|
1032
|
+
if (authErr) return authErr;
|
|
1033
|
+
const token = requestToken(req);
|
|
1034
|
+
if (!token?.startsWith("utok_")) {
|
|
1035
|
+
return withCors(req, Response.json({ ok: false, error: "user_token_required" }, { status: 403 }));
|
|
1036
|
+
}
|
|
1037
|
+
const authCtx = resolveRequestAuth(req);
|
|
1038
|
+
if (!authCtx) {
|
|
1039
|
+
return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 403 }));
|
|
1040
|
+
}
|
|
1041
|
+
const networkId = url.searchParams.get("network_id");
|
|
1042
|
+
if (!networkId) {
|
|
1043
|
+
return withCors(req, Response.json({ ok: false, error: "network_id required" }, { status: 400 }));
|
|
1044
|
+
}
|
|
1045
|
+
const role = getUserNetworkRole(authCtx.userId, networkId);
|
|
1046
|
+
if (!role) {
|
|
1047
|
+
return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
|
|
1048
|
+
}
|
|
1049
|
+
return createUserEventStream(networkId, authCtx.userId);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// ── SSE push: Agent 实时接收任务推送 ──
|
|
1053
|
+
// GET /events/知识哥 → 保持长连接,send_task 时秒推
|
|
1054
|
+
const eventsMatch = url.pathname.match(/^\/events\/(.+)$/);
|
|
1055
|
+
if (eventsMatch && req.method === "GET") {
|
|
1056
|
+
const authErr = requireAuth(req);
|
|
1057
|
+
if (authErr) return authErr;
|
|
1058
|
+
const sessionName = decodeURIComponent(eventsMatch[1]);
|
|
1059
|
+
const token = requestToken(req);
|
|
1060
|
+
const authCtx = resolveRequestAuth(req);
|
|
1061
|
+
const scopedNetId = authCtx?.networkId || url.searchParams.get("network_id");
|
|
1062
|
+
|
|
1063
|
+
// ── Path 1: legacy AUTH_TOKEN (master token) — unchanged ──
|
|
1064
|
+
if (!authCtx && isLegacyAuthToken(req)) {
|
|
1065
|
+
if (scopedNetId) {
|
|
1066
|
+
const session = db.get<any>(
|
|
1067
|
+
"SELECT 1 FROM sessions WHERE alias = ?1 AND network_id = ?2",
|
|
1068
|
+
sessionName, scopedNetId
|
|
1069
|
+
);
|
|
1070
|
+
if (!session) return withCors(req, Response.json({ ok: false, error: "session not in requested network" }, { status: 403 }));
|
|
1071
|
+
}
|
|
1072
|
+
return createSSEStream(sessionName, scopedNetId);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// ── Path 2: ntok_ (network-bound agent token) — pre-#247 behavior preserved ──
|
|
1076
|
+
if (token?.startsWith("ntok_")) {
|
|
1077
|
+
if (!authCtx || !scopedNetId) {
|
|
1078
|
+
return withCors(req, Response.json({ ok: false, error: "network-scoped token required for SSE" }, { status: 403 }));
|
|
1079
|
+
}
|
|
1080
|
+
const role = getUserNetworkRole(authCtx.userId, scopedNetId);
|
|
1081
|
+
if (!role) return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
|
|
1082
|
+
const session = db.get<any>(
|
|
1083
|
+
"SELECT 1 FROM sessions WHERE alias = ?1 AND network_id = ?2",
|
|
1084
|
+
sessionName, scopedNetId
|
|
1085
|
+
);
|
|
1086
|
+
if (!session && authCtx.networkId !== scopedNetId) {
|
|
1087
|
+
return withCors(req, Response.json({ ok: false, error: "session not in requested network" }, { status: 403 }));
|
|
1088
|
+
}
|
|
1089
|
+
return createSSEStream(sessionName, scopedNetId);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// ── Path 3 (#247): utok_ (user token, dashboard SSE path) — 4 gates ──
|
|
1093
|
+
//
|
|
1094
|
+
// Pre-#247 the only V3 path required `ntok_` and rejected `utok_` outright,
|
|
1095
|
+
// which locked the dashboard out of SSE (the dashboard proxy authenticates
|
|
1096
|
+
// with the user's `utok_`). That broke real-time chat updates — replies
|
|
1097
|
+
// were persisted but never live-pushed to the UI.
|
|
1098
|
+
//
|
|
1099
|
+
// Auth-scoping rules (all four must pass — see #247 design):
|
|
1100
|
+
// gate 1: token resolves to a valid user context (authCtx non-null)
|
|
1101
|
+
// gate 2: network scope is explicit — `utok_` has no implicit network,
|
|
1102
|
+
// so the caller must pass `?network_id=<id>`
|
|
1103
|
+
// gate 3: user is a member of that network (any role: owner / admin / member)
|
|
1104
|
+
// gate 4: channel-ownership — one of:
|
|
1105
|
+
// (a) sessionName === own username (the dashboard's default channel —
|
|
1106
|
+
// `send_reply` pushes to the original sender's alias which equals
|
|
1107
|
+
// the dashboard user's username; NO `sessions` row required for
|
|
1108
|
+
// this branch since the dashboard user is not a registered agent)
|
|
1109
|
+
// (b) sessionName is an existing agent alias in `scopedNetId`
|
|
1110
|
+
// (lets members monitor agents within their network)
|
|
1111
|
+
//
|
|
1112
|
+
// Security: rule 4(a) uses strict equality, so a `utok_` user cannot subscribe
|
|
1113
|
+
// to another user's username channel (no cross-user eavesdrop). Rule 4(b) is
|
|
1114
|
+
// scoped to `scopedNetId` + gate 3 membership, so cross-network eavesdrop is
|
|
1115
|
+
// blocked too. There is intentionally NO org-admin bypass — even users with
|
|
1116
|
+
// global `admin` role must be a member of the network they want to listen on.
|
|
1117
|
+
if (!authCtx) {
|
|
1118
|
+
return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 403 }));
|
|
1119
|
+
}
|
|
1120
|
+
if (!scopedNetId) {
|
|
1121
|
+
return withCors(req, Response.json({
|
|
1122
|
+
ok: false,
|
|
1123
|
+
error: "network_id required (utok has no implicit network; pass ?network_id=<id>)"
|
|
1124
|
+
}, { status: 403 }));
|
|
1125
|
+
}
|
|
1126
|
+
const utokRole = getUserNetworkRole(authCtx.userId, scopedNetId);
|
|
1127
|
+
if (!utokRole) {
|
|
1128
|
+
return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
|
|
1129
|
+
}
|
|
1130
|
+
if (sessionName === authCtx.username) {
|
|
1131
|
+
// 多用户 Agent 权限:受限成员的用户名撞上本网络某个 Agent 的 alias 时,这个频道其实是那个
|
|
1132
|
+
// Agent 的任务流 —— 不给订阅(入网时已拦撞名,这里兜住 Agent 之后才注册同名 alias 的情况)。
|
|
1133
|
+
if (isAgentRestricted(authCtx.userId, scopedNetId) && usernameIsAgentAlias(scopedNetId, authCtx.username)) {
|
|
1134
|
+
return withCors(req, Response.json({
|
|
1135
|
+
ok: false,
|
|
1136
|
+
error: "channel not allowed: must be your username or an existing agent in your network"
|
|
1137
|
+
}, { status: 403 }));
|
|
1138
|
+
}
|
|
1139
|
+
return createSSEStream(sessionName, scopedNetId);
|
|
1140
|
+
}
|
|
1141
|
+
// 多用户 Agent 权限:Agent 频道推的是**所有人**发给这个 Agent 的任务原文,
|
|
1142
|
+
// 受限成员即使被授权了这个 Agent 也不能订阅(他只该看到自己与它的往来,走自己的用户名频道)。
|
|
1143
|
+
// 与「频道不存在」同一个 403 文案,不给 alias 探测留差异。
|
|
1144
|
+
if (isAgentRestricted(authCtx.userId, scopedNetId)) {
|
|
1145
|
+
return withCors(req, Response.json({
|
|
1146
|
+
ok: false,
|
|
1147
|
+
error: "channel not allowed: must be your username or an existing agent in your network"
|
|
1148
|
+
}, { status: 403 }));
|
|
1149
|
+
}
|
|
1150
|
+
const utokSession = db.get<any>(
|
|
1151
|
+
"SELECT 1 FROM sessions WHERE alias = ?1 AND network_id = ?2",
|
|
1152
|
+
sessionName, scopedNetId
|
|
1153
|
+
);
|
|
1154
|
+
if (!utokSession) {
|
|
1155
|
+
return withCors(req, Response.json({
|
|
1156
|
+
ok: false,
|
|
1157
|
+
error: "channel not allowed: must be your username or an existing agent in your network"
|
|
1158
|
+
}, { status: 403 }));
|
|
1159
|
+
}
|
|
1160
|
+
return createSSEStream(sessionName, scopedNetId);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// ── V3: License endpoints ──
|
|
1164
|
+
if (url.pathname === "/api/license" && req.method === "GET") {
|
|
1165
|
+
const license = db.get<any>(
|
|
1166
|
+
"SELECT type, expires_at, max_agents, max_networks, max_tasks_day FROM licenses ORDER BY created_at LIMIT 1",
|
|
1167
|
+
);
|
|
1168
|
+
if (!license) return withCors(req, Response.json({ ok: true, status: "no_license" }));
|
|
1169
|
+
const now = new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
1170
|
+
const expired = license.expires_at && license.expires_at < now;
|
|
1171
|
+
// #1650 — licenses.expires_at 是 TEXT(datetime('now') 家族),UTC 但不带时区
|
|
1172
|
+
// 标记;`new Date()` 会按本机时区解析,边界上差一天。parse 失败时返回 null
|
|
1173
|
+
// 而不是 NaN —— JSON.stringify(NaN) 本来也序列化成 null,线上字节不变。
|
|
1174
|
+
const expiresMs = parseHubTimestamp(license.expires_at);
|
|
1175
|
+
const daysLeft = expiresMs === null
|
|
1176
|
+
? null
|
|
1177
|
+
: Math.max(0, Math.ceil((expiresMs - Date.now()) / 86400000));
|
|
1178
|
+
return withCors(req, Response.json({
|
|
1179
|
+
ok: true,
|
|
1180
|
+
license: { type: license.type, expires_at: license.expires_at, days_left: daysLeft, expired },
|
|
1181
|
+
limits: { max_agents: license.max_agents, max_networks: license.max_networks, max_tasks_day: license.max_tasks_day },
|
|
1182
|
+
}));
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
if (url.pathname === "/api/license/activate" && req.method === "POST") {
|
|
1186
|
+
try {
|
|
1187
|
+
const body = await req.json() as any;
|
|
1188
|
+
const key = body.key;
|
|
1189
|
+
if (!key) return withCors(req, Response.json({ ok: false, error: "key required" }, { status: 400 }));
|
|
1190
|
+
// For now: accept any key starting with "anet-" as valid pro license
|
|
1191
|
+
if (!key.startsWith("anet-") || key.length < 16) {
|
|
1192
|
+
return withCors(req, Response.json({ ok: false, error: "invalid license key" }, { status: 400 }));
|
|
1193
|
+
}
|
|
1194
|
+
// Upgrade existing license or create new
|
|
1195
|
+
db.run("DELETE FROM licenses");
|
|
1196
|
+
const licId = `lic_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
|
1197
|
+
db.run(
|
|
1198
|
+
"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'))",
|
|
1199
|
+
[licId, key]
|
|
1200
|
+
);
|
|
1201
|
+
return withCors(req, Response.json({ ok: true, type: "pro", expires_in_days: 365 }));
|
|
1202
|
+
} catch (e: any) {
|
|
1203
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
// ── V3: Auth endpoints (public) ──
|
|
1208
|
+
if (url.pathname === "/api/auth/register" && req.method === "POST") {
|
|
1209
|
+
const clientIP = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
|
|
1210
|
+
if (!checkRateLimit(clientIP, 30)) {
|
|
1211
|
+
return withCors(req, Response.json({ ok: false, error: "too many requests, try again later" }, { status: 429 }));
|
|
1212
|
+
}
|
|
1213
|
+
try {
|
|
1214
|
+
const body = await req.json() as any;
|
|
1215
|
+
const result = register(body.username, body.password, body.email, body.display_name, { client: { label: body.client_label, userAgent: req.headers.get("user-agent") } });
|
|
1216
|
+
if (result.ok) logAudit(result.user!.user_id, body.username, "register", "user", result.user!.user_id);
|
|
1217
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
1218
|
+
} catch (e: any) {
|
|
1219
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
if (url.pathname === "/api/auth/login" && req.method === "POST") {
|
|
1224
|
+
const clientIP = getLoginClientIp(req);
|
|
1225
|
+
const ipRate = sharedLoginIpRateLimiter.check(clientIP || "unknown");
|
|
1226
|
+
if (!ipRate.allowed) {
|
|
1227
|
+
logAudit(null, null, "login_rate_limited", "auth", undefined, clientIP);
|
|
1228
|
+
return withCors(req, Response.json(
|
|
1229
|
+
{ ok: false, error: "rate_limited", message: "Too many login attempts. Try again later.", retry_after_ms: ipRate.retryAfterMs },
|
|
1230
|
+
{ status: 429, headers: { "Retry-After": String(Math.ceil((ipRate.retryAfterMs ?? 1000) / 1000)) } }
|
|
1231
|
+
));
|
|
1232
|
+
}
|
|
1233
|
+
try {
|
|
1234
|
+
const body = await req.json() as any;
|
|
1235
|
+
const lock = sharedLoginFailureLockout.check(body.username);
|
|
1236
|
+
if (lock.locked) {
|
|
1237
|
+
logAudit(null, body.username, "login_locked", "auth", undefined, `retry_after_ms=${lock.retryAfterMs ?? 0}`);
|
|
1238
|
+
return withCors(req, Response.json(
|
|
1239
|
+
{ ok: false, error: "login_locked", message: "Too many failed login attempts. Try again later.", retry_after_ms: lock.retryAfterMs },
|
|
1240
|
+
{ status: 429, headers: { "Retry-After": String(Math.ceil((lock.retryAfterMs ?? 1000) / 1000)) } }
|
|
1241
|
+
));
|
|
1242
|
+
}
|
|
1243
|
+
const result = login(body.username, body.password, { label: body.client_label, userAgent: req.headers.get("user-agent") });
|
|
1244
|
+
if (result.ok) {
|
|
1245
|
+
sharedLoginFailureLockout.recordSuccess(body.username);
|
|
1246
|
+
logAudit(result.user!.user_id, body.username, "login", "user", result.user!.user_id);
|
|
1247
|
+
} else {
|
|
1248
|
+
const failure = sharedLoginFailureLockout.recordFailure(body.username);
|
|
1249
|
+
if (failure.locked) {
|
|
1250
|
+
const safeUsername = String(body.username ?? "").replace(/[\r\n\t]/g, " ").slice(0, 80);
|
|
1251
|
+
console.warn(`[auth] login locked for username=${safeUsername} failures=${failure.failures} retry_after_ms=${failure.lockMs}`);
|
|
1252
|
+
}
|
|
1253
|
+
logAudit(null, body.username, "login_failed", "user", undefined, "invalid credentials");
|
|
1254
|
+
}
|
|
1255
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 401 }));
|
|
1256
|
+
} catch (e: any) {
|
|
1257
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
if (url.pathname === "/api/auth/me" && req.method === "GET") {
|
|
1262
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
1263
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
1264
|
+
const resolved = resolveToken(token);
|
|
1265
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1266
|
+
// 节点令牌:user 仍是签发它的用户(旧 agent-node 靠它打日志),但只列它绑定的那个网络,
|
|
1267
|
+
// 并用 credential 说清楚这是一个节点身份 —— user.role 不是这个调用者的权限。
|
|
1268
|
+
const allNetworks = getUserAllNetworks(resolved.user.user_id);
|
|
1269
|
+
const networks = resolved.networkId ? allNetworks.filter((n: any) => n.network_id === resolved.networkId) : allNetworks;
|
|
1270
|
+
const credential = resolved.networkId
|
|
1271
|
+
? { kind: "node", network_id: resolved.networkId, node_alias: resolved.tokenName?.startsWith("node:") ? resolved.tokenName.slice(5) : null, acts_as_owner: false }
|
|
1272
|
+
: { kind: "user" };
|
|
1273
|
+
return withCors(req, Response.json({ ok: true, user: resolved.user, networks, current_network: resolved.networkId, credential }));
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
if (url.pathname === "/api/auth/me" && req.method === "PUT") {
|
|
1277
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
1278
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
1279
|
+
const resolved = resolveToken(token);
|
|
1280
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1281
|
+
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1282
|
+
try {
|
|
1283
|
+
const body = await req.json() as any;
|
|
1284
|
+
const updates: string[] = [];
|
|
1285
|
+
const params: any[] = [];
|
|
1286
|
+
if (body.display_name) { updates.push(`display_name = ?${params.length + 1}`); params.push(body.display_name); }
|
|
1287
|
+
if (body.email) { updates.push(`email = ?${params.length + 1}`); params.push(body.email); }
|
|
1288
|
+
if (updates.length > 0) {
|
|
1289
|
+
updates.push(`updated_at = datetime('now')`);
|
|
1290
|
+
params.push(resolved.user.user_id);
|
|
1291
|
+
db.run(`UPDATE users SET ${updates.join(", ")} WHERE user_id = ?${params.length}`, params);
|
|
1292
|
+
}
|
|
1293
|
+
// Re-fetch
|
|
1294
|
+
const user = db.get<any>("SELECT user_id, username, display_name, email, role FROM users WHERE user_id = ?1", resolved.user.user_id);
|
|
1295
|
+
return withCors(req, Response.json({ ok: true, user }));
|
|
1296
|
+
} catch (e: any) {
|
|
1297
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
if (url.pathname === "/api/auth/password" && req.method === "POST") {
|
|
1302
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
1303
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
1304
|
+
const resolved = resolveToken(token);
|
|
1305
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1306
|
+
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1307
|
+
try {
|
|
1308
|
+
const body = await req.json() as any;
|
|
1309
|
+
const result = changePassword(resolved.user.user_id, body.old_password, body.new_password, resolved.tokenId);
|
|
1310
|
+
if (result.ok) {
|
|
1311
|
+
const issued = issueUserToken(resolved.user.user_id, "password-change");
|
|
1312
|
+
if (resolved.tokenId) revokeToken(resolved.user.user_id, resolved.tokenId);
|
|
1313
|
+
logAudit(resolved.user.user_id, resolved.user.username, "password_changed", "user", resolved.user.user_id);
|
|
1314
|
+
return withCors(req, Response.json({ ...result, token: issued.token, token_id: issued.token_id }));
|
|
1315
|
+
}
|
|
1316
|
+
return withCors(req, Response.json(result, { status: 400 }));
|
|
1317
|
+
} catch (e: any) {
|
|
1318
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
// ── V3.13: Create network token for a node ──
|
|
1323
|
+
if (url.pathname === "/api/auth/node-token" && req.method === "POST") {
|
|
1324
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
1325
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1326
|
+
const resolved = resolveToken(token);
|
|
1327
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1328
|
+
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1329
|
+
try {
|
|
1330
|
+
const body = await req.json() as any;
|
|
1331
|
+
if (!body.network_id || !body.node_name) return withCors(req, Response.json({ ok: false, error: "network_id and node_name required" }, { status: 400 }));
|
|
1332
|
+
const result = createNetworkTokenForNode(resolved.user.user_id, body.network_id, body.node_name, body.node_id);
|
|
1333
|
+
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "node_token_created", "network", body.network_id, body.node_name);
|
|
1334
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
1335
|
+
} catch (e: any) {
|
|
1336
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
// ── #84: node rename 2PC — Server surface (RFC-010 §4) ──
|
|
1341
|
+
// The CLI orchestrates the 2PC; these endpoints are the Server steps:
|
|
1342
|
+
// prepare = PHASE 1 P3, commit = PHASE 2 C1, abort = PHASE 1 rollback.
|
|
1343
|
+
if (url.pathname === "/api/node-rename/prepare" && req.method === "POST") {
|
|
1344
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
1345
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1346
|
+
const resolved = resolveToken(token);
|
|
1347
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1348
|
+
try {
|
|
1349
|
+
const body = await req.json() as any;
|
|
1350
|
+
if (!body.network_id || !body.old_alias || !body.new_alias) {
|
|
1351
|
+
return withCors(req, Response.json({ ok: false, error: "network_id, old_alias, new_alias required" }, { status: 400 }));
|
|
1352
|
+
}
|
|
1353
|
+
if (!nodeMayRename(resolved, body.network_id, body.old_alias)) return userTokenRequired(req);
|
|
1354
|
+
const result = prepareRename(resolved.user.user_id, body.network_id, body.old_alias, body.new_alias);
|
|
1355
|
+
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "node_rename_prepared", "node", body.old_alias, body.new_alias);
|
|
1356
|
+
return withCors(req, Response.json(result, { status: result.ok || result.code === "node_local_only" ? 200 : 400 }));
|
|
1357
|
+
} catch (e: any) {
|
|
1358
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
if (url.pathname === "/api/node-rename/commit" && req.method === "POST") {
|
|
1363
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
1364
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1365
|
+
const resolved = resolveToken(token);
|
|
1366
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1367
|
+
try {
|
|
1368
|
+
const body = await req.json() as any;
|
|
1369
|
+
if (!body.txn_id) return withCors(req, Response.json({ ok: false, error: "txn_id required" }, { status: 400 }));
|
|
1370
|
+
if (!nodeMayRenameTxn(resolved, body.txn_id)) return userTokenRequired(req);
|
|
1371
|
+
const result = commitRename(resolved.user.user_id, body.txn_id);
|
|
1372
|
+
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "node_rename_committed", "node", body.txn_id);
|
|
1373
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
1374
|
+
} catch (e: any) {
|
|
1375
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
if (url.pathname === "/api/node-rename/abort" && req.method === "POST") {
|
|
1380
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
1381
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1382
|
+
const resolved = resolveToken(token);
|
|
1383
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1384
|
+
try {
|
|
1385
|
+
const body = await req.json() as any;
|
|
1386
|
+
if (!body.txn_id) return withCors(req, Response.json({ ok: false, error: "txn_id required" }, { status: 400 }));
|
|
1387
|
+
if (!nodeMayRenameTxn(resolved, body.txn_id)) return userTokenRequired(req);
|
|
1388
|
+
const result = abortRename(resolved.user.user_id, body.txn_id);
|
|
1389
|
+
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "node_rename_aborted", "node", body.txn_id);
|
|
1390
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
1391
|
+
} catch (e: any) {
|
|
1392
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
// ── V3: Token management ──
|
|
1397
|
+
if (url.pathname === "/api/auth/tokens" && req.method === "GET") {
|
|
1398
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
1399
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1400
|
+
const resolved = resolveToken(token);
|
|
1401
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1402
|
+
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1403
|
+
const tokens = listTokens(resolved.user.user_id);
|
|
1404
|
+
return withCors(req, Response.json({ ok: true, tokens }));
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
if (url.pathname === "/api/auth/tokens" && req.method === "POST") {
|
|
1408
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
1409
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1410
|
+
const resolved = resolveToken(token);
|
|
1411
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1412
|
+
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1413
|
+
try {
|
|
1414
|
+
const body = await req.json() as any;
|
|
1415
|
+
const result = createToken(resolved.user.user_id, body.name || "api-token", body.network_id);
|
|
1416
|
+
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "token_created", "token", result.token_id);
|
|
1417
|
+
return withCors(req, Response.json(result));
|
|
1418
|
+
} catch (e: any) {
|
|
1419
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
const tokenDeleteMatch = url.pathname.match(/^\/api\/auth\/tokens\/([^/]+)$/);
|
|
1424
|
+
if (tokenDeleteMatch && req.method === "DELETE") {
|
|
1425
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
1426
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1427
|
+
const resolved = resolveToken(token);
|
|
1428
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1429
|
+
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1430
|
+
const result = revokeToken(resolved.user.user_id, tokenDeleteMatch[1]);
|
|
1431
|
+
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "token_revoked", "token", tokenDeleteMatch[1]);
|
|
1432
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 404 }));
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
// ── 登录设备:列出 / 撤销我的登录会话(见 auth.ts「登录会话令牌」) ──
|
|
1436
|
+
// 只接受用户令牌:节点令牌不是一次登录,也不能替签发它的人管账号(同 #2086)。
|
|
1437
|
+
if (url.pathname === "/api/auth/sessions" && req.method === "GET") {
|
|
1438
|
+
const token = requestToken(req, { allowQueryToken: false });
|
|
1439
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1440
|
+
const resolved = resolveToken(token);
|
|
1441
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1442
|
+
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1443
|
+
const sessions = listSessions(resolved.user.user_id, resolved.tokenId);
|
|
1444
|
+
return withCors(req, Response.json({ ok: true, sessions, current_token_id: resolved.tokenId, idle_timeout_days: sessionIdleDays() }));
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
if (url.pathname === "/api/auth/sessions/revoke-others" && req.method === "POST") {
|
|
1448
|
+
const token = requestToken(req, { allowQueryToken: false });
|
|
1449
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1450
|
+
const resolved = resolveToken(token);
|
|
1451
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1452
|
+
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1453
|
+
// 当前令牌必须是一次登录会话,否则「保留当前这个」无从说起(API 令牌调用会把所有会话都撤掉)。
|
|
1454
|
+
const current = resolved.tokenId ? listSessions(resolved.user.user_id, resolved.tokenId).find((x) => x.is_current) : undefined;
|
|
1455
|
+
if (!current) return withCors(req, Response.json({ ok: false, error: "session_token_required", message: "call this with a login session token" }, { status: 403 }));
|
|
1456
|
+
const revoked = revokeOtherSessions(resolved.user.user_id, current.token_id);
|
|
1457
|
+
logAudit(resolved.user.user_id, resolved.user.username, "sessions_revoked_others", "token", current.token_id, `revoked=${revoked}`);
|
|
1458
|
+
return withCors(req, Response.json({ ok: true, revoked, kept_token_id: current.token_id }));
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
const sessionDeleteMatch = url.pathname.match(/^\/api\/auth\/sessions\/([^/]+)$/);
|
|
1462
|
+
if (sessionDeleteMatch && req.method === "DELETE") {
|
|
1463
|
+
const token = requestToken(req, { allowQueryToken: false });
|
|
1464
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1465
|
+
const resolved = resolveToken(token);
|
|
1466
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1467
|
+
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1468
|
+
const targetId = decodeURIComponent(sessionDeleteMatch[1]);
|
|
1469
|
+
const result = revokeSession(resolved.user.user_id, targetId);
|
|
1470
|
+
const self = targetId === resolved.tokenId;
|
|
1471
|
+
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "session_revoked", "token", targetId, self ? "self (logout)" : undefined);
|
|
1472
|
+
return withCors(req, Response.json({ ...result, ...(result.ok ? { was_current: self } : {}) }, { status: result.ok ? 200 : 404 }));
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
// ── V3: Network management ──
|
|
1476
|
+
if (url.pathname === "/api/networks" && req.method === "GET") {
|
|
1477
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
1478
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
1479
|
+
const resolved = resolveToken(token);
|
|
1480
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1481
|
+
// V3.13: ntok_ can only see its bound network; utok_ sees all member networks
|
|
1482
|
+
if (resolved.networkId) {
|
|
1483
|
+
// ntok_ — only return the bound network
|
|
1484
|
+
const net = db.get<any>(`SELECT ${NETWORK_REST_SELECT} FROM networks WHERE network_id = ?1`, resolved.networkId);
|
|
1485
|
+
return withCors(req, Response.json({ ok: true, networks: net ? [withNetworkNameAlias(net)] : [] }));
|
|
1486
|
+
}
|
|
1487
|
+
// Global Hub admins have cross-network authority throughout the REST
|
|
1488
|
+
// surface, so limiting this discovery endpoint to their membership rows
|
|
1489
|
+
// made otherwise-authorized networks impossible to select (#94). Keep
|
|
1490
|
+
// ordinary utok_ callers membership-scoped; ntok_ returned above stays
|
|
1491
|
+
// bound to exactly one network.
|
|
1492
|
+
const networks = resolved.user.role === "admin"
|
|
1493
|
+
? db.all<any>(
|
|
1494
|
+
`SELECT ${NETWORK_REST_SELECT},
|
|
1495
|
+
COALESCE((SELECT nm.role FROM network_members nm
|
|
1496
|
+
WHERE nm.network_id = networks.network_id
|
|
1497
|
+
AND nm.user_id = ?1), 'admin') AS member_role
|
|
1498
|
+
FROM networks ORDER BY created_at`,
|
|
1499
|
+
resolved.user.user_id,
|
|
1500
|
+
)
|
|
1501
|
+
: getUserAllNetworks(resolved.user.user_id);
|
|
1502
|
+
const visibleNetworks = networks.map(withNetworkNameAlias);
|
|
1503
|
+
return withCors(req, Response.json({ ok: true, networks: visibleNetworks }));
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
if (url.pathname === "/api/networks" && req.method === "POST") {
|
|
1507
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
1508
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
1509
|
+
const resolved = resolveToken(token);
|
|
1510
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1511
|
+
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1512
|
+
try {
|
|
1513
|
+
const body = await req.json() as any;
|
|
1514
|
+
const result = createNetwork(resolved.user.user_id, body.name, body.description);
|
|
1515
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
1516
|
+
} catch (e: any) {
|
|
1517
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
// ── Agent 分组(RFC-038 §8):owner/admin/Hub 管理员管理;组授权动态生效 ──
|
|
1522
|
+
// GET/POST /api/networks/:id/agent-groups · PATCH/DELETE …/:gid · PUT …/:gid/members {node_ids}
|
|
1523
|
+
const groupsMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/agent-groups(?:\/([^/]+)(\/members)?)?$/);
|
|
1524
|
+
if (groupsMatch) {
|
|
1525
|
+
const token = requestToken(req, { allowQueryToken: false });
|
|
1526
|
+
const resolved = token ? resolveToken(token) : null;
|
|
1527
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1528
|
+
// 组是人的管理对象,节点令牌不能碰。
|
|
1529
|
+
if (resolved.networkId) return withCors(req, Response.json({ ok: false, error: "user_token_required" }, { status: 403 }));
|
|
1530
|
+
const netId = decodeURIComponent(groupsMatch[1]);
|
|
1531
|
+
const groupId = groupsMatch[2] ? decodeURIComponent(groupsMatch[2]) : null;
|
|
1532
|
+
const membersPath = !!groupsMatch[3];
|
|
1533
|
+
const hubAdmin = resolved.user.role === "admin";
|
|
1534
|
+
const callerRole = getUserNetworkRole(resolved.user.user_id, netId);
|
|
1535
|
+
// 受限成员(以及普通成员)看不到组列表:他们只需要看到最终能访问的 Agent。
|
|
1536
|
+
if (!hubAdmin && callerRole !== "owner" && callerRole !== "admin") {
|
|
1537
|
+
return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
|
|
1538
|
+
}
|
|
1539
|
+
const actor = resolved.user;
|
|
1540
|
+
const readBody = async (): Promise<any | null> => {
|
|
1541
|
+
try { const b = await req.json(); return b && typeof b === "object" && !Array.isArray(b) ? b : null; } catch { return null; }
|
|
1542
|
+
};
|
|
1543
|
+
const kick = (userIds: string[]) => { for (const uid of userIds) closeUserStreamsInNetwork(netId, uid); };
|
|
1544
|
+
if (!groupId && req.method === "GET") {
|
|
1545
|
+
return withCors(req, Response.json({ ok: true, network_id: netId, groups: listAgentGroups(netId) }));
|
|
1546
|
+
}
|
|
1547
|
+
if (!groupId && req.method === "POST") {
|
|
1548
|
+
const body = await readBody();
|
|
1549
|
+
if (!body) return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 }));
|
|
1550
|
+
const r = createAgentGroup({ networkId: netId, name: body.name, description: body.description, nodeIds: body.node_ids, actorUserId: actor.user_id });
|
|
1551
|
+
if (!r.ok) return withCors(req, Response.json({ ok: false, error: r.error, ...(r.detail ? { detail: r.detail } : {}) }, { status: r.status }));
|
|
1552
|
+
logAudit(actor.user_id, actor.username, "agent_group_created", "agent_group", r.group.group_id,
|
|
1553
|
+
JSON.stringify({ name: r.group.name, node_ids: r.group.node_ids }).slice(0, 4000), undefined, netId);
|
|
1554
|
+
return withCors(req, Response.json({ ok: true, group: r.group }));
|
|
1555
|
+
}
|
|
1556
|
+
if (groupId && !membersPath && req.method === "PATCH") {
|
|
1557
|
+
const body = await readBody();
|
|
1558
|
+
if (!body) return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 }));
|
|
1559
|
+
const r = updateAgentGroup({ networkId: netId, groupId, name: body.name, description: body.description });
|
|
1560
|
+
if (!r.ok) return withCors(req, Response.json({ ok: false, error: r.error, ...(r.detail ? { detail: r.detail } : {}) }, { status: r.status }));
|
|
1561
|
+
logAudit(actor.user_id, actor.username, "agent_group_renamed", "agent_group", groupId, JSON.stringify({ name: r.group.name }), undefined, netId);
|
|
1562
|
+
return withCors(req, Response.json({ ok: true, group: r.group }));
|
|
1563
|
+
}
|
|
1564
|
+
if (groupId && membersPath && req.method === "PUT") {
|
|
1565
|
+
const body = await readBody();
|
|
1566
|
+
if (!body) return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 }));
|
|
1567
|
+
const r = replaceAgentGroupMembers({ networkId: netId, groupId, nodeIds: body.node_ids, actorUserId: actor.user_id });
|
|
1568
|
+
if (!r.ok) return withCors(req, Response.json({ ok: false, error: r.error, ...(r.detail ? { detail: r.detail } : {}) }, { status: r.status }));
|
|
1569
|
+
logAudit(actor.user_id, actor.username, "agent_group_members_changed", "agent_group", groupId,
|
|
1570
|
+
JSON.stringify({ added: r.added, removed: r.removed }).slice(0, 4000), undefined, netId);
|
|
1571
|
+
// 组成员变了:这个组上有授权的成员按新权限重连(观察流按连接时的权限过滤)。
|
|
1572
|
+
if (r.added.length || r.removed.length) kick(usersGrantedGroup(groupId));
|
|
1573
|
+
return withCors(req, Response.json({ ok: true, group: r.group, added: r.added, removed: r.removed }));
|
|
1574
|
+
}
|
|
1575
|
+
if (groupId && !membersPath && req.method === "DELETE") {
|
|
1576
|
+
const r = deleteAgentGroup(netId, groupId);
|
|
1577
|
+
if (!r.ok) return withCors(req, Response.json({ ok: false, error: r.error }, { status: r.status }));
|
|
1578
|
+
logAudit(actor.user_id, actor.username, "agent_group_deleted", "agent_group", groupId,
|
|
1579
|
+
JSON.stringify({ affected_user_count: r.affected_user_ids.length }), undefined, netId);
|
|
1580
|
+
kick(r.affected_user_ids);
|
|
1581
|
+
return withCors(req, Response.json({ ok: true, affected_user_ids: r.affected_user_ids }));
|
|
1582
|
+
}
|
|
1583
|
+
return withCors(req, Response.json({ ok: false, error: "method not allowed" }, { status: 405 }));
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
// ── 多用户 Agent 权限:成员可访问的 Agent(owner/admin 管理) ──
|
|
1587
|
+
// GET → { agent_access: 'all'|'granted', grants: [{node_id, alias, can_message}] }
|
|
1588
|
+
// PUT { grants?: [{node_id | alias, can_message?}] | string[], agent_access?: 'all'|'granted' } → 整体替换
|
|
1589
|
+
// ── 任务(需求卡)的人员权限(RFC-038 §9):成员能看 / 能改哪些卡。owner/admin/Hub 管理员管理 ──
|
|
1590
|
+
// GET → { task_access: 'all'|'scoped', restricted, project_grants: [{project_id, can_edit}] }
|
|
1591
|
+
// PUT { task_access?, project_grants?: [{project_id, can_edit?}] | string[] } → 不传的字段保持原样;传了整体替换
|
|
1592
|
+
const taskGrantsMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/members\/([^/]+)\/task-grants$/);
|
|
1593
|
+
if (taskGrantsMatch && (req.method === "GET" || req.method === "PUT")) {
|
|
1594
|
+
const token = requestToken(req, { allowQueryToken: false });
|
|
1595
|
+
const resolved = token ? resolveToken(token) : null;
|
|
1596
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1597
|
+
if (resolved.networkId) return withCors(req, Response.json({ ok: false, error: "user_token_required" }, { status: 403 }));
|
|
1598
|
+
const netId = decodeURIComponent(taskGrantsMatch[1]);
|
|
1599
|
+
const targetUid = decodeURIComponent(taskGrantsMatch[2]);
|
|
1600
|
+
const hubAdmin = resolved.user.role === "admin";
|
|
1601
|
+
const callerRole = getUserNetworkRole(resolved.user.user_id, netId);
|
|
1602
|
+
if (!hubAdmin && callerRole !== "owner" && callerRole !== "admin") {
|
|
1603
|
+
return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
|
|
1604
|
+
}
|
|
1605
|
+
const targetRole = getUserNetworkRole(targetUid, netId);
|
|
1606
|
+
if (!targetRole) return withCors(req, Response.json({ ok: false, error: "member_not_found" }, { status: 404 }));
|
|
1607
|
+
const view = () => ({
|
|
1608
|
+
ok: true, network_id: netId, user_id: targetUid, role: targetRole,
|
|
1609
|
+
task_access: getTaskAccessMode(netId, targetUid), restricted: isTaskScoped(targetUid, netId),
|
|
1610
|
+
project_grants: listProjectGrants(netId, targetUid),
|
|
1611
|
+
});
|
|
1612
|
+
if (req.method === "GET") return withCors(req, Response.json(view()));
|
|
1613
|
+
// 网络 admin 不能改 owner / 其他 admin(对他们本来也不生效)。
|
|
1614
|
+
if (!hubAdmin && callerRole !== "owner" && (targetRole === "owner" || targetRole === "admin")) {
|
|
1615
|
+
return withCors(req, Response.json({ ok: false, error: "owner required" }, { status: 403 }));
|
|
1616
|
+
}
|
|
1617
|
+
let body: any;
|
|
1618
|
+
try { body = await req.json(); } catch { return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 })); }
|
|
1619
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 }));
|
|
1620
|
+
if (body.task_access === undefined && body.project_grants === undefined) {
|
|
1621
|
+
return withCors(req, Response.json({ ok: false, error: "task_access or project_grants required" }, { status: 400 }));
|
|
1622
|
+
}
|
|
1623
|
+
const before = { task_access: getTaskAccessMode(netId, targetUid), project_grants: listProjectGrants(netId, targetUid) };
|
|
1624
|
+
const result = replaceTaskGrants({ networkId: netId, userId: targetUid, taskAccess: body.task_access, projectGrants: body.project_grants, actorUserId: resolved.user.user_id });
|
|
1625
|
+
if (!result.ok) {
|
|
1626
|
+
return withCors(req, Response.json({ ok: false, error: result.error, ...(result.detail ? { detail: result.detail } : {}) }, { status: result.status }));
|
|
1627
|
+
}
|
|
1628
|
+
logAudit(resolved.user.user_id, resolved.user.username, "member_task_grants_changed", "network", netId,
|
|
1629
|
+
JSON.stringify({ user_id: targetUid, before, after: { task_access: result.task_access, project_grants: result.project_grants } }).slice(0, 4000),
|
|
1630
|
+
undefined, netId);
|
|
1631
|
+
return withCors(req, Response.json(view()));
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
const grantsMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/members\/([^/]+)\/agent-grants$/);
|
|
1635
|
+
if (grantsMatch && (req.method === "GET" || req.method === "PUT")) {
|
|
1636
|
+
const token = requestToken(req, { allowQueryToken: false });
|
|
1637
|
+
const resolved = token ? resolveToken(token) : null;
|
|
1638
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1639
|
+
// 授权是用户层面的管理动作,网络令牌(节点身份)不能改人的权限。
|
|
1640
|
+
if (resolved.networkId) return withCors(req, Response.json({ ok: false, error: "user_token_required" }, { status: 403 }));
|
|
1641
|
+
const netId = decodeURIComponent(grantsMatch[1]);
|
|
1642
|
+
const targetUid = decodeURIComponent(grantsMatch[2]);
|
|
1643
|
+
const hubAdmin = resolved.user.role === "admin";
|
|
1644
|
+
const callerRole = getUserNetworkRole(resolved.user.user_id, netId);
|
|
1645
|
+
if (!hubAdmin && callerRole !== "owner" && callerRole !== "admin") {
|
|
1646
|
+
return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
|
|
1647
|
+
}
|
|
1648
|
+
const targetRole = getUserNetworkRole(targetUid, netId);
|
|
1649
|
+
if (!targetRole) return withCors(req, Response.json({ ok: false, error: "member_not_found" }, { status: 404 }));
|
|
1650
|
+
if (req.method === "GET") {
|
|
1651
|
+
return withCors(req, Response.json({
|
|
1652
|
+
ok: true,
|
|
1653
|
+
network_id: netId,
|
|
1654
|
+
user_id: targetUid,
|
|
1655
|
+
role: targetRole,
|
|
1656
|
+
agent_access: getAgentAccessMode(netId, targetUid),
|
|
1657
|
+
restricted: isAgentRestricted(targetUid, netId),
|
|
1658
|
+
grants: listAgentGrants(netId, targetUid),
|
|
1659
|
+
group_grants: listGroupGrants(netId, targetUid),
|
|
1660
|
+
}));
|
|
1661
|
+
}
|
|
1662
|
+
// 网络 admin 不能改 owner / 其他 admin 的授权(对他们本来也不生效,但别让 admin 去碰平级)。
|
|
1663
|
+
if (!hubAdmin && callerRole !== "owner" && (targetRole === "owner" || targetRole === "admin")) {
|
|
1664
|
+
return withCors(req, Response.json({ ok: false, error: "owner required" }, { status: 403 }));
|
|
1665
|
+
}
|
|
1666
|
+
let body: any;
|
|
1667
|
+
try { body = await req.json(); } catch { return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 })); }
|
|
1668
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 }));
|
|
1669
|
+
if (body.grants === undefined && body.node_ids === undefined && body.agent_access === undefined && body.group_grants === undefined) {
|
|
1670
|
+
return withCors(req, Response.json({ ok: false, error: "grants or agent_access required" }, { status: 400 }));
|
|
1671
|
+
}
|
|
1672
|
+
const result = replaceAgentGrants({
|
|
1673
|
+
networkId: netId,
|
|
1674
|
+
userId: targetUid,
|
|
1675
|
+
grants: body.grants ?? body.node_ids,
|
|
1676
|
+
agentAccess: body.agent_access,
|
|
1677
|
+
groupGrants: body.group_grants,
|
|
1678
|
+
actorUserId: resolved.user.user_id,
|
|
1679
|
+
});
|
|
1680
|
+
if (!result.ok) {
|
|
1681
|
+
return withCors(req, Response.json({ ok: false, error: result.error, ...(result.detail ? { detail: result.detail } : {}) }, { status: result.status }));
|
|
1682
|
+
}
|
|
1683
|
+
logAudit(resolved.user.user_id, resolved.user.username, "member_agent_grants_changed", "network", netId,
|
|
1684
|
+
JSON.stringify({ user_id: targetUid, agent_access: result.agent_access, grants: result.grants.map((g) => ({ node_id: g.node_id, alias: g.alias, can_message: g.can_message })), group_grants: result.group_grants.map((g) => ({ group_id: g.group_id, can_message: g.can_message })) }).slice(0, 4000),
|
|
1685
|
+
undefined, netId);
|
|
1686
|
+
// 已连着的观察流 / 用户流按旧权限鉴权过,断开让它按新权限重连。
|
|
1687
|
+
closeUserStreamsInNetwork(netId, targetUid);
|
|
1688
|
+
return withCors(req, Response.json({ ok: true, network_id: netId, user_id: targetUid, agent_access: result.agent_access, restricted: isAgentRestricted(targetUid, netId), grants: result.grants, group_grants: result.group_grants }));
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
// ── 多用户:网络里的人类成员通讯录 —— 任何成员(含受限成员)都能看,只有身份字段 ──
|
|
1692
|
+
const humansMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/humans$/);
|
|
1693
|
+
if (humansMatch && req.method === "GET") {
|
|
1694
|
+
const token = requestToken(req, { allowQueryToken: false });
|
|
1695
|
+
const resolved = token ? resolveToken(token) : null;
|
|
1696
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1697
|
+
const netId = decodeURIComponent(humansMatch[1]);
|
|
1698
|
+
if (resolved.networkId && resolved.networkId !== netId) return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
|
|
1699
|
+
if (resolved.user.role !== "admin" && !getUserNetworkRole(resolved.user.user_id, netId)) {
|
|
1700
|
+
return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
|
|
1701
|
+
}
|
|
1702
|
+
const humans = getNetworkHumans(netId);
|
|
1703
|
+
// 在线状态(online / last_seen_at)只给用户令牌:节点令牌拿到的仍是原来的身份字段,不多得任何东西。
|
|
1704
|
+
if (isNodeCredential(resolved)) return withCors(req, Response.json({ ok: true, network_id: netId, humans }));
|
|
1705
|
+
const presence = getUserPresence(humans.map((h) => h.user_id));
|
|
1706
|
+
return withCors(req, Response.json({ ok: true, network_id: netId, humans: humans.map((h) => ({ ...h, ...presence.get(h.user_id)! })) }));
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
// ── V3.13: Network members + invites ──
|
|
1710
|
+
const membersMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/members(?:\/([^/]+))?$/);
|
|
1711
|
+
if (membersMatch) {
|
|
1712
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
1713
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1714
|
+
const resolved = resolveToken(token);
|
|
1715
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1716
|
+
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1717
|
+
const netId = membersMatch[1];
|
|
1718
|
+
const targetUid = membersMatch[2];
|
|
1719
|
+
const callerRole = getUserNetworkRole(resolved.user.user_id, netId);
|
|
1720
|
+
if (!callerRole) return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
|
|
1721
|
+
|
|
1722
|
+
if (req.method === "GET") {
|
|
1723
|
+
if (!["owner", "admin"].includes(callerRole)) return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
|
|
1724
|
+
const members = getNetworkMembers(netId);
|
|
1725
|
+
return withCors(req, Response.json({ ok: true, members }));
|
|
1726
|
+
}
|
|
1727
|
+
if (req.method === "POST") {
|
|
1728
|
+
if (!["owner", "admin"].includes(callerRole)) return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
|
|
1729
|
+
const body = await req.json() as any;
|
|
1730
|
+
// agent_access 缺省 'granted'(新成员看不到任何 Agent);owner/admin 可显式传 'all' 加一个完全信任的成员。
|
|
1731
|
+
if (body.agent_access !== undefined && body.agent_access !== "all" && body.agent_access !== "granted") {
|
|
1732
|
+
return withCors(req, Response.json({ ok: false, error: "invalid_agent_access" }, { status: 400 }));
|
|
1733
|
+
}
|
|
1734
|
+
// task_access 缺省 NEW_MEMBER_TASK_ACCESS(RFC-038 §9.4);显式传 'all' / 'scoped' 覆盖。
|
|
1735
|
+
if (body.task_access !== undefined && body.task_access !== "all" && body.task_access !== "scoped") {
|
|
1736
|
+
return withCors(req, Response.json({ ok: false, error: "invalid_task_access" }, { status: 400 }));
|
|
1737
|
+
}
|
|
1738
|
+
const result = addNetworkMember(netId, body.user_id, body.role || "member", resolved.user.user_id, { agentAccess: body.agent_access, taskAccess: body.task_access });
|
|
1739
|
+
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "member_added", "network", netId, `${body.user_id} as ${body.role || "member"} agent_access=${body.agent_access === "all" ? "all" : "granted"} task_access=${body.task_access ?? NEW_MEMBER_TASK_ACCESS}`, undefined, netId);
|
|
1740
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
1741
|
+
}
|
|
1742
|
+
if (req.method === "PUT" && targetUid) {
|
|
1743
|
+
if (callerRole !== "owner") return withCors(req, Response.json({ ok: false, error: "owner required" }, { status: 403 }));
|
|
1744
|
+
const body = await req.json() as any;
|
|
1745
|
+
const result = updateMemberRole(netId, targetUid, body.role);
|
|
1746
|
+
if (result.ok) {
|
|
1747
|
+
logAudit(resolved.user.user_id, resolved.user.username, "member_role_changed", "network", netId, `${targetUid} → ${body.role}`, undefined, netId);
|
|
1748
|
+
closeUserStreamsInNetwork(netId, targetUid);
|
|
1749
|
+
}
|
|
1750
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
1751
|
+
}
|
|
1752
|
+
if (req.method === "DELETE" && targetUid) {
|
|
1753
|
+
if (!["owner", "admin"].includes(callerRole)) return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
|
|
1754
|
+
const result = removeNetworkMember(netId, targetUid);
|
|
1755
|
+
if (result.ok) {
|
|
1756
|
+
logAudit(resolved.user.user_id, resolved.user.username, "member_removed", "network", netId, targetUid, undefined, netId);
|
|
1757
|
+
closeUserStreamsInNetwork(netId, targetUid);
|
|
1758
|
+
}
|
|
1759
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
if (url.pathname.match(/^\/api\/networks\/([^/]+)\/invite$/) && req.method === "POST") {
|
|
1764
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
1765
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1766
|
+
const resolved = resolveToken(token);
|
|
1767
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1768
|
+
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1769
|
+
const netId = url.pathname.split("/")[3];
|
|
1770
|
+
const callerRole = getUserNetworkRole(resolved.user.user_id, netId);
|
|
1771
|
+
if (!callerRole || !["owner", "admin"].includes(callerRole)) {
|
|
1772
|
+
return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
|
|
1773
|
+
}
|
|
1774
|
+
const body = await req.json() as any;
|
|
1775
|
+
const result = createInvite(netId, resolved.user.user_id, body.role || "member", body.max_uses || 1, body.expires_days);
|
|
1776
|
+
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "invite_created", "network", netId, result.invite_code);
|
|
1777
|
+
return withCors(req, Response.json(result));
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
if (url.pathname === "/api/networks/join" && req.method === "POST") {
|
|
1781
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
1782
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1783
|
+
const resolved = resolveToken(token);
|
|
1784
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1785
|
+
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1786
|
+
const body = await req.json() as any;
|
|
1787
|
+
const result = joinByInvite(body.invite_code, resolved.user.user_id);
|
|
1788
|
+
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "network_joined", "network", result.network_id, `via invite, role=${result.role}`);
|
|
1789
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
// ── 多用户账号:管理员建号 / 列用户 ──
|
|
1793
|
+
// POST {username, password, display_name?, email?, network_id?, role?}
|
|
1794
|
+
// Hub 管理员:任意;某网络 owner/admin:只能带上自己管的 network_id(新用户进这个网络)。
|
|
1795
|
+
// 新成员默认 agent_access='granted' —— 看不到任何 Agent,等管理员在 agent-grants 里授权。
|
|
1796
|
+
// GET → Hub 管理员:全部用户 + 每人所在网络 / 角色 / agent_access。
|
|
1797
|
+
if (url.pathname === "/api/admin/users" && (req.method === "GET" || req.method === "POST")) {
|
|
1798
|
+
const token = requestToken(req, { allowQueryToken: false });
|
|
1799
|
+
const resolved = token ? resolveToken(token) : null;
|
|
1800
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1801
|
+
if (resolved.networkId) return withCors(req, Response.json({ ok: false, error: "user_token_required" }, { status: 403 }));
|
|
1802
|
+
const hubAdmin = resolved.user.role === "admin";
|
|
1803
|
+
if (req.method === "GET") {
|
|
1804
|
+
if (!hubAdmin) return withCors(req, Response.json({ ok: false, error: "admin required" }, { status: 403 }));
|
|
1805
|
+
return withCors(req, Response.json({ ok: true, users: listUsersWithMemberships() }));
|
|
1806
|
+
}
|
|
1807
|
+
const clientIP = getClientIP(req, server);
|
|
1808
|
+
if (!checkRateLimit(`admin-users:${clientIP}`, 30)) {
|
|
1809
|
+
return withCors(req, Response.json({ ok: false, error: "too many requests, try again later" }, { status: 429 }));
|
|
1810
|
+
}
|
|
1811
|
+
let body: any;
|
|
1812
|
+
try { body = await req.json(); } catch { return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 })); }
|
|
1813
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 }));
|
|
1814
|
+
const result = adminCreateUser(body, { userId: resolved.user.user_id, isHubAdmin: hubAdmin });
|
|
1815
|
+
if (!result.ok) {
|
|
1816
|
+
logAudit(resolved.user.user_id, resolved.user.username, "admin_user_create_denied", "user", undefined,
|
|
1817
|
+
JSON.stringify({ username: String(body.username ?? "").slice(0, 50), network_id: body.network_id ?? null, error: result.error }), clientIP);
|
|
1818
|
+
return withCors(req, Response.json({ ok: false, error: result.error }, { status: result.status }));
|
|
1819
|
+
}
|
|
1820
|
+
logAudit(resolved.user.user_id, resolved.user.username, "admin_user_created", "user", result.user.user_id,
|
|
1821
|
+
JSON.stringify({ username: result.user.username, membership: result.membership }), clientIP, result.membership?.network_id);
|
|
1822
|
+
return withCors(req, Response.json({ ok: true, user: result.user, personal_network_id: result.network_id, membership: result.membership }));
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
// ── V3: Admin APIs (require auth) ──
|
|
1826
|
+
if (url.pathname === "/api/users" && req.method === "GET") {
|
|
1827
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
1828
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1829
|
+
const resolved = resolveToken(token);
|
|
1830
|
+
if (!isHubAdminCredential(resolved)) {
|
|
1831
|
+
return withCors(req, Response.json({ ok: false, error: "admin required" }, { status: 403 }));
|
|
1832
|
+
}
|
|
1833
|
+
const users = db.all("SELECT user_id, username, display_name, email, role, created_at FROM users ORDER BY created_at");
|
|
1834
|
+
return withCors(req, Response.json({ ok: true, users }));
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
const netDetailMatch = url.pathname.match(/^\/api\/networks\/([^/]+)$/);
|
|
1838
|
+
if (netDetailMatch && req.method === "GET") {
|
|
1839
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
1840
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1841
|
+
const resolved = resolveToken(token);
|
|
1842
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1843
|
+
const networkId = netDetailMatch[1];
|
|
1844
|
+
const network = db.get<any>(`SELECT ${NETWORK_REST_SELECT} FROM networks WHERE network_id = ?1`, networkId);
|
|
1845
|
+
if (!network) return withCors(req, Response.json({ ok: false, error: "network not found" }, { status: 404 }));
|
|
1846
|
+
// Membership check: must be a member or system admin
|
|
1847
|
+
const viewerRole = getUserNetworkRole(resolved.user.user_id, networkId);
|
|
1848
|
+
if (resolved.networkId && resolved.networkId !== networkId) {
|
|
1849
|
+
return withCors(req, Response.json({ ok: false, error: "access denied" }, { status: 403 }));
|
|
1850
|
+
}
|
|
1851
|
+
if (!viewerRole && !isHubAdminCredential(resolved)) {
|
|
1852
|
+
return withCors(req, Response.json({ ok: false, error: "access denied" }, { status: 403 }));
|
|
1853
|
+
}
|
|
1854
|
+
// Get network stats —— 受限成员只数得到授权给他的 Agent 与自己的往来。
|
|
1855
|
+
const detailScope: RestNetworkScope = resolved.user.role === "admin" || resolved.networkId
|
|
1856
|
+
? { networkId, networkIds: null }
|
|
1857
|
+
: resolveRestNetworkScope(networkId, { userId: resolved.user.user_id, networkId: null, username: resolved.user.username }, false);
|
|
1858
|
+
const nodeParams: unknown[] = [];
|
|
1859
|
+
const nodeCount = db.get<{ cnt: number }>(addAgentNetworkScope("SELECT COUNT(*) as cnt FROM nodes WHERE 1=1", nodeParams, detailScope, { alias: "alias", nodeId: "node_id" }), ...nodeParams);
|
|
1860
|
+
const sessionParams: unknown[] = [];
|
|
1861
|
+
const sessionCount = db.get<{ cnt: number }>(addAgentNetworkScope("SELECT COUNT(*) as cnt FROM sessions WHERE 1=1", sessionParams, detailScope, { alias: "alias", nodeId: "node_id" }), ...sessionParams);
|
|
1862
|
+
const taskParams: unknown[] = [];
|
|
1863
|
+
const taskStats = db.all<any>(addOwnTrafficScope("SELECT status, COUNT(*) as count FROM tasks WHERE 1=1", taskParams, detailScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" }) + " GROUP BY status", ...taskParams);
|
|
1864
|
+
return withCors(req, Response.json({
|
|
1865
|
+
ok: true, network,
|
|
1866
|
+
stats: { nodes: nodeCount?.cnt || 0, sessions: sessionCount?.cnt || 0, tasks: taskStats },
|
|
1867
|
+
}));
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
if (netDetailMatch && req.method === "DELETE") {
|
|
1871
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
1872
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1873
|
+
const resolved = resolveToken(token);
|
|
1874
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1875
|
+
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1876
|
+
// Hub 管理员删别人的网络:同一个资源、同一个动词,只是换一条判据 —— 只允许空网络
|
|
1877
|
+
// (计数见 auth.ts adminDeleteEmptyNetwork)。owner 自己删仍走下面原来的路径,行为不变。
|
|
1878
|
+
const owner = db.get<{ owner_id: string }>("SELECT owner_id FROM networks WHERE network_id = ?1", netDetailMatch[1]);
|
|
1879
|
+
if (owner && owner.owner_id !== resolved.user.user_id && isHubAdminCredential(resolved)) {
|
|
1880
|
+
const adminResult = adminDeleteEmptyNetwork(netDetailMatch[1], { files: countNetworkUploads(netDetailMatch[1]) });
|
|
1881
|
+
if (!adminResult.ok) {
|
|
1882
|
+
return withCors(req, Response.json({ ok: false, error: adminResult.error, counts: adminResult.counts }, { status: adminResult.status }));
|
|
1883
|
+
}
|
|
1884
|
+
logAudit(resolved.user.user_id, resolved.user.username, "network_deleted", "network", netDetailMatch[1],
|
|
1885
|
+
JSON.stringify({ admin_override: true, owner_id: adminResult.owner_id, network_name: adminResult.network_name, cleaned: adminResult.cleaned }),
|
|
1886
|
+
getClientIP(req, server), netDetailMatch[1]);
|
|
1887
|
+
return withCors(req, Response.json({ ok: true, admin_override: true, cleaned: adminResult.cleaned }));
|
|
1888
|
+
}
|
|
1889
|
+
const result = deleteNetwork(resolved.user.user_id, netDetailMatch[1]);
|
|
1890
|
+
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "network_deleted", "network", netDetailMatch[1]);
|
|
1891
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
if (netDetailMatch && req.method === "PUT") {
|
|
1895
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
1896
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1897
|
+
const resolved = resolveToken(token);
|
|
1898
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1899
|
+
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1900
|
+
try {
|
|
1901
|
+
const body = await req.json() as any;
|
|
1902
|
+
if (body.name) {
|
|
1903
|
+
const result = renameNetwork(resolved.user.user_id, netDetailMatch[1], body.name);
|
|
1904
|
+
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "network_renamed", "network", netDetailMatch[1], body.name);
|
|
1905
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
1906
|
+
}
|
|
1907
|
+
return withCors(req, Response.json({ ok: false, error: "name required" }, { status: 400 }));
|
|
1908
|
+
} catch (e: any) {
|
|
1909
|
+
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
// ── REST: health (public, no auth) ──
|
|
1914
|
+
if (url.pathname === "/health") {
|
|
1915
|
+
// #473 — /health is ANONYMOUS BY CONTRACT (the central watchdog
|
|
1916
|
+
// cron curls it every minute to decide dead-or-alive; adding auth
|
|
1917
|
+
// here would blind it — do NOT add requireAuth, do NOT change the
|
|
1918
|
+
// 200-with-JSON shape without telling the watchdog owner first).
|
|
1919
|
+
// Anonymous callers get AGGREGATES ONLY. The per-key SSE detail
|
|
1920
|
+
// (`{networkId}:{alias}` + observer keys) used to be inlined here,
|
|
1921
|
+
// which handed any internet stranger the full network id + all
|
|
1922
|
+
// agent aliases (95 on the public hub when audited) — that detail
|
|
1923
|
+
// now lives behind auth at GET /api/stats/sse.
|
|
1924
|
+
// `version` stays: it does help fingerprinting, but ops needs it
|
|
1925
|
+
// to verify deploys land, and the tradeoff was accepted in review.
|
|
1926
|
+
//
|
|
1927
|
+
// #495-followup / f28a6c1b — auth-gated `sse_sessions` restored so
|
|
1928
|
+
// the dashboard's own "online" widget can compute per-agent status
|
|
1929
|
+
// in one round-trip. Rules:
|
|
1930
|
+
// - anonymous (no valid token): NO sse_sessions field → watchdog
|
|
1931
|
+
// contract untouched, no topology leak (mutation removing this
|
|
1932
|
+
// gate turns the anonymous test RED — see health-redaction.test)
|
|
1933
|
+
// - admin utok_ / legacy master / DEV_OPEN: full sessions map
|
|
1934
|
+
// (parity with /api/stats/sse ops path)
|
|
1935
|
+
// - regular utok_ member: sessions map FILTERED to network_ids
|
|
1936
|
+
// the caller is a member of — safe against the "any account
|
|
1937
|
+
// sees all-network topology" regression the #473 fix closed;
|
|
1938
|
+
// dashboard user proxies with the logged-in user's utok_ so
|
|
1939
|
+
// their own network's aliases surface; strangers' networks do
|
|
1940
|
+
// not
|
|
1941
|
+
const count = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM sessions");
|
|
1942
|
+
const sse = getSSEStats();
|
|
1943
|
+
const license = db.get<any>("SELECT type, expires_at FROM licenses LIMIT 1");
|
|
1944
|
+
|
|
1945
|
+
// Non-fatal auth probe (no 401 — /health must stay 200 anonymous).
|
|
1946
|
+
const healthAuth = resolveRequestAuth(req);
|
|
1947
|
+
const healthIsMaster = !healthAuth && isLegacyAuthToken(req);
|
|
1948
|
+
const healthIsDevOpen = !healthAuth && !AUTH_TOKEN && DEV_OPEN;
|
|
1949
|
+
const healthIsAdmin = !!(healthAuth?.username && db.get<any>(
|
|
1950
|
+
"SELECT role FROM users WHERE username = ?1", healthAuth.username,
|
|
1951
|
+
)?.role === "admin");
|
|
1952
|
+
|
|
1953
|
+
let scopedSessions: Record<string, number> | undefined;
|
|
1954
|
+
if (healthIsMaster || healthIsDevOpen || healthIsAdmin) {
|
|
1955
|
+
// Ops parity with /api/stats/sse — full map.
|
|
1956
|
+
scopedSessions = sse.sessions;
|
|
1957
|
+
} else if (healthAuth?.userId) {
|
|
1958
|
+
// Regular authenticated member — filter to networks they belong
|
|
1959
|
+
// to. Keys are `{networkId}:{alias}` (or observer keys shaped
|
|
1960
|
+
// `\0netobs:{networkId}`); parse the network prefix and keep
|
|
1961
|
+
// only entries whose network is in the member's set.
|
|
1962
|
+
//
|
|
1963
|
+
// 🔴 ntok_ (network-scoped token) MUST be forced to its single
|
|
1964
|
+
// bound network (parity with resolveRestNetworkScope L277:
|
|
1965
|
+
// `if (authCtx.networkId) return { networkId: authCtx.networkId }`).
|
|
1966
|
+
// Falling through to the utok_ union query would return
|
|
1967
|
+
// sessions from every network the underlying user belongs to,
|
|
1968
|
+
// silently escalating a network-scoped token to a user-scoped
|
|
1969
|
+
// token — the exact "认证 ≠ 已授权" mistake this whole change
|
|
1970
|
+
// is meant to prevent. Same shape as f28a6c1b root cause; see
|
|
1971
|
+
// CR3 audit 4703b0e7.
|
|
1972
|
+
const memberNets = new Set<string>(
|
|
1973
|
+
healthAuth.networkId
|
|
1974
|
+
? [healthAuth.networkId] // ntok_ single-network enforce
|
|
1975
|
+
: db.all<{ network_id: string }>(
|
|
1976
|
+
"SELECT network_id FROM network_members WHERE user_id = ?1",
|
|
1977
|
+
healthAuth.userId,
|
|
1978
|
+
).map((r) => r.network_id),
|
|
1979
|
+
);
|
|
1980
|
+
// 多用户 Agent 权限:键里带着 alias,受限网络整网不出现在这里(与 addNetworkScope 同一 fail-closed 口径)。
|
|
1981
|
+
if (!healthAuth.networkId) {
|
|
1982
|
+
for (const restricted of restrictedNetworkIds(healthAuth.userId)) memberNets.delete(restricted);
|
|
1983
|
+
}
|
|
1984
|
+
// If member has no networks at all, keep field present but empty
|
|
1985
|
+
// so the dashboard can distinguish "authenticated + none active"
|
|
1986
|
+
// from "not authenticated at all".
|
|
1987
|
+
const filtered: Record<string, number> = {};
|
|
1988
|
+
for (const [key, n] of Object.entries(sse.sessions)) {
|
|
1989
|
+
// Observer keys are shape `\0netobs:<networkId>` raw; keyed
|
|
1990
|
+
// through `printableKey()` in push.ts, they surface here as
|
|
1991
|
+
// the literal string `\\0netobs:<networkId>` (backslash-zero,
|
|
1992
|
+
// not NUL byte). Use the shared `PRINTABLE_OBSERVER_KEY_PREFIX`
|
|
1993
|
+
// export so this side and the emitter side stay locked to the
|
|
1994
|
+
// same literal — CR3 audit 4703b0e7 caught prior check
|
|
1995
|
+
// `startsWith("netobs:")` was always false → observer keys
|
|
1996
|
+
// silently over-filtered out of member views.
|
|
1997
|
+
const isObserver = key.startsWith(PRINTABLE_OBSERVER_KEY_PREFIX);
|
|
1998
|
+
const netId = isObserver
|
|
1999
|
+
? key.slice(PRINTABLE_OBSERVER_KEY_PREFIX.length)
|
|
2000
|
+
: key.split(":")[0];
|
|
2001
|
+
if (memberNets.has(netId)) filtered[key] = n;
|
|
2002
|
+
}
|
|
2003
|
+
scopedSessions = filtered;
|
|
2004
|
+
}
|
|
2005
|
+
// healthAuth == null AND no legacy master AND no DEV_OPEN → leave
|
|
2006
|
+
// scopedSessions undefined → sse_sessions field omitted entirely
|
|
2007
|
+
// (watchdog + arbitrary internet strangers see aggregates only).
|
|
2008
|
+
|
|
2009
|
+
const body: Record<string, unknown> = {
|
|
2010
|
+
ok: true,
|
|
2011
|
+
version: SERVER_VERSION,
|
|
2012
|
+
api_version: "v3",
|
|
2013
|
+
transport: "streamable-http",
|
|
2014
|
+
sessions_count: count?.cnt ?? 0,
|
|
2015
|
+
sse_connections: sse.total,
|
|
2016
|
+
auth: DEV_OPEN ? "dev-open" : "user-token",
|
|
2017
|
+
security: DEV_OPEN ? "dev-open" : "secured",
|
|
2018
|
+
tmux: TMUX_ENABLED ? "enabled" : "disabled",
|
|
2019
|
+
v3_auth: true,
|
|
2020
|
+
multi_network: true,
|
|
2021
|
+
license: license?.type || "none",
|
|
2022
|
+
uptime: Math.floor(process.uptime()),
|
|
2023
|
+
// #496 — public, stable capability data. Dashboard upload proxies
|
|
2024
|
+
// need the Hub's real limits before opening a streaming request;
|
|
2025
|
+
// duplicating these constants client-side creates a silent
|
|
2026
|
+
// lower-limit drift that the Hub can never observe.
|
|
2027
|
+
limits: {
|
|
2028
|
+
max_upload_bytes: MAX_UPLOAD_BYTES,
|
|
2029
|
+
max_request_content_length: MAX_REQUEST_CONTENT_LENGTH,
|
|
2030
|
+
},
|
|
2031
|
+
// Public, stable feature flags for REST reads whose own response can't carry them.
|
|
2032
|
+
// status_node_id: GET /api/status accepts ?node_id=<id> (one node's rows; with light=1 the
|
|
2033
|
+
// row also carries node_id). The /api/status body stays byte-identical without the param, so
|
|
2034
|
+
// the flag lives here — agent-nodes already read /health once at boot.
|
|
2035
|
+
capabilities: HUB_HEALTH_CAPABILITIES,
|
|
2036
|
+
};
|
|
2037
|
+
if (scopedSessions !== undefined) body.sse_sessions = scopedSessions;
|
|
2038
|
+
return withCors(req, Response.json(body));
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
// ── All REST /api endpoints require auth (if token configured) ──
|
|
2042
|
+
const authErr = requireAuth(req);
|
|
2043
|
+
if (authErr) return withCors(req, authErr);
|
|
2044
|
+
|
|
2045
|
+
// Resolve network scope for REST queries — enforce isolation
|
|
2046
|
+
// Token-bound networkId takes precedence (ntok_ → forced), then query param
|
|
2047
|
+
const restAuth = resolveRequestAuth(req);
|
|
2048
|
+
// Hub 管理员身份只随用户令牌;节点令牌即使由管理员签发也不是管理员(见 isNodeCredential)。
|
|
2049
|
+
const isAdmin = !!(restAuth?.username && !restAuth.networkId && db.get<any>("SELECT role FROM users WHERE username = ?1", restAuth.username)?.role === "admin");
|
|
2050
|
+
const restScope = resolveRestNetworkScope(url.searchParams.get("network_id"), restAuth, isAdmin);
|
|
2051
|
+
// 多用户 Agent 权限:下面凡是读 Agent 数据的查询,受限成员在受限网络里默认拿不到任何行
|
|
2052
|
+
// (addNetworkScope fail-closed);逐条审过的路径改用 addAgentNetworkScope / addOwnTrafficScope
|
|
2053
|
+
// 按授权放行,人类数据用 addHumanNetworkScope。
|
|
2054
|
+
if (restScope.denied) {
|
|
2055
|
+
return withCors(req, Response.json({ ok: false, error: restScope.denied }, { status: 403 }));
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
const sideActor = resolveSideThreadActor(req, resolveRequestAuth(req, { allowQueryToken: false }), isAdmin);
|
|
2059
|
+
const nodeCommandActor = sideActor?.kind === "node" && sideActor.boundNodeId && sideActor.boundNetworkId
|
|
2060
|
+
? { tokenId: sideActor.tokenId, networkId: sideActor.boundNetworkId, nodeId: sideActor.boundNodeId }
|
|
2061
|
+
: null;
|
|
2062
|
+
if (productionSideThreadTransport) {
|
|
2063
|
+
const attachmentResponse = await productionSideThreadTransport.attachment(req, url, nodeCommandActor);
|
|
2064
|
+
if (attachmentResponse) return withCors(req, attachmentResponse);
|
|
2065
|
+
const commandResponse = await productionSideThreadTransport.handle({ req, url, actor: nodeCommandActor });
|
|
2066
|
+
if (commandResponse) return withCors(req, commandResponse);
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
const sideThreadResponse = await handleSideThreadHttpRequest({
|
|
2070
|
+
req,
|
|
2071
|
+
url,
|
|
2072
|
+
// Durable prompts/results never accept query-string credentials.
|
|
2073
|
+
actor: sideActor,
|
|
2074
|
+
coordinator: sideThreadCoordinator,
|
|
2075
|
+
resolveCapabilityTarget: resolveSideThreadCapabilityTarget,
|
|
2076
|
+
});
|
|
2077
|
+
if (sideThreadResponse) return withCors(req, sideThreadResponse);
|
|
2078
|
+
|
|
2079
|
+
// RFC-036 owner-gated host schedule edits. This handler independently
|
|
2080
|
+
// verifies exact node ownership and node-token binding; network admin is
|
|
2081
|
+
// deliberately not an override.
|
|
2082
|
+
const requestCredential = requestToken(req);
|
|
2083
|
+
const externalScheduleEditResponse = await handleExternalScheduleEditRequest({
|
|
2084
|
+
req,
|
|
2085
|
+
url,
|
|
2086
|
+
auth: restAuth,
|
|
2087
|
+
isUserToken: requestCredential.startsWith("utok_"),
|
|
2088
|
+
isNodeToken: requestCredential.startsWith("ntok_"),
|
|
2089
|
+
});
|
|
2090
|
+
if (externalScheduleEditResponse) return withCors(req, externalScheduleEditResponse);
|
|
2091
|
+
|
|
2092
|
+
// Hub-owned scheduled tasks. Dashboard and mobile are management
|
|
2093
|
+
// clients only; each occurrence is dispatched as an ordinary task by the
|
|
2094
|
+
// scheduler started in startHub(). Node tokens are rejected in the
|
|
2095
|
+
// handler so an agent cannot grant itself a persistent execution loop.
|
|
2096
|
+
const scheduledResponse = await handleScheduledTaskRequest({
|
|
2097
|
+
req,
|
|
2098
|
+
url,
|
|
2099
|
+
auth: restAuth,
|
|
2100
|
+
isAdmin,
|
|
2101
|
+
isNodeToken: requestToken(req).startsWith("ntok_"),
|
|
2102
|
+
scope: restScope,
|
|
2103
|
+
});
|
|
2104
|
+
if (scheduledResponse) return withCors(req, scheduledResponse);
|
|
2105
|
+
|
|
2106
|
+
// ── 多用户:人与人私信(同网络两个用户之间;受限成员也可以) ──
|
|
2107
|
+
// POST /api/dm {network_id?, to_user_id | to_username, message, attachments?, client_request_id?}
|
|
2108
|
+
// GET /api/dm?network_id=&with=<user_id>[&limit&before] → 双向会话记录(新的在前)
|
|
2109
|
+
// GET /api/dm/threads?network_id= → 每个对方一行 + 未读数
|
|
2110
|
+
// 已读沿用 POST /api/messages/ack(私信就是收件人 user_inbox 里的行)。
|
|
2111
|
+
if (url.pathname === "/api/dm" || url.pathname === "/api/dm/threads") {
|
|
2112
|
+
if (!restAuth || !requestToken(req).startsWith("utok_")) {
|
|
2113
|
+
return withCors(req, Response.json({ ok: false, error: "user_token_required" }, { status: 403 }));
|
|
2114
|
+
}
|
|
2115
|
+
if (req.method === "POST" && url.pathname === "/api/dm") {
|
|
2116
|
+
let body: any;
|
|
2117
|
+
try { body = await req.json(); } catch { return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 })); }
|
|
2118
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 }));
|
|
2119
|
+
const dmNet = typeof body.network_id === "string" && body.network_id ? body.network_id : singleNetworkId(restScope);
|
|
2120
|
+
if (!dmNet) return withCors(req, Response.json({ ok: false, error: "network_id_required" }, { status: 400 }));
|
|
2121
|
+
const sent = sendHumanDm({
|
|
2122
|
+
networkId: dmNet,
|
|
2123
|
+
sender: { userId: restAuth.userId, username: restAuth.username },
|
|
2124
|
+
toUserId: body.to_user_id, toUsername: body.to_username,
|
|
2125
|
+
message: body.message, attachments: body.attachments, clientRequestId: body.client_request_id,
|
|
2126
|
+
});
|
|
2127
|
+
if (!sent.ok) return withCors(req, Response.json({ ok: false, error: sent.error }, { status: sent.status }));
|
|
2128
|
+
return withCors(req, Response.json({ ok: true, message: sent.message, delivered: sent.delivered }));
|
|
2129
|
+
}
|
|
2130
|
+
if (req.method === "GET") {
|
|
2131
|
+
const dmNet = url.searchParams.get("network_id") || singleNetworkId(restScope);
|
|
2132
|
+
if (!dmNet) return withCors(req, Response.json({ ok: false, error: "network_id_required" }, { status: 400 }));
|
|
2133
|
+
if (!getUserNetworkRole(restAuth.userId, dmNet)) {
|
|
2134
|
+
return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
|
|
2135
|
+
}
|
|
2136
|
+
if (url.pathname === "/api/dm/threads") {
|
|
2137
|
+
return withCors(req, Response.json({ ok: true, network_id: dmNet, threads: listDmThreads(dmNet, restAuth.userId) }));
|
|
2138
|
+
}
|
|
2139
|
+
const other = url.searchParams.get("with") ?? "";
|
|
2140
|
+
if (!other) return withCors(req, Response.json({ ok: false, error: "with_required" }, { status: 400 }));
|
|
2141
|
+
const limit = Number(url.searchParams.get("limit")) || 50;
|
|
2142
|
+
return withCors(req, Response.json({ ok: true, network_id: dmNet, with: other, messages: listDmThread(dmNet, restAuth.userId, other, limit, url.searchParams.get("before")) }));
|
|
2143
|
+
}
|
|
2144
|
+
return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
const requirementsResponse = await handleRequirementsRequest({
|
|
2148
|
+
req,
|
|
2149
|
+
url,
|
|
2150
|
+
auth: restAuth,
|
|
2151
|
+
isAdmin,
|
|
2152
|
+
isNodeToken: requestToken(req).startsWith("ntok_"),
|
|
2153
|
+
scope: restScope,
|
|
2154
|
+
});
|
|
2155
|
+
if (requirementsResponse) return withCors(req, requirementsResponse);
|
|
2156
|
+
|
|
2157
|
+
// ── REST: per-route timing (ops-only, same gate as /api/stats/sse) ──
|
|
2158
|
+
// ?minutes=N(默认 15,最多 1440):最近 N 分钟里每个路由的次数、总耗时、p95、最大、字节。
|
|
2159
|
+
// &by=caller:每个路由再带 callers —— 令牌种类 + UA 家族的次数 / 字节(route-timing.ts callerClass)。不带时输出逐字节不变。
|
|
2160
|
+
if (url.pathname === "/api/stats/routes" && req.method === "GET") {
|
|
2161
|
+
if (restAuth && !isAdmin) {
|
|
2162
|
+
return withCors(req, Response.json({ ok: false, error: "admin or master token required" }, { status: 403 }));
|
|
2163
|
+
}
|
|
2164
|
+
const minutes = Math.min(Math.max(Number(url.searchParams.get("minutes")) || 15, 1), 1440);
|
|
2165
|
+
const byCaller = url.searchParams.get("by") === "caller";
|
|
2166
|
+
return withCors(req, Response.json({ ok: true, ...routeStats(minutes * 60_000, Date.now(), { byCaller }) }));
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
// ── #473 REST: SSE connection detail (ops-only) ──
|
|
2170
|
+
// The per-key breakdown /health used to expose anonymously: keys are
|
|
2171
|
+
// `{networkId}:{alias}` (+ `\0netobs:{networkId}` observer keys via
|
|
2172
|
+
// printableKey), i.e. the full network/agent topology. Ops-scoped:
|
|
2173
|
+
// legacy master token (restAuth null once requireAuth passed) or an
|
|
2174
|
+
// admin user. Regular members get 403 — the detail spans EVERY
|
|
2175
|
+
// network, so per-network filtering would be required to open it
|
|
2176
|
+
// wider; no consumer needs that today.
|
|
2177
|
+
if (url.pathname === "/api/stats/sse") {
|
|
2178
|
+
// restAuth === null here means the caller passed the legacy master
|
|
2179
|
+
// token (requireAuth already accepted it above) OR the hub runs in
|
|
2180
|
+
// DEV_OPEN mode. Both are treated as ops: master token is root, and
|
|
2181
|
+
// under DEV_OPEN this endpoint is anonymously readable BY DESIGN
|
|
2182
|
+
// (dev convenience — production never sets COMMHUB_DEV_OPEN, so the
|
|
2183
|
+
// topology detail stays gated in every real deployment).
|
|
2184
|
+
// 节点令牌:只看它绑定网络的那部分(投递可达性探针要用);不再因签发者是管理员而看到全部网络。
|
|
2185
|
+
if (restAuth?.networkId && !isAdmin) {
|
|
2186
|
+
const bound = restAuth.networkId;
|
|
2187
|
+
const sse = getSSEStats();
|
|
2188
|
+
const sessions = Object.fromEntries(Object.entries(sse.sessions).filter(([key]) => key.split(":").includes(bound)));
|
|
2189
|
+
return withCors(req, Response.json({ ok: true, total: Object.values(sessions).reduce((a, b) => a + b, 0), sessions, scope: "network", network_id: bound }));
|
|
2190
|
+
}
|
|
2191
|
+
if (restAuth && !isAdmin) {
|
|
2192
|
+
return withCors(req, Response.json({ ok: false, error: "admin or master token required" }, { status: 403 }));
|
|
2193
|
+
}
|
|
2194
|
+
const sse = getSSEStats();
|
|
2195
|
+
return withCors(req, Response.json({ ok: true, total: sse.total, sessions: sse.sessions }));
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
// ── REST: all sessions status ──
|
|
2199
|
+
if (url.pathname === "/api/status") {
|
|
2200
|
+
// Round-2/4 review ③: stale-marking moved to startStaleSessionSweeper()
|
|
2201
|
+
// (background timer, ~60s cadence). Read paths no longer fire UPDATE.
|
|
2202
|
+
// Per-request UPDATE was a write-amp vector — see stale-sweeper.ts.
|
|
2203
|
+
cleanupCommittedRenameSessions(restScope.networkId ? [restScope.networkId] : restScope.networkIds ?? null);
|
|
2204
|
+
// `?light=1` returns a narrow projection (alias / status / agent / task /
|
|
2205
|
+
// server / updated_at + runtime + network_id) — used by the mobile APP
|
|
2206
|
+
// list view, where pulling 30+ fields × 150 agents was making cold open
|
|
2207
|
+
// take 12s+ on flaky cellular (Vincent tg, #220 PWA). Default response
|
|
2208
|
+
// is unchanged so the dashboard / scripts that read full telemetry are
|
|
2209
|
+
// unaffected.
|
|
2210
|
+
const isLight = url.searchParams.get("light") === "1";
|
|
2211
|
+
const nodeIdFilter = url.searchParams.get("node_id")?.trim();
|
|
2212
|
+
const params: any[] = [];
|
|
2213
|
+
let sql = isLight
|
|
2214
|
+
? `SELECT alias, status, agent, task, server, updated_at, network_id${nodeIdFilter ? ", node_id" : ""} FROM sessions WHERE 1=1`
|
|
2215
|
+
: `SELECT ${SESSION_REST_SELECT} FROM sessions WHERE 1=1`;
|
|
2216
|
+
sql = addAgentNetworkScope(sql, params, restScope, { alias: "alias", nodeId: "node_id" });
|
|
2217
|
+
// `?alias=<name>` narrows to that one agent's rows (same projection, same scope). The app's
|
|
2218
|
+
// chat info / node detail read the full projection for ONE agent; unfiltered that is every
|
|
2219
|
+
// session in the network (533 KB, 94 KB gzip on the production network) per open and per
|
|
2220
|
+
// 10 s poll. Older hubs ignore the parameter and return every row — the app still picks its
|
|
2221
|
+
// row by alias, so old app × new hub and new app × old hub both keep working.
|
|
2222
|
+
const aliasFilter = url.searchParams.get("alias")?.trim();
|
|
2223
|
+
if (aliasFilter) {
|
|
2224
|
+
params.push(aliasFilter);
|
|
2225
|
+
sql += ` AND alias = ?${params.length}`;
|
|
2226
|
+
}
|
|
2227
|
+
// `?node_id=<id>` narrows to one node's rows the same way (same scope, same projection). Every
|
|
2228
|
+
// agent-node resolves its own current alias every 30 s; unfiltered that is the whole network's
|
|
2229
|
+
// full projection (~91 KB gzip per call on production) to read one row. Advertised in /health
|
|
2230
|
+
// `capabilities` ("status_node_id") because old hubs ignore the parameter and — in the light
|
|
2231
|
+
// projection, which has no node_id — the caller could not tell its row from anyone else's.
|
|
2232
|
+
if (nodeIdFilter) {
|
|
2233
|
+
params.push(nodeIdFilter);
|
|
2234
|
+
sql += ` AND node_id = ?${params.length}`;
|
|
2235
|
+
}
|
|
2236
|
+
sql += " ORDER BY updated_at DESC";
|
|
2237
|
+
// `model` comes straight from the explicit sessions projection; `runtime` is
|
|
2238
|
+
// derived from the raw `agent` field. Both default to null for old nodes
|
|
2239
|
+
// that never reported a model — the dashboard falls back to a placeholder.
|
|
2240
|
+
const sessions = db.all(sql, ...params).map((s: any) => {
|
|
2241
|
+
if (isLight) {
|
|
2242
|
+
return {
|
|
2243
|
+
alias: s.alias,
|
|
2244
|
+
status: s.status,
|
|
2245
|
+
agent: s.agent ?? null,
|
|
2246
|
+
// 列表预览只要一行;全文 task 占 light 载荷 52%(294 节点 128 KB)。全量 /api/status 不变。
|
|
2247
|
+
task: trimLightTask(s.task),
|
|
2248
|
+
server: s.server ?? null,
|
|
2249
|
+
updated_at: s.updated_at ?? null,
|
|
2250
|
+
runtime: normalizeRuntime(s.agent),
|
|
2251
|
+
network_id: s.network_id ?? null,
|
|
2252
|
+
// Only when filtering by node_id, so the unfiltered light response keeps its exact bytes.
|
|
2253
|
+
...(nodeIdFilter ? { node_id: s.node_id ?? null } : {}),
|
|
2254
|
+
};
|
|
2255
|
+
}
|
|
2256
|
+
const externalSchedules = (() => {
|
|
2257
|
+
if (typeof s.external_schedules !== "string") return null;
|
|
2258
|
+
try { return JSON.parse(s.external_schedules); } catch { return null; }
|
|
2259
|
+
})();
|
|
2260
|
+
const { external_schedules: _externalSchedulesJson, ...publicSession } = s;
|
|
2261
|
+
return {
|
|
2262
|
+
...publicSession,
|
|
2263
|
+
external_schedules: externalSchedules,
|
|
2264
|
+
// app#225 follow-up — sqlite INTEGER 0/1 → boolean on the wire.
|
|
2265
|
+
rules_file_capable: s.rules_file_capable === 1,
|
|
2266
|
+
skills_capable: s.skills_capable === 1,
|
|
2267
|
+
files_capable: s.files_capable === 1,
|
|
2268
|
+
logs_capable: s.logs_capable === 1,
|
|
2269
|
+
model: s.model ?? null,
|
|
2270
|
+
runtime: normalizeRuntime(s.agent),
|
|
2271
|
+
host: {
|
|
2272
|
+
hostname: s.hostname ?? null,
|
|
2273
|
+
ip: s.ip ?? null,
|
|
2274
|
+
cpu_load_1min: s.cpu_load_1min ?? null,
|
|
2275
|
+
cpu_cores: s.cpu_cores ?? null,
|
|
2276
|
+
mem_total_gb: s.mem_total_gb ?? null,
|
|
2277
|
+
mem_used_gb: s.mem_used_gb ?? null,
|
|
2278
|
+
mem_avail_gb: s.mem_avail_gb ?? null,
|
|
2279
|
+
disk_total_gb: s.disk_total_gb ?? null,
|
|
2280
|
+
disk_used_gb: s.disk_used_gb ?? null,
|
|
2281
|
+
disk_avail_gb: s.disk_avail_gb ?? null,
|
|
2282
|
+
},
|
|
2283
|
+
process_telemetry: {
|
|
2284
|
+
rss_bytes: s.process_rss_bytes ?? null,
|
|
2285
|
+
rss_mb: s.process_rss_mb ?? null,
|
|
2286
|
+
cpu_pct: s.process_cpu_pct ?? null,
|
|
2287
|
+
uptime_seconds: s.process_uptime_seconds ?? null,
|
|
2288
|
+
in_flight_count: s.process_in_flight_count ?? null,
|
|
2289
|
+
},
|
|
2290
|
+
};
|
|
2291
|
+
});
|
|
2292
|
+
const summary = sessions.reduce((acc: any, session: any) => {
|
|
2293
|
+
const raw = String(session.status || "").toLowerCase();
|
|
2294
|
+
if (raw === "offline") acc.offline++;
|
|
2295
|
+
else if (["working", "blocked", "error", "waiting_input", "running", "busy"].includes(raw)) acc.working++;
|
|
2296
|
+
else acc.idle++;
|
|
2297
|
+
return acc;
|
|
2298
|
+
}, { idle: 0, working: 0, offline: 0, total: sessions.length });
|
|
2299
|
+
return withCors(req, Response.json({ ok: true, sessions, summary }));
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
// ── REST: aggregate agents by physical server ──
|
|
2303
|
+
if (url.pathname === "/api/servers") {
|
|
2304
|
+
// Round-2/4 review ③: stale-marking moved to background sweeper.
|
|
2305
|
+
const params: any[] = [];
|
|
2306
|
+
let sql = `
|
|
2307
|
+
SELECT hostname, ip, cpu_load_1min, cpu_cores, mem_avail_gb, mem_used_gb,
|
|
2308
|
+
COALESCE(last_seen_at, updated_at) AS last_seen
|
|
2309
|
+
FROM sessions
|
|
2310
|
+
WHERE 1=1
|
|
2311
|
+
`;
|
|
2312
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
2313
|
+
sql += " ORDER BY COALESCE(last_seen_at, updated_at) DESC";
|
|
2314
|
+
|
|
2315
|
+
const grouped = new Map<string, {
|
|
2316
|
+
hostname: string;
|
|
2317
|
+
ip: string;
|
|
2318
|
+
agent_count: number;
|
|
2319
|
+
cpu_load_1min: number | null;
|
|
2320
|
+
cpu_cores: number | null;
|
|
2321
|
+
mem_avail_gb: number | null;
|
|
2322
|
+
mem_used_gb: number | null;
|
|
2323
|
+
last_seen: string | null;
|
|
2324
|
+
}>();
|
|
2325
|
+
|
|
2326
|
+
const preferDisplayIp = (current: string, next: string) => {
|
|
2327
|
+
const isWeak = (ip: string) => !ip || ip === "unknown" || ip === "127.0.0.1" || ip === "::1";
|
|
2328
|
+
if (isWeak(current) && !isWeak(next)) return next;
|
|
2329
|
+
return current;
|
|
2330
|
+
};
|
|
2331
|
+
const hasHostTelemetry = (row: any) =>
|
|
2332
|
+
row.cpu_load_1min != null || row.cpu_cores != null || row.mem_avail_gb != null || row.mem_used_gb != null;
|
|
2333
|
+
|
|
2334
|
+
for (const row of db.all<any>(sql, ...params)) {
|
|
2335
|
+
const hostname = row.hostname || "unknown";
|
|
2336
|
+
const ip = row.ip || "unknown";
|
|
2337
|
+
// Group primarily by hostname. A single host can report both a
|
|
2338
|
+
// routable/container IP and loopback (127.0.0.1); splitting those
|
|
2339
|
+
// into separate cards makes the dashboard show one useful load row
|
|
2340
|
+
// plus one "n/a" duplicate. Unknown hostnames still fall back to IP.
|
|
2341
|
+
const key = hostname !== "unknown" ? `host:${hostname}` : `ip:${ip}`;
|
|
2342
|
+
const existing = grouped.get(key);
|
|
2343
|
+
if (existing) {
|
|
2344
|
+
existing.agent_count += 1;
|
|
2345
|
+
existing.ip = preferDisplayIp(existing.ip, ip);
|
|
2346
|
+
if (parseSqliteTime(row.last_seen) > parseSqliteTime(existing.last_seen)) existing.last_seen = row.last_seen ?? existing.last_seen;
|
|
2347
|
+
if (hasHostTelemetry(row) && (
|
|
2348
|
+
existing.cpu_load_1min == null ||
|
|
2349
|
+
existing.cpu_cores == null ||
|
|
2350
|
+
existing.mem_avail_gb == null ||
|
|
2351
|
+
existing.mem_used_gb == null
|
|
2352
|
+
)) {
|
|
2353
|
+
existing.cpu_load_1min = row.cpu_load_1min ?? existing.cpu_load_1min;
|
|
2354
|
+
existing.cpu_cores = row.cpu_cores ?? existing.cpu_cores;
|
|
2355
|
+
existing.mem_avail_gb = row.mem_avail_gb ?? existing.mem_avail_gb;
|
|
2356
|
+
existing.mem_used_gb = row.mem_used_gb ?? existing.mem_used_gb;
|
|
2357
|
+
}
|
|
2358
|
+
continue;
|
|
2359
|
+
}
|
|
2360
|
+
grouped.set(key, {
|
|
2361
|
+
hostname,
|
|
2362
|
+
ip,
|
|
2363
|
+
agent_count: 1,
|
|
2364
|
+
cpu_load_1min: row.cpu_load_1min ?? null,
|
|
2365
|
+
cpu_cores: row.cpu_cores ?? null,
|
|
2366
|
+
mem_avail_gb: row.mem_avail_gb ?? null,
|
|
2367
|
+
mem_used_gb: row.mem_used_gb ?? null,
|
|
2368
|
+
last_seen: row.last_seen ?? null,
|
|
2369
|
+
});
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
return withCors(req, Response.json(Array.from(grouped.values())));
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
const serverDetailMatch = url.pathname.match(/^\/api\/server\/([^/]+)\/(health|agents)$/);
|
|
2376
|
+
if (serverDetailMatch && req.method === "GET") {
|
|
2377
|
+
const host = decodeURIComponent(serverDetailMatch[1]);
|
|
2378
|
+
const detailKind = serverDetailMatch[2];
|
|
2379
|
+
if (!host) return withCors(req, Response.json({ ok: false, error: "host required" }, { status: 400 }));
|
|
2380
|
+
|
|
2381
|
+
// Round-2/4 review ③: stale-marking moved to background sweeper.
|
|
2382
|
+
if (detailKind === "agents") {
|
|
2383
|
+
const params: any[] = [host, host];
|
|
2384
|
+
let sql = `
|
|
2385
|
+
SELECT alias, agent, status, task, progress, model, hostname, ip, external_schedules,
|
|
2386
|
+
cpu_load_1min, cpu_cores, mem_avail_gb, mem_used_gb, mem_total_gb,
|
|
2387
|
+
disk_avail_gb, disk_used_gb, disk_total_gb,
|
|
2388
|
+
process_rss_bytes, process_rss_mb, process_cpu_pct, process_uptime_seconds, process_in_flight_count,
|
|
2389
|
+
COALESCE(last_seen_at, updated_at) AS last_seen
|
|
2390
|
+
FROM sessions
|
|
2391
|
+
WHERE (hostname = ?1 OR ip = ?2)
|
|
2392
|
+
`;
|
|
2393
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
2394
|
+
sql += " ORDER BY alias";
|
|
2395
|
+
const agents = db.all<any>(sql, ...params).map((s) => ({
|
|
2396
|
+
alias: s.alias,
|
|
2397
|
+
runtime: normalizeRuntime(s.agent),
|
|
2398
|
+
raw_agent: s.agent ?? null,
|
|
2399
|
+
model: s.model ?? null,
|
|
2400
|
+
status: s.status ?? "offline",
|
|
2401
|
+
task: s.task ?? null,
|
|
2402
|
+
progress: s.progress ?? 0,
|
|
2403
|
+
external_schedules: (() => {
|
|
2404
|
+
if (typeof s.external_schedules !== "string") return null;
|
|
2405
|
+
try { return JSON.parse(s.external_schedules); } catch { return null; }
|
|
2406
|
+
})(),
|
|
2407
|
+
last_seen: s.last_seen ?? null,
|
|
2408
|
+
health: agentHealthChip(s.status, s.last_seen),
|
|
2409
|
+
hostname: s.hostname ?? null,
|
|
2410
|
+
ip: s.ip ?? null,
|
|
2411
|
+
telemetry: {
|
|
2412
|
+
cpu_load_1min: s.cpu_load_1min ?? null,
|
|
2413
|
+
cpu_cores: s.cpu_cores ?? null,
|
|
2414
|
+
cpu_pct: cpuPct(s.cpu_load_1min, s.cpu_cores),
|
|
2415
|
+
mem_total_gb: s.mem_total_gb ?? null,
|
|
2416
|
+
mem_used_gb: s.mem_used_gb ?? null,
|
|
2417
|
+
mem_avail_gb: s.mem_avail_gb ?? null,
|
|
2418
|
+
disk_total_gb: s.disk_total_gb ?? null,
|
|
2419
|
+
disk_used_gb: s.disk_used_gb ?? null,
|
|
2420
|
+
disk_avail_gb: s.disk_avail_gb ?? null,
|
|
2421
|
+
process_rss_bytes: s.process_rss_bytes ?? null,
|
|
2422
|
+
process_rss_mb: s.process_rss_mb ?? null,
|
|
2423
|
+
process_cpu_pct: s.process_cpu_pct ?? null,
|
|
2424
|
+
process_uptime_seconds: s.process_uptime_seconds ?? null,
|
|
2425
|
+
process_in_flight_count: s.process_in_flight_count ?? null,
|
|
2426
|
+
},
|
|
2427
|
+
process_telemetry: {
|
|
2428
|
+
rss_bytes: s.process_rss_bytes ?? null,
|
|
2429
|
+
rss_mb: s.process_rss_mb ?? null,
|
|
2430
|
+
cpu_pct: s.process_cpu_pct ?? null,
|
|
2431
|
+
uptime_seconds: s.process_uptime_seconds ?? null,
|
|
2432
|
+
in_flight_count: s.process_in_flight_count ?? null,
|
|
2433
|
+
},
|
|
2434
|
+
}));
|
|
2435
|
+
if (agents.length === 0) return withCors(req, Response.json({ ok: false, error: "server not found" }, { status: 404 }));
|
|
2436
|
+
return withCors(req, Response.json({ ok: true, host, agent_count: agents.length, agents }));
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
const latestParams: any[] = [host, host];
|
|
2440
|
+
let latestSql = `
|
|
2441
|
+
SELECT hostname, ip, COUNT(*) OVER () AS agent_count,
|
|
2442
|
+
cpu_load_1min, cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb,
|
|
2443
|
+
disk_total_gb, disk_used_gb, disk_avail_gb,
|
|
2444
|
+
COALESCE(last_seen_at, updated_at) AS last_seen
|
|
2445
|
+
FROM sessions
|
|
2446
|
+
WHERE (hostname = ?1 OR ip = ?2)
|
|
2447
|
+
`;
|
|
2448
|
+
latestSql = addNetworkScope(latestSql, latestParams, restScope);
|
|
2449
|
+
latestSql += " ORDER BY COALESCE(last_seen_at, updated_at) DESC LIMIT 1";
|
|
2450
|
+
const latest = db.get<any>(latestSql, ...latestParams);
|
|
2451
|
+
if (!latest) return withCors(req, Response.json({ ok: false, error: "server not found" }, { status: 404 }));
|
|
2452
|
+
|
|
2453
|
+
const since24h = sqliteTime(new Date(Date.now() - 24 * 60 * 60 * 1000));
|
|
2454
|
+
const histParams: any[] = [host, host, since24h];
|
|
2455
|
+
let histSql = `
|
|
2456
|
+
SELECT created_at, cpu_load_1min, cpu_cores, mem_total_gb, mem_used_gb, mem_avail_gb,
|
|
2457
|
+
disk_total_gb, disk_used_gb, disk_avail_gb
|
|
2458
|
+
FROM agent_telemetry
|
|
2459
|
+
WHERE (hostname = ?1 OR ip = ?2) AND created_at >= ?3
|
|
2460
|
+
`;
|
|
2461
|
+
histSql = addNetworkScope(histSql, histParams, restScope);
|
|
2462
|
+
histSql += " ORDER BY created_at ASC";
|
|
2463
|
+
const historyRows = db.all<any>(histSql, ...histParams);
|
|
2464
|
+
const now = Date.now();
|
|
2465
|
+
const alert = serverAlertLevel(latest);
|
|
2466
|
+
|
|
2467
|
+
return withCors(req, Response.json({
|
|
2468
|
+
ok: true,
|
|
2469
|
+
host,
|
|
2470
|
+
hostname: latest.hostname ?? null,
|
|
2471
|
+
ip: latest.ip ?? null,
|
|
2472
|
+
agent_count: latest.agent_count ?? 0,
|
|
2473
|
+
alert_level: alert.level,
|
|
2474
|
+
alerts: alert.alerts,
|
|
2475
|
+
latest: {
|
|
2476
|
+
cpu_load_1min: latest.cpu_load_1min ?? null,
|
|
2477
|
+
cpu_cores: latest.cpu_cores ?? null,
|
|
2478
|
+
cpu_pct: cpuPct(latest.cpu_load_1min, latest.cpu_cores),
|
|
2479
|
+
mem_total_gb: latest.mem_total_gb ?? null,
|
|
2480
|
+
mem_used_gb: latest.mem_used_gb ?? null,
|
|
2481
|
+
mem_avail_gb: latest.mem_avail_gb ?? null,
|
|
2482
|
+
disk_total_gb: latest.disk_total_gb ?? null,
|
|
2483
|
+
disk_used_gb: latest.disk_used_gb ?? null,
|
|
2484
|
+
disk_avail_gb: latest.disk_avail_gb ?? null,
|
|
2485
|
+
last_seen: latest.last_seen ?? null,
|
|
2486
|
+
},
|
|
2487
|
+
history: {
|
|
2488
|
+
"5m": bucketTelemetry(historyRows, now - 5 * 60 * 1000, 60 * 1000),
|
|
2489
|
+
"1h": bucketTelemetry(historyRows, now - 60 * 60 * 1000, 5 * 60 * 1000),
|
|
2490
|
+
"24h": bucketTelemetry(historyRows, now - 24 * 60 * 60 * 1000, 60 * 60 * 1000),
|
|
2491
|
+
},
|
|
2492
|
+
}));
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
// ── REST: file upload (#221) ──
|
|
2496
|
+
// POST multipart/form-data with a single `file` field. Bearer auth.
|
|
2497
|
+
// 12 MiB cap (two-stage: Content-Length pre-check + post-parse size
|
|
2498
|
+
// verify). Returns { ok, file_id, path, url, size, mime }. The
|
|
2499
|
+
// file_id is server-generated (crypto.randomUUID with hyphens
|
|
2500
|
+
// stripped) so a client-supplied filename never enters the storage
|
|
2501
|
+
// path. The download URL `/api/files/<file_id>` requires the same
|
|
2502
|
+
// Bearer auth and forces Content-Disposition: attachment +
|
|
2503
|
+
// X-Content-Type-Options: nosniff so uploaded files can never be
|
|
2504
|
+
// executed or served as HTML.
|
|
2505
|
+
if (url.pathname === "/api/upload" && req.method === "POST") {
|
|
2506
|
+
// #503 Finding 3 = Option A (lead 40be9845): every 4xx returned
|
|
2507
|
+
// BEFORE `req.formData()` drains the request body MUST include
|
|
2508
|
+
// `Connection: close`. Otherwise the client's still-inbound bytes
|
|
2509
|
+
// land on a keepalive connection the server already answered on,
|
|
2510
|
+
// poisoning the next pooled request. Bug is pre-existing (411/
|
|
2511
|
+
// 413/415/429/401 all have this shape) — the #503 authz block
|
|
2512
|
+
// just made it visible under aggregate `bun test` with a 12MiB
|
|
2513
|
+
// upload immediately followed by another request. Per-file green
|
|
2514
|
+
// hides this class of bug.
|
|
2515
|
+
const earlyReject = (res: Response): Response => {
|
|
2516
|
+
const wrapped = withCors(req, res);
|
|
2517
|
+
wrapped.headers.set("Connection", "close");
|
|
2518
|
+
return wrapped;
|
|
2519
|
+
};
|
|
2520
|
+
const authErr = requireAuth(req);
|
|
2521
|
+
if (authErr) return earlyReject(authErr);
|
|
2522
|
+
|
|
2523
|
+
// Rate-limit key prefers the token id (so 60/h is per-token, not
|
|
2524
|
+
// per-IP); falls back to IP for legacy or anonymous-but-dev-open.
|
|
2525
|
+
const authCtx = resolveRequestAuth(req);
|
|
2526
|
+
const rateKey = authCtx?.tokenId ?? `ip:${getClientIP(req, server)}`;
|
|
2527
|
+
const rate = sharedUploadRateLimiter.check(rateKey);
|
|
2528
|
+
if (!rate.allowed) {
|
|
2529
|
+
const headers: Record<string, string> = {
|
|
2530
|
+
"X-RateLimit-Limit": String(60),
|
|
2531
|
+
"X-RateLimit-Remaining": "0",
|
|
2532
|
+
"X-RateLimit-Reset": String(Math.floor(rate.resetAt / 1000)),
|
|
2533
|
+
};
|
|
2534
|
+
if (rate.retryAfterMs) headers["Retry-After"] = String(Math.ceil(rate.retryAfterMs / 1000));
|
|
2535
|
+
return earlyReject(new Response(
|
|
2536
|
+
JSON.stringify({ ok: false, error: "rate_limited", message: "Upload rate limit exceeded (60/hour). Try again later.", retry_after_ms: rate.retryAfterMs }),
|
|
2537
|
+
{ status: 429, headers: { ...headers, "Content-Type": "application/json; charset=utf-8" } }, // #426 — legacy clients default to ISO-8859-1 without charset
|
|
2538
|
+
));
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
// Two-stage size cap per 通信龙 dispatch c38de7a9:
|
|
2542
|
+
// stage 1 — reject before reading body when Content-Length
|
|
2543
|
+
// exceeds the cap (saves bandwidth, fails fast)
|
|
2544
|
+
// stage 2 — re-verify the parsed File.size after Bun has parsed
|
|
2545
|
+
// the multipart envelope (defends against a missing
|
|
2546
|
+
// or lied Content-Length header)
|
|
2547
|
+
const contentLength = req.headers.get("Content-Length");
|
|
2548
|
+
if (!contentLength) {
|
|
2549
|
+
return earlyReject(Response.json(
|
|
2550
|
+
{ ok: false, error: "length_required", message: "Content-Length header is required for /api/upload" },
|
|
2551
|
+
{ status: 411 },
|
|
2552
|
+
));
|
|
2553
|
+
}
|
|
2554
|
+
const declaredBytes = Number(contentLength);
|
|
2555
|
+
if (!Number.isFinite(declaredBytes) || declaredBytes < 0) {
|
|
2556
|
+
return earlyReject(Response.json({ ok: false, error: "bad_content_length" }, { status: 400 }));
|
|
2557
|
+
}
|
|
2558
|
+
if (declaredBytes > MAX_REQUEST_CONTENT_LENGTH) {
|
|
2559
|
+
return earlyReject(Response.json(
|
|
2560
|
+
{ ok: false, error: "payload_too_large", message: `Upload exceeds the ${MAX_UPLOAD_BYTES} byte limit`, limit_bytes: MAX_UPLOAD_BYTES },
|
|
2561
|
+
{ status: 413 },
|
|
2562
|
+
));
|
|
2563
|
+
}
|
|
2564
|
+
|
|
2565
|
+
// #503 — decide which network this upload is attributed to, then
|
|
2566
|
+
// check the caller may write there. Attribution and authorization
|
|
2567
|
+
// are two questions; neither substitutes for the other. Placed
|
|
2568
|
+
// after the in-memory rate limiter (above) and before the body is
|
|
2569
|
+
// read, so an unauthorized caller never gets a DB query per byte
|
|
2570
|
+
// uploaded and never gets their multipart envelope parsed.
|
|
2571
|
+
const principal = resolvePrincipal(req);
|
|
2572
|
+
const requestedNetId = url.searchParams.get("network_id");
|
|
2573
|
+
// ?purpose=dm:这个文件是为一条人与人私信传的(见 UploadIndexEntry.scope)。缺省 = 旧行为。
|
|
2574
|
+
// 拼错的值拒掉,不静默当成缺省 —— 否则私信图会不声不响地变成全网可见。
|
|
2575
|
+
const purpose = url.searchParams.get("purpose");
|
|
2576
|
+
if (purpose !== null && purpose !== DM_FILE_SCOPE) {
|
|
2577
|
+
return earlyReject(Response.json({ ok: false, error: "bad_purpose", message: `purpose must be "${DM_FILE_SCOPE}" or absent` }, { status: 400 }));
|
|
2578
|
+
}
|
|
2579
|
+
let uploadNetId: string | null = null;
|
|
2580
|
+
|
|
2581
|
+
switch (principal.kind) {
|
|
2582
|
+
case "anonymous":
|
|
2583
|
+
// Unreachable: requireAuth above already 401s. Kept so the
|
|
2584
|
+
// switch stays exhaustive and a future auth change fails loud.
|
|
2585
|
+
return earlyReject(Response.json({ ok: false, error: "auth_required" }, { status: 401 }));
|
|
2586
|
+
case "dev-open-anon":
|
|
2587
|
+
// Local dev only. Deliberately does NOT claim a real network,
|
|
2588
|
+
// even if one was requested — an unauthenticated caller must
|
|
2589
|
+
// not be able to file blobs into a tenant's network.
|
|
2590
|
+
uploadNetId = null;
|
|
2591
|
+
break;
|
|
2592
|
+
case "legacy-master":
|
|
2593
|
+
// Unreachable today: requireAuth 401s master tokens on every
|
|
2594
|
+
// non-GET /api/ request (RFC-001 made them read-only). Kept
|
|
2595
|
+
// fail-closed so relaxing that rule cannot silently start
|
|
2596
|
+
// producing unattributed blobs.
|
|
2597
|
+
if (!requestedNetId) {
|
|
2598
|
+
return earlyReject(Response.json({
|
|
2599
|
+
ok: false, error: "network_id_required",
|
|
2600
|
+
message: "legacy master uploads must specify a network_id query param",
|
|
2601
|
+
}, { status: 400 }));
|
|
2602
|
+
}
|
|
2603
|
+
uploadNetId = requestedNetId;
|
|
2604
|
+
break;
|
|
2605
|
+
case "ntok":
|
|
2606
|
+
if (requestedNetId && requestedNetId !== principal.boundNetworkId) {
|
|
2607
|
+
return earlyReject(Response.json({
|
|
2608
|
+
ok: false, error: "network_id_conflict",
|
|
2609
|
+
message: "network_id query param conflicts with the token-bound network",
|
|
2610
|
+
}, { status: 400 }));
|
|
2611
|
+
}
|
|
2612
|
+
uploadNetId = principal.boundNetworkId;
|
|
2613
|
+
break;
|
|
2614
|
+
case "admin-utok": {
|
|
2615
|
+
// #503 Finding 2 = Option F (lead 40be9845): admin follows the same
|
|
2616
|
+
// "auto-derive when unambiguous / require param when ambiguous" rule
|
|
2617
|
+
// as utok_. Rule 4's REASON (no unowned files) is preserved — the
|
|
2618
|
+
// derived network is a real membership. Strict on genuine ambiguity:
|
|
2619
|
+
// 0 or ≥2 memberships → 400. Preserves prod upload contract (in
|
|
2620
|
+
// prod admin is in exactly 1 network; auto-derive succeeds).
|
|
2621
|
+
if (requestedNetId) {
|
|
2622
|
+
// Admin bypasses membership; network existence validated below.
|
|
2623
|
+
uploadNetId = requestedNetId;
|
|
2624
|
+
break;
|
|
2625
|
+
}
|
|
2626
|
+
const adminNetworks = getUserNetworkIds(principal.userId);
|
|
2627
|
+
if (adminNetworks.length === 1) {
|
|
2628
|
+
uploadNetId = adminNetworks[0];
|
|
2629
|
+
} else {
|
|
2630
|
+
return earlyReject(Response.json({
|
|
2631
|
+
ok: false, error: "network_id_required",
|
|
2632
|
+
message: "admin has 0 or ≥2 network memberships; network_id query param required",
|
|
2633
|
+
}, { status: 400 }));
|
|
2634
|
+
}
|
|
2635
|
+
break;
|
|
2636
|
+
}
|
|
2637
|
+
case "utok": {
|
|
2638
|
+
if (requestedNetId) {
|
|
2639
|
+
// A non-member asking for someone else's network never gets
|
|
2640
|
+
// here: the REST scope guard (`restScope.denied`) already
|
|
2641
|
+
// 403'd, with the same body whether the network exists or
|
|
2642
|
+
// not. Re-checking membership here would be a second, drifting
|
|
2643
|
+
// copy of a decision this codebase makes in one place.
|
|
2644
|
+
uploadNetId = requestedNetId;
|
|
2645
|
+
break;
|
|
2646
|
+
}
|
|
2647
|
+
uploadNetId = singleNetworkId(resolveRestNetworkScope(url.searchParams.get("network_id"), authCtx, false));
|
|
2648
|
+
if (!uploadNetId) {
|
|
2649
|
+
return earlyReject(Response.json({
|
|
2650
|
+
ok: false, error: "network_id_required",
|
|
2651
|
+
message: "network_id query param required for a multi-network user token; 'first network' is not assumed",
|
|
2652
|
+
}, { status: 400 }));
|
|
2653
|
+
}
|
|
2654
|
+
break;
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
if (uploadNetId !== null) {
|
|
2659
|
+
const networkRow = db.get<any>("SELECT network_id FROM networks WHERE network_id = ?1", uploadNetId);
|
|
2660
|
+
if (!networkRow) {
|
|
2661
|
+
if (principal.kind === "admin-utok" || principal.kind === "legacy-master") {
|
|
2662
|
+
return earlyReject(Response.json({
|
|
2663
|
+
ok: false, error: "unknown_network",
|
|
2664
|
+
message: "network_id does not exist",
|
|
2665
|
+
}, { status: 400 }));
|
|
2666
|
+
}
|
|
2667
|
+
// Byte-identical to the non-member 404 above: distinguishing
|
|
2668
|
+
// the two would hand an unprivileged caller a network-existence
|
|
2669
|
+
// oracle.
|
|
2670
|
+
return earlyReject(Response.json({ ok: false, error: "not_found" }, { status: 404 }));
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
// 上传是人类侧写入(给私信 / 给授权 Agent 的任务挂附件),受限成员也可以;下载另有授权判定。
|
|
2675
|
+
if (!canRestWriteNetworkAsHuman(authCtx, uploadNetId, principal.kind === "admin-utok")) {
|
|
2676
|
+
return earlyReject(Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
const contentType = req.headers.get("Content-Type") ?? "";
|
|
2680
|
+
if (!/^multipart\/form-data/i.test(contentType)) {
|
|
2681
|
+
return earlyReject(Response.json(
|
|
2682
|
+
{ ok: false, error: "unsupported_media_type", message: "Use multipart/form-data with a 'file' field" },
|
|
2683
|
+
{ status: 415 },
|
|
2684
|
+
));
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
let form: FormData;
|
|
2688
|
+
try {
|
|
2689
|
+
form = await req.formData();
|
|
2690
|
+
} catch (e: any) {
|
|
2691
|
+
return withCors(req, Response.json(
|
|
2692
|
+
{ ok: false, error: "multipart_parse_failed", message: e?.message?.slice(0, 200) ?? "multipart parse failed" },
|
|
2693
|
+
{ status: 400 },
|
|
2694
|
+
));
|
|
2695
|
+
}
|
|
2696
|
+
const file = form.get("file");
|
|
2697
|
+
if (!file || typeof file === "string") {
|
|
2698
|
+
return withCors(req, Response.json(
|
|
2699
|
+
{ ok: false, error: "missing_file", message: "Expected a 'file' field with binary content" },
|
|
2700
|
+
{ status: 400 },
|
|
2701
|
+
));
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
// Stage 2 — re-verify parsed size against the documented cap.
|
|
2705
|
+
if (file.size > MAX_UPLOAD_BYTES) {
|
|
2706
|
+
return withCors(req, Response.json(
|
|
2707
|
+
{ ok: false, error: "payload_too_large", message: `Upload exceeds the ${MAX_UPLOAD_BYTES} byte limit`, limit_bytes: MAX_UPLOAD_BYTES, observed_bytes: file.size },
|
|
2708
|
+
{ status: 413 },
|
|
2709
|
+
));
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2712
|
+
const fileId = generateFileId();
|
|
2713
|
+
const ext = sanitizeExt(file.name);
|
|
2714
|
+
let storage;
|
|
2715
|
+
try {
|
|
2716
|
+
storage = buildStoragePath(fileId, ext);
|
|
2717
|
+
} catch (e: any) {
|
|
2718
|
+
// Should be unreachable — we generated the id ourselves — but
|
|
2719
|
+
// surface as 500 if our own primitives reject our own input.
|
|
2720
|
+
console.error("[/api/upload] buildStoragePath rejected:", e?.message);
|
|
2721
|
+
return withCors(req, Response.json({ ok: false, error: "internal_path_error" }, { status: 500 }));
|
|
2722
|
+
}
|
|
2723
|
+
|
|
2724
|
+
try {
|
|
2725
|
+
mkdirSync(pathDirname(storage.absolutePath), { recursive: true });
|
|
2726
|
+
const buf = Buffer.from(await file.arrayBuffer());
|
|
2727
|
+
// #693 — write blob to .tmp then rename for crash-safe durability.
|
|
2728
|
+
const tmpPath = storage.absolutePath + ".tmp";
|
|
2729
|
+
writeFileSync(tmpPath, buf);
|
|
2730
|
+
// Defense-in-depth: after writing, confirm the path still sits
|
|
2731
|
+
// inside the uploads root (catches a symlink swap or root env
|
|
2732
|
+
// poisoning across processes).
|
|
2733
|
+
if (!isPathInsideUploadsRoot(tmpPath) || !isPathInsideUploadsRoot(storage.absolutePath)) {
|
|
2734
|
+
try { unlinkSync(tmpPath); } catch { /* best-effort */ }
|
|
2735
|
+
console.error("[/api/upload] write escaped uploads root:", storage.absolutePath);
|
|
2736
|
+
return withCors(req, Response.json({ ok: false, error: "internal_path_escape" }, { status: 500 }));
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
// Persist the index entry so /api/files/<file_id> can do an
|
|
2740
|
+
// O(1) lookup without scanning every date bucket.
|
|
2741
|
+
const idxPath = indexEntryPath(fileId);
|
|
2742
|
+
if (idxPath) {
|
|
2743
|
+
mkdirSync(pathDirname(idxPath), { recursive: true });
|
|
2744
|
+
writeFileSync(idxPath, JSON.stringify({
|
|
2745
|
+
file_id: fileId,
|
|
2746
|
+
date_bucket: storage.dateBucket,
|
|
2747
|
+
ext: storage.ext,
|
|
2748
|
+
name: typeof file.name === "string" ? file.name.slice(0, 255) : "",
|
|
2749
|
+
mime: typeof file.type === "string" ? file.type.slice(0, 100) : "application/octet-stream",
|
|
2750
|
+
size: file.size,
|
|
2751
|
+
owner: authCtx?.username ?? null,
|
|
2752
|
+
owner_id: authCtx?.userId ?? null,
|
|
2753
|
+
// Key omitted (not null) when unattributed — see UploadIndexEntry.
|
|
2754
|
+
...(uploadNetId ? { network_id: uploadNetId } : {}),
|
|
2755
|
+
...(purpose === DM_FILE_SCOPE ? { scope: DM_FILE_SCOPE } : {}),
|
|
2756
|
+
uploaded_at: new Date().toISOString(),
|
|
2757
|
+
}, null, 2));
|
|
2758
|
+
} else {
|
|
2759
|
+
// No index path → refuse to leave an unindexed blob.
|
|
2760
|
+
try { unlinkSync(tmpPath); } catch { /* best-effort */ }
|
|
2761
|
+
return withCors(req, Response.json({ ok: false, error: "index_path_error" }, { status: 500 }));
|
|
2762
|
+
}
|
|
2763
|
+
// Index first, then publish blob via rename — if rename fails, drop index.
|
|
2764
|
+
try {
|
|
2765
|
+
renameSync(tmpPath, storage.absolutePath);
|
|
2766
|
+
} catch (renameErr: any) {
|
|
2767
|
+
try { unlinkSync(tmpPath); } catch { /* best-effort */ }
|
|
2768
|
+
try { if (idxPath) unlinkSync(idxPath); } catch { /* best-effort */ }
|
|
2769
|
+
throw renameErr;
|
|
2770
|
+
}
|
|
2771
|
+
} catch (e: any) {
|
|
2772
|
+
console.error("[/api/upload] write failed:", e?.message);
|
|
2773
|
+
return withCors(req, Response.json({ ok: false, error: "write_failed", message: e?.message?.slice(0, 200) ?? "write failed" }, { status: 500 }));
|
|
2774
|
+
}
|
|
2775
|
+
|
|
2776
|
+
return withCors(req, Response.json({
|
|
2777
|
+
ok: true,
|
|
2778
|
+
file_id: fileId,
|
|
2779
|
+
path: storage.absolutePath,
|
|
2780
|
+
url: `/api/files/${fileId}`,
|
|
2781
|
+
size: file.size,
|
|
2782
|
+
mime: typeof file.type === "string" ? file.type : "application/octet-stream",
|
|
2783
|
+
}));
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
// ── REST: file download (#221 + #495 + #500 CR2 HEAD) ──
|
|
2787
|
+
// GET or HEAD /api/files/:file_id with Bearer auth + ownership
|
|
2788
|
+
// check. Always forces Content-Disposition: attachment +
|
|
2789
|
+
// X-Content-Type-Options: nosniff so the served file can never be
|
|
2790
|
+
// executed or rendered as HTML. The file_id is validated against
|
|
2791
|
+
// the same strict regex used at generation time before any
|
|
2792
|
+
// filesystem access.
|
|
2793
|
+
//
|
|
2794
|
+
// Ownership gate is factored into `authorizeFileDownload` (see
|
|
2795
|
+
// src/server.ts near resolveRequestAuth). HEAD MUST route through
|
|
2796
|
+
// the same helper — pre-#500-CR2, HEAD fell through to a fallback
|
|
2797
|
+
// 200 page whose body happened to be byte-identical to the
|
|
2798
|
+
// "unknown file" case, so no enumeration oracle formed in practice.
|
|
2799
|
+
// That was coincidence, not a gate. A change to the fallback (e.g.
|
|
2800
|
+
// adding "Requested resource: <hint>") would materialize the oracle
|
|
2801
|
+
// with zero failing tests. Any future Range, conditional-request,
|
|
2802
|
+
// or dashboard-proxy path for /api/files MUST route through this
|
|
2803
|
+
// handler (via authorizeFileDownload) — do not duplicate the
|
|
2804
|
+
// allow-list inline, or the branches will drift.
|
|
2805
|
+
const fileMatch = url.pathname.match(/^\/api\/files\/(.+)$/);
|
|
2806
|
+
if (fileMatch && (req.method === "GET" || req.method === "HEAD")) {
|
|
2807
|
+
// #501: file credentials must never travel in the URL. Query tokens
|
|
2808
|
+
// leak through browser history, access/proxy logs, copied links, and
|
|
2809
|
+
// Referer. SSE/EventSource compatibility keeps the global query-token
|
|
2810
|
+
// fallback for now; file GET/HEAD deliberately opt out here.
|
|
2811
|
+
const authErr = requireAuth(req, { allowQueryToken: false });
|
|
2812
|
+
if (authErr) return withCors(req, authErr);
|
|
2813
|
+
|
|
2814
|
+
const fileId = decodeURIComponent(fileMatch[1]);
|
|
2815
|
+
if (!FILE_ID_REGEX.test(fileId)) {
|
|
2816
|
+
return withCors(req, Response.json({ ok: false, error: "bad_file_id" }, { status: 400 }));
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2819
|
+
const idxPath = indexEntryPath(fileId);
|
|
2820
|
+
if (!idxPath || !existsSync(idxPath)) {
|
|
2821
|
+
return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
let entry: any;
|
|
2825
|
+
try { entry = JSON.parse(readFileSync(idxPath, "utf-8")); } catch {
|
|
2826
|
+
// #509 副指挥 66983a19: corrupted index must not leak "server
|
|
2827
|
+
// failed" signal — 404 with same shape as unknown file_id
|
|
2828
|
+
// (input refused at boundary, no enumeration).
|
|
2829
|
+
return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
|
|
2830
|
+
}
|
|
2831
|
+
if (!validateIndexEntry(entry)) {
|
|
2832
|
+
// #509 副指挥 66983a19: schema-invalid index (bucket "../etc",
|
|
2833
|
+
// missing fields, wrong types) is also 404 — same discipline.
|
|
2834
|
+
return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
|
|
2835
|
+
}
|
|
2836
|
+
|
|
2837
|
+
// #495 ownership gate — placed AFTER file-exists so denied
|
|
2838
|
+
// callers cannot distinguish "you don't own this" from
|
|
2839
|
+
// "no such file". Both branches return the same 404 shape
|
|
2840
|
+
// for BOTH GET and HEAD (HEAD honours body-omission but the
|
|
2841
|
+
// status code and headers are the authoritative signal).
|
|
2842
|
+
if (!authorizeFileDownload(resolvePrincipal(req, { allowQueryToken: false }), normalizeEntry(entry), fileId)) {
|
|
2843
|
+
return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
|
|
2844
|
+
}
|
|
2845
|
+
|
|
2846
|
+
// #509 defence-in-depth: pre-check date_bucket against BOTH
|
|
2847
|
+
// shape regex AND real-calendar semantics. A poisoned index (e.g.
|
|
2848
|
+
// "../etc", "2026-02-30", "2026-13-01") must return 404 not 500
|
|
2849
|
+
// — 500 says "server failed"; 404 says "input refused at boundary".
|
|
2850
|
+
// 副指挥 b1082017 / 66983a19 hard doors:
|
|
2851
|
+
// • 稳定 404 非 500 for invalid/missing/poisoned date_bucket
|
|
2852
|
+
// • 真日历语义校验, 不仅 regex 形状
|
|
2853
|
+
if (!isValidCalendarBucket(entry.date_bucket)) {
|
|
2854
|
+
return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
let storage;
|
|
2858
|
+
try {
|
|
2859
|
+
// #509 fix: use the date_bucket recorded in the index at upload
|
|
2860
|
+
// time, NOT today's date. buildStoragePath computes today; that is
|
|
2861
|
+
// correct for new uploads only. Yesterday's file lives in
|
|
2862
|
+
// yesterday's bucket forever — read path must respect that.
|
|
2863
|
+
storage = pathForExistingBlob(entry.date_bucket, entry.file_id, entry.ext);
|
|
2864
|
+
} catch {
|
|
2865
|
+
// Defence-in-depth: unreachable given the pre-check above, but
|
|
2866
|
+
// keep as fallback. Return 404 not 500 to match the pre-check
|
|
2867
|
+
// shape — caller sees the same code for all invalid-bucket cases.
|
|
2868
|
+
return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
|
|
2869
|
+
}
|
|
2870
|
+
if (!isPathInsideUploadsRoot(storage.absolutePath)) {
|
|
2871
|
+
// Defence: should be unreachable since buildStoragePath rejected
|
|
2872
|
+
// malformed inputs, but a poisoned index could try to fool us.
|
|
2873
|
+
return withCors(req, Response.json({ ok: false, error: "path_escape" }, { status: 500 }));
|
|
2874
|
+
}
|
|
2875
|
+
if (!existsSync(storage.absolutePath)) {
|
|
2876
|
+
return withCors(req, Response.json({ ok: false, error: "blob_missing" }, { status: 404 }));
|
|
2877
|
+
}
|
|
2878
|
+
|
|
2879
|
+
// Defence: verify the on-disk size matches what the index claims
|
|
2880
|
+
// before serving — a tampered index could otherwise mislead the
|
|
2881
|
+
// caller about file integrity.
|
|
2882
|
+
const st = statSync(storage.absolutePath);
|
|
2883
|
+
if (st.size !== entry.size) {
|
|
2884
|
+
console.error("[/api/files] size mismatch:", { fileId, indexed: entry.size, onDisk: st.size });
|
|
2885
|
+
return withCors(req, Response.json({ ok: false, error: "size_mismatch" }, { status: 500 }));
|
|
2886
|
+
}
|
|
2887
|
+
|
|
2888
|
+
const safeFilename = (entry.name && /^[\x20-\x7e]+$/.test(entry.name))
|
|
2889
|
+
? entry.name
|
|
2890
|
+
: `${fileId}${entry.ext}`;
|
|
2891
|
+
const responseHeaders: Record<string, string> = {
|
|
2892
|
+
...corsHeaders(req),
|
|
2893
|
+
"Content-Type": "application/octet-stream", // always opaque; nosniff prevents the client from re-deciding
|
|
2894
|
+
"Content-Length": String(entry.size),
|
|
2895
|
+
"Content-Disposition": `attachment; filename="${safeFilename.replace(/"/g, "")}"`,
|
|
2896
|
+
"X-Content-Type-Options": "nosniff",
|
|
2897
|
+
"Cache-Control": "private, no-store",
|
|
2898
|
+
"Accept-Ranges": "bytes",
|
|
2899
|
+
};
|
|
2900
|
+
const range = parseSingleByteRange(req.headers.get("Range"), entry.size);
|
|
2901
|
+
if (range && !range.ok) {
|
|
2902
|
+
return new Response(null, {
|
|
2903
|
+
status: 416,
|
|
2904
|
+
headers: {
|
|
2905
|
+
...responseHeaders,
|
|
2906
|
+
"Content-Length": "0",
|
|
2907
|
+
"Content-Range": `bytes */${entry.size}`,
|
|
2908
|
+
},
|
|
2909
|
+
});
|
|
2910
|
+
}
|
|
2911
|
+
if (range?.ok) {
|
|
2912
|
+
const headers = {
|
|
2913
|
+
...responseHeaders,
|
|
2914
|
+
"Content-Length": String(range.length),
|
|
2915
|
+
"Content-Range": `bytes ${range.start}-${range.end}/${entry.size}`,
|
|
2916
|
+
};
|
|
2917
|
+
if (req.method === "HEAD") {
|
|
2918
|
+
return new Response(null, { status: 206, headers });
|
|
2919
|
+
}
|
|
2920
|
+
const blob = Bun.file(storage.absolutePath).slice(range.start, range.end + 1);
|
|
2921
|
+
return new Response(blob, { status: 206, headers });
|
|
2922
|
+
}
|
|
2923
|
+
// HEAD: return the same status and headers as GET, but no body,
|
|
2924
|
+
// per RFC 9110 §9.3.2. This ensures HEAD passes through the same
|
|
2925
|
+
// authorization helper — a cross-owner HEAD is 404 (same as GET),
|
|
2926
|
+
// not a fallback 200. Status/headers are the authoritative
|
|
2927
|
+
// signal; body omission is HTTP-standard for HEAD.
|
|
2928
|
+
if (req.method === "HEAD") {
|
|
2929
|
+
return new Response(null, { status: 200, headers: responseHeaders });
|
|
2930
|
+
}
|
|
2931
|
+
const blob = Bun.file(storage.absolutePath);
|
|
2932
|
+
return new Response(blob, { status: 200, headers: responseHeaders });
|
|
2933
|
+
}
|
|
2934
|
+
|
|
2935
|
+
// ── REST: send task ──
|
|
2936
|
+
if (url.pathname === "/api/task" && req.method === "POST") {
|
|
2937
|
+
let raw: unknown;
|
|
2938
|
+
try {
|
|
2939
|
+
raw = await req.json();
|
|
2940
|
+
} catch {
|
|
2941
|
+
return withCors(req, Response.json({ error: "invalid JSON" }, { status: 400 }));
|
|
2942
|
+
}
|
|
2943
|
+
const parsed = TaskSchema.safeParse(raw);
|
|
2944
|
+
if (!parsed.success) {
|
|
2945
|
+
return withCors(req, Response.json({ error: "invalid input", details: parsed.error.format() }, { status: 400 }));
|
|
2946
|
+
}
|
|
2947
|
+
const body = parsed.data;
|
|
2948
|
+
let taskNetId: string | null = null;
|
|
2949
|
+
if (restAuth?.networkId) {
|
|
2950
|
+
taskNetId = restAuth.networkId;
|
|
2951
|
+
} else if (body.network_id) {
|
|
2952
|
+
if (restAuth && !isAdmin && !getUserNetworkRole(restAuth.userId, body.network_id)) {
|
|
2953
|
+
return withCors(req, Response.json({ ok: false, error: "access denied to requested network" }, { status: 403 }));
|
|
2954
|
+
}
|
|
2955
|
+
taskNetId = body.network_id;
|
|
2956
|
+
} else {
|
|
2957
|
+
taskNetId = restAuth
|
|
2958
|
+
? resolveRestWriteNetworkId(restScope, restAuth, isAdmin)
|
|
2959
|
+
: null;
|
|
2960
|
+
}
|
|
2961
|
+
if (restAuth && !taskNetId) {
|
|
2962
|
+
return withCors(req, Response.json({
|
|
2963
|
+
ok: false,
|
|
2964
|
+
error: "network_id_required",
|
|
2965
|
+
message: "network_id is required when the user token has zero or multiple network memberships",
|
|
2966
|
+
}, { status: 400 }));
|
|
2967
|
+
}
|
|
2968
|
+
// 多用户 Agent 权限:受限成员只能给授权且 can_message 的 Agent 发;
|
|
2969
|
+
// 目标不存在 / 没授权 / 只读授权 → 同一个 403,不留 alias 探测差异。发件人固定为自己的用户名。
|
|
2970
|
+
const restrictedSender = !!restAuth && !isAdmin && !restAuth.networkId && isAgentRestricted(restAuth.userId, taskNetId!);
|
|
2971
|
+
if (restrictedSender) {
|
|
2972
|
+
if (!canRestWriteNetworkAsHuman(restAuth, taskNetId, isAdmin)) {
|
|
2973
|
+
return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
|
|
2974
|
+
}
|
|
2975
|
+
const restrictedTarget = resolveCanonicalAlias(taskNetId, body.alias).alias;
|
|
2976
|
+
const restrictedSession = db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", restrictedTarget, taskNetId);
|
|
2977
|
+
if (!restrictedSession || !canMessageAgent(restAuth!.userId, taskNetId, { alias: restrictedTarget, nodeId: restrictedSession.node_id })) {
|
|
2978
|
+
return withCors(req, Response.json({ ok: false, error: "agent_not_granted", message: "this agent has not been granted to you; ask a network admin" }, { status: 403 }));
|
|
2979
|
+
}
|
|
2980
|
+
if (typeof body.from === "string" && body.from.trim() && body.from.trim() !== restAuth!.username) {
|
|
2981
|
+
return withCors(req, Response.json({ ok: false, error: "from_session_identity_mismatch", message: "restricted members always send as their own username" }, { status: 403 }));
|
|
2982
|
+
}
|
|
2983
|
+
for (const raw of [(body as any).attachments, (body as any).meta?.attachments]) {
|
|
2984
|
+
if (restrictedMemberAttachmentsDenied(restAuth!.userId, restAuth!.username, taskNetId!, raw)) {
|
|
2985
|
+
return withCors(req, Response.json({ ok: false, error: "attachment_not_accessible" }, { status: 403 }));
|
|
2986
|
+
}
|
|
2987
|
+
}
|
|
2988
|
+
} else if (!canRestWriteNetwork(restAuth, taskNetId, isAdmin)) {
|
|
2989
|
+
return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
|
|
2990
|
+
}
|
|
2991
|
+
const canonical = resolveCanonicalAlias(taskNetId, body.alias);
|
|
2992
|
+
const targetAlias = canonical.alias;
|
|
2993
|
+
const target = resolveRestDeliveryTarget(targetAlias, taskNetId);
|
|
2994
|
+
if (target.state === "not_found") {
|
|
2995
|
+
return withCors(req, Response.json({
|
|
2996
|
+
ok: false,
|
|
2997
|
+
error: "alias_not_found",
|
|
2998
|
+
message: target.message,
|
|
2999
|
+
alias: targetAlias,
|
|
3000
|
+
queued: false,
|
|
3001
|
+
...(canonical.renamed ? { renamed_from: body.alias, renamed_to: targetAlias } : {}),
|
|
3002
|
+
}, { status: 404 }));
|
|
3003
|
+
}
|
|
3004
|
+
// Identity binding, mirroring the MCP transport (tools.ts defaultFrom /
|
|
3005
|
+
// fromIdentityMismatchReply). Both transports resolve the same
|
|
3006
|
+
// api_tokens row through resolveToken, and auth.ts already documents the
|
|
3007
|
+
// invariant: "Network-bound node tokens are an identity boundary: they
|
|
3008
|
+
// must not spoof another node via from_session."
|
|
3009
|
+
//
|
|
3010
|
+
// MCP enforced it; this REST handler did not — it took `body.from`
|
|
3011
|
+
// verbatim, so any node could POST /api/task with another node's alias
|
|
3012
|
+
// and be recorded (and, once sender labels ship, *rendered in a human's
|
|
3013
|
+
// TUI*) as that node. Same table, same token, one transport hardened and
|
|
3014
|
+
// its sibling not.
|
|
3015
|
+
//
|
|
3016
|
+
// User tokens keep the previous behaviour: the Dashboard legitimately
|
|
3017
|
+
// posts as its logged-in user, and tightening that is a separate change
|
|
3018
|
+
// with its own compatibility surface.
|
|
3019
|
+
// The decision itself lives in rest-identity.ts as a pure function so the
|
|
3020
|
+
// shipped path and the tested path are the same code.
|
|
3021
|
+
const identity = resolveRestFromSession({
|
|
3022
|
+
token: requestToken(req),
|
|
3023
|
+
tokenName: restAuth?.tokenName,
|
|
3024
|
+
authenticatedUsername: restAuth?.username,
|
|
3025
|
+
requestedFrom: body.from,
|
|
3026
|
+
});
|
|
3027
|
+
if (!identity.ok) {
|
|
3028
|
+
return withCors(req, Response.json({
|
|
3029
|
+
ok: false,
|
|
3030
|
+
error: identity.error,
|
|
3031
|
+
message: identity.message,
|
|
3032
|
+
token_alias: identity.tokenAlias,
|
|
3033
|
+
requested_from_session: identity.requestedFromSession,
|
|
3034
|
+
}, { status: 403 }));
|
|
3035
|
+
}
|
|
3036
|
+
const fromSession = identity.fromSession;
|
|
3037
|
+
const ttlSeconds = (body as any).ttl_seconds || 3600;
|
|
3038
|
+
const fromNodeId = resolveRestNodeIdForAlias(fromSession, taskNetId);
|
|
3039
|
+
const targetNodeId = target.session?.node_id ?? null;
|
|
3040
|
+
const actualTo = restActualTo(target, taskNetId);
|
|
3041
|
+
// #221 — fold top-level `attachments` into `meta.attachments` so
|
|
3042
|
+
// the REST and MCP send_task transports produce identical
|
|
3043
|
+
// tasks.meta_json shape downstream. Top-level wins over any
|
|
3044
|
+
// duplicate `meta.attachments` the client supplied. Validation
|
|
3045
|
+
// matches the MCP-side schema (max 20 entries, file_id regex,
|
|
3046
|
+
// size cap, etc.).
|
|
3047
|
+
const attachmentsResult = validateAttachments((body as any).attachments ?? (body as any).meta?.attachments);
|
|
3048
|
+
if (!attachmentsResult.ok) {
|
|
3049
|
+
return withCors(req, Response.json({ ok: false, error: "bad_attachments", message: attachmentsResult.error }, { status: 400 }));
|
|
3050
|
+
}
|
|
3051
|
+
const mergedMeta = attachmentsResult.attachments.length
|
|
3052
|
+
? { ...((body as any).meta && typeof (body as any).meta === "object" ? (body as any).meta : {}), attachments: attachmentsResult.attachments }
|
|
3053
|
+
: (body as any).meta;
|
|
3054
|
+
const rawToken = requestToken(req);
|
|
3055
|
+
const authOrigin: TaskAuthOrigin = rawToken.startsWith("ntok_")
|
|
3056
|
+
? "node"
|
|
3057
|
+
: restAuth
|
|
3058
|
+
? "user"
|
|
3059
|
+
: "legacy";
|
|
3060
|
+
const metaJson = normalizeMetaJson(stampTaskAuthOrigin(mergedMeta, authOrigin));
|
|
3061
|
+
|
|
3062
|
+
// Client-request-id idempotency, mirroring the MCP `send_task` tool
|
|
3063
|
+
// (tools.ts). The app/Dashboard mint one `meta.client_request_id` per
|
|
3064
|
+
// message bubble and reuse it only to retry that bubble, so:
|
|
3065
|
+
// - the task id is derived from (network, from, request id): a retry
|
|
3066
|
+
// after a lost response finds the row it already wrote and gets the
|
|
3067
|
+
// same task id back (200, idempotent_replay) — across restarts too;
|
|
3068
|
+
// - the same id with a different payload fails closed (409);
|
|
3069
|
+
// - a NEW id is a new message even when the text is identical, so it
|
|
3070
|
+
// must not hit the #212 content-hash window below (that silently
|
|
3071
|
+
// dropped a user's second 「好」 while the app showed it delivered).
|
|
3072
|
+
const clientRequestId = clientRequestIdFromMeta(mergedMeta);
|
|
3073
|
+
const id = clientRequestId
|
|
3074
|
+
? idempotentTaskId(taskNetId ?? null, fromSession, clientRequestId)
|
|
3075
|
+
: crypto.randomUUID();
|
|
3076
|
+
if (clientRequestId) {
|
|
3077
|
+
const existing = db.get<StoredIdempotentTask & { to_node_id: string | null }>(
|
|
3078
|
+
"SELECT task_id, from_name, to_node_id, to_name, priority, content, network_id, meta_json, status FROM tasks WHERE task_id = ?1",
|
|
3079
|
+
[id],
|
|
3080
|
+
);
|
|
3081
|
+
if (existing) {
|
|
3082
|
+
if (!idempotentTaskMatches(existing, {
|
|
3083
|
+
fromName: fromSession, toName: targetAlias, priority: body.priority, content: body.task,
|
|
3084
|
+
networkId: taskNetId ?? null, metaJson,
|
|
3085
|
+
})) {
|
|
3086
|
+
console.log(`[/api/task] ${fromSession} → ${targetAlias}: REJECTED idempotency_conflict (key=request_id, task=${existing.task_id.slice(0, 13)})`);
|
|
3087
|
+
return withCors(req, Response.json({
|
|
3088
|
+
ok: false,
|
|
3089
|
+
error: "idempotency_conflict",
|
|
3090
|
+
message: "client_request_id was already used with a different task payload",
|
|
3091
|
+
}, { status: 409 }));
|
|
3092
|
+
}
|
|
3093
|
+
console.log(`[/api/task] ${fromSession} → ${targetAlias}: REPLAY (key=request_id, task=${existing.task_id.slice(0, 13)})`);
|
|
3094
|
+
return withCors(req, Response.json({
|
|
3095
|
+
ok: true,
|
|
3096
|
+
task_id: existing.task_id,
|
|
3097
|
+
message_id: existing.task_id,
|
|
3098
|
+
task_status: existing.status,
|
|
3099
|
+
idempotent_replay: true,
|
|
3100
|
+
actual_to: {
|
|
3101
|
+
alias: existing.to_name,
|
|
3102
|
+
to_node_id: existing.to_node_id ?? null,
|
|
3103
|
+
network_id: existing.network_id ?? null,
|
|
3104
|
+
},
|
|
3105
|
+
}));
|
|
3106
|
+
}
|
|
3107
|
+
}
|
|
3108
|
+
|
|
3109
|
+
// #212 dedup guardrail. Mirrors the MCP `send_task` tool: same
|
|
3110
|
+
// (from_session, target_alias, task) within COMMHUB_SEND_DEDUP_WINDOW_MS
|
|
3111
|
+
// is rejected with a structured `duplicate_send` error so dashboard
|
|
3112
|
+
// dispatch buttons and scripted REST callers get the same guarantee
|
|
3113
|
+
// as agent-driven MCP traffic. Sends carrying a client_request_id are
|
|
3114
|
+
// keyed on that id instead of the content (send_dedup.ts header).
|
|
3115
|
+
const dedupScope = { requestId: clientRequestId, networkId: taskNetId ?? null };
|
|
3116
|
+
const dedup = sharedSendDedup.check(fromSession, targetAlias, body.task, undefined, dedupScope);
|
|
3117
|
+
if (dedup.duplicate) {
|
|
3118
|
+
const payload = buildDuplicateSendPayload({
|
|
3119
|
+
from: fromSession,
|
|
3120
|
+
to: targetAlias,
|
|
3121
|
+
ageMs: dedup.ageMs,
|
|
3122
|
+
windowMs: sharedSendDedup.windowMs,
|
|
3123
|
+
});
|
|
3124
|
+
console.log(`[/api/task] ${fromSession} → ${targetAlias}: DROPPED duplicate (key=${dedup.kind}, age=${dedup.ageMs}ms, window=${sharedSendDedup.windowMs}ms)`);
|
|
3125
|
+
return withCors(req, Response.json(payload, { status: 429 }));
|
|
3126
|
+
}
|
|
3127
|
+
|
|
3128
|
+
// RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.2a REST site
|
|
3129
|
+
// 1/2 — paired with the 6 MCP sites guarded in tools.ts at PR1.1).
|
|
3130
|
+
// Dashboard's "Dispatch" button hits this endpoint, so without
|
|
3131
|
+
// the guard a stopping/stopped/deleting node still got tasks
|
|
3132
|
+
// routed through REST. Returns 409 with the same structured
|
|
3133
|
+
// payload the MCP handlers use.
|
|
3134
|
+
{
|
|
3135
|
+
const lc = assertNodeActive(targetAlias, taskNetId ?? null);
|
|
3136
|
+
if (!lc.ok) {
|
|
3137
|
+
return withCors(req, Response.json(lc, { status: 409 }));
|
|
3138
|
+
}
|
|
3139
|
+
}
|
|
3140
|
+
// Mirror send_task MCP: write inbox + tasks rows in a single
|
|
3141
|
+
// transaction so the dispatch is visible to dashboard's Tasks page
|
|
3142
|
+
// and the parent_task_id lineage chain. Previously this endpoint
|
|
3143
|
+
// only wrote inbox, leaving GET /api/tasks empty for any task
|
|
3144
|
+
// dispatched via REST (anet demo, dashboard Dispatch button, etc.).
|
|
3145
|
+
db.transaction(() => {
|
|
3146
|
+
db.run(
|
|
3147
|
+
`INSERT INTO inbox (id, task_id, session_name, node_id, type, priority, content, from_session, requires_response, network_id, meta_json)
|
|
3148
|
+
VALUES (?1, ?1, ?2, ?3, 'task', ?4, ?5, ?6, 'reply', ?7, ?8)`,
|
|
3149
|
+
[id, targetAlias, targetNodeId, body.priority, body.task, fromSession, taskNetId, metaJson]
|
|
3150
|
+
);
|
|
3151
|
+
db.run(
|
|
3152
|
+
`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)
|
|
3153
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'delivered', ?7, 'reply', datetime('now'), datetime('now'), datetime('now', ?8), ?9, ?10, ?11)`,
|
|
3154
|
+
[id, fromNodeId, fromSession, targetNodeId, targetAlias, body.priority, body.task, `+${ttlSeconds} seconds`, taskNetId, body.parent_task_id ?? null, metaJson]
|
|
3155
|
+
);
|
|
3156
|
+
// Touch session row so the dashboard reflects "task in flight"
|
|
3157
|
+
// immediately, without waiting for the agent's report_status to
|
|
3158
|
+
// arrive. Updating both `task` and `updated_at` is enough — we
|
|
3159
|
+
// leave `status` to the agent (idle → working → idle).
|
|
3160
|
+
const touchParams: any[] = [body.task.slice(0, 200), targetAlias];
|
|
3161
|
+
let touchSql = "UPDATE sessions SET task = ?1, updated_at = datetime('now') WHERE alias = ?2";
|
|
3162
|
+
if (taskNetId) { touchSql += " AND network_id = ?3"; touchParams.push(taskNetId); }
|
|
3163
|
+
db.run(touchSql, touchParams);
|
|
3164
|
+
});
|
|
3165
|
+
// SSE push: 秒达
|
|
3166
|
+
const pendingParams: any[] = [targetAlias];
|
|
3167
|
+
let pendingSql = "SELECT COUNT(*) as cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
|
|
3168
|
+
if (taskNetId) { pendingSql += " AND network_id = ?2"; pendingParams.push(taskNetId); }
|
|
3169
|
+
const pending = db.get<{ cnt: number }>(pendingSql, ...pendingParams);
|
|
3170
|
+
if (target.state === "online") {
|
|
3171
|
+
pushEvent(targetAlias, { type: "new_task", inbox_count: pending?.cnt ?? 1, priority: body.priority, from: fromSession, ...(canonical.renamed ? { renamed_from: body.alias } : {}) }, taskNetId);
|
|
3172
|
+
}
|
|
3173
|
+
// #461 network observer summary — unconditional (the task row
|
|
3174
|
+
// exists even when the target is offline/queued), metadata only.
|
|
3175
|
+
pushNetworkObserverEvent(taskNetId, { type: "new_task", task_id: id, from: fromSession, to: targetAlias, status: target.state === "online" ? "delivered" : "queued", priority: body.priority });
|
|
3176
|
+
// #212 — stamp the dedup index only after the inbox/tasks insert
|
|
3177
|
+
// succeeds. Mirrors the MCP `send_task` path so a failed write
|
|
3178
|
+
// never shadows a legitimate retry.
|
|
3179
|
+
sharedSendDedup.record(fromSession, targetAlias, body.task, undefined, dedupScope);
|
|
3180
|
+
if (target.state === "offline") {
|
|
3181
|
+
return withCors(req, Response.json({
|
|
3182
|
+
ok: false,
|
|
3183
|
+
error: "alias_offline",
|
|
3184
|
+
message: target.message,
|
|
3185
|
+
alias: targetAlias,
|
|
3186
|
+
queued: true,
|
|
3187
|
+
task_id: id,
|
|
3188
|
+
message_id: id,
|
|
3189
|
+
session_status: target.session.status ?? "offline",
|
|
3190
|
+
actual_to: actualTo,
|
|
3191
|
+
...(canonical.renamed ? { renamed_from: body.alias, renamed_to: targetAlias } : {}),
|
|
3192
|
+
}, { status: 202 }));
|
|
3193
|
+
}
|
|
3194
|
+
return withCors(req, Response.json({
|
|
3195
|
+
ok: true,
|
|
3196
|
+
task_id: id,
|
|
3197
|
+
message_id: id,
|
|
3198
|
+
actual_to: actualTo,
|
|
3199
|
+
...(canonical.renamed ? { renamed_from: body.alias, renamed_to: targetAlias } : {}),
|
|
3200
|
+
}));
|
|
3201
|
+
}
|
|
3202
|
+
|
|
3203
|
+
// ── REST: broadcast ──
|
|
3204
|
+
if (url.pathname === "/api/broadcast" && req.method === "POST") {
|
|
3205
|
+
let raw: unknown;
|
|
3206
|
+
try {
|
|
3207
|
+
raw = await req.json();
|
|
3208
|
+
} catch {
|
|
3209
|
+
return withCors(req, Response.json({ error: "invalid JSON" }, { status: 400 }));
|
|
3210
|
+
}
|
|
3211
|
+
const parsed = BroadcastSchema.safeParse(raw);
|
|
3212
|
+
if (!parsed.success) {
|
|
3213
|
+
return withCors(req, Response.json({ error: "invalid input", details: parsed.error.format() }, { status: 400 }));
|
|
3214
|
+
}
|
|
3215
|
+
const body = parsed.data;
|
|
3216
|
+
if (restAuth && !restScope.networkId && !isAdmin) {
|
|
3217
|
+
return withCors(req, Response.json({ ok: false, error: "network_id required for user token when broadcasting" }, { status: 400 }));
|
|
3218
|
+
}
|
|
3219
|
+
if (!canRestWriteNetwork(restAuth, restScope.networkId, isAdmin)) {
|
|
3220
|
+
return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
|
|
3221
|
+
}
|
|
3222
|
+
let sql = "SELECT alias, node_id, network_id FROM sessions WHERE alias IS NOT NULL";
|
|
3223
|
+
const params: any[] = [];
|
|
3224
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
3225
|
+
if (body.filter_server) { sql += " AND server = ?"; params.push(body.filter_server); }
|
|
3226
|
+
if (body.filter_status) { sql += " AND status = ?"; params.push(body.filter_status); }
|
|
3227
|
+
const targets = db.all<{ alias: string; node_id: string | null; network_id: string | null }>(sql, ...params);
|
|
3228
|
+
const ids: string[] = [];
|
|
3229
|
+
// #1440 ① — mirrors the MCP handler: push to the recipients that
|
|
3230
|
+
// actually got an inbox row, not to every candidate target.
|
|
3231
|
+
const delivered: Array<{ alias: string; netId: string | null }> = [];
|
|
3232
|
+
for (const t of targets) {
|
|
3233
|
+
// RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.2a REST site
|
|
3234
|
+
// 2/2). Broadcast skips non-active recipients silently per its
|
|
3235
|
+
// best-effort semantics — mirrors the MCP broadcast handler
|
|
3236
|
+
// in tools.ts (PR1.1 site 6/6).
|
|
3237
|
+
const lc = assertNodeActive(t.alias, t.network_id ?? null);
|
|
3238
|
+
if (!lc.ok) continue;
|
|
3239
|
+
const id = crypto.randomUUID();
|
|
3240
|
+
db.run(
|
|
3241
|
+
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id)
|
|
3242
|
+
VALUES (?1, ?2, ?3, 'broadcast', 'normal', ?4, 'api', ?5)`,
|
|
3243
|
+
[id, t.alias, t.node_id ?? null, body.message, t.network_id]
|
|
3244
|
+
);
|
|
3245
|
+
ids.push(id);
|
|
3246
|
+
delivered.push({ alias: t.alias, netId: t.network_id });
|
|
3247
|
+
}
|
|
3248
|
+
for (const d of delivered) {
|
|
3249
|
+
pushEvent(d.alias, { type: "broadcast", inbox_count: pendingInboxCount(d.alias, d.netId) }, d.netId);
|
|
3250
|
+
}
|
|
3251
|
+
return withCors(req, Response.json({ ok: true, recipients: ids.length, message_ids: ids }));
|
|
3252
|
+
}
|
|
3253
|
+
|
|
3254
|
+
// ── REST: tmux capture-pane ──
|
|
3255
|
+
const tmuxCapture = url.pathname.match(/^\/api\/tmux\/([a-zA-Z0-9_-]+)$/);
|
|
3256
|
+
if (tmuxCapture && req.method === "GET") {
|
|
3257
|
+
const tmuxErr = requireTmuxAccess(req, server);
|
|
3258
|
+
if (tmuxErr) return withCors(req, tmuxErr);
|
|
3259
|
+
const name = tmuxCapture[1];
|
|
3260
|
+
const lines = Number(url.searchParams.get("lines")) || 30;
|
|
3261
|
+
try {
|
|
3262
|
+
const proc = Bun.spawn(["tmux", "capture-pane", "-t", name, "-p"], {
|
|
3263
|
+
stdout: "pipe", stderr: "pipe",
|
|
3264
|
+
});
|
|
3265
|
+
const text = await new Response(proc.stdout).text();
|
|
3266
|
+
const err = await new Response(proc.stderr).text();
|
|
3267
|
+
const code = await proc.exited;
|
|
3268
|
+
if (code !== 0) {
|
|
3269
|
+
return withCors(req, Response.json({ ok: false, error: err.trim() || `exit ${code}` }, { status: 400 }));
|
|
3270
|
+
}
|
|
3271
|
+
const trimmed = text.split("\n").slice(-lines).join("\n");
|
|
3272
|
+
return withCors(req, Response.json({ ok: true, tmux_name: name, lines: lines, output: trimmed }));
|
|
3273
|
+
} catch (e) {
|
|
3274
|
+
return withCors(req, Response.json({ ok: false, error: (e as Error).message }, { status: 500 }));
|
|
3275
|
+
}
|
|
3276
|
+
}
|
|
3277
|
+
|
|
3278
|
+
// ── REST: tmux send-keys ──
|
|
3279
|
+
const tmuxSend = url.pathname.match(/^\/api\/tmux\/([a-zA-Z0-9_-]+)\/send$/);
|
|
3280
|
+
if (tmuxSend && req.method === "POST") {
|
|
3281
|
+
const tmuxErr = requireTmuxAccess(req, server);
|
|
3282
|
+
if (tmuxErr) return withCors(req, tmuxErr);
|
|
3283
|
+
const name = tmuxSend[1];
|
|
3284
|
+
let body: { text?: string; enter?: boolean };
|
|
3285
|
+
try { body = await req.json(); } catch {
|
|
3286
|
+
return withCors(req, Response.json({ error: "invalid JSON" }, { status: 400 }));
|
|
3287
|
+
}
|
|
3288
|
+
if (!body.text || typeof body.text !== "string") {
|
|
3289
|
+
return withCors(req, Response.json({ error: "text is required" }, { status: 400 }));
|
|
3290
|
+
}
|
|
3291
|
+
const args = ["tmux", "send-keys", "-t", name, body.text];
|
|
3292
|
+
if (body.enter !== false) args.push("Enter");
|
|
3293
|
+
try {
|
|
3294
|
+
const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });
|
|
3295
|
+
const err = await new Response(proc.stderr).text();
|
|
3296
|
+
const code = await proc.exited;
|
|
3297
|
+
if (code !== 0) {
|
|
3298
|
+
return withCors(req, Response.json({ ok: false, error: err.trim() || `exit ${code}` }, { status: 400 }));
|
|
3299
|
+
}
|
|
3300
|
+
return withCors(req, Response.json({ ok: true, sent: body.text }));
|
|
3301
|
+
} catch (e) {
|
|
3302
|
+
return withCors(req, Response.json({ ok: false, error: (e as Error).message }, { status: 500 }));
|
|
3303
|
+
}
|
|
3304
|
+
}
|
|
3305
|
+
|
|
3306
|
+
// #1828 —— 用户名与某个节点 alias 撞名时,inbox 里 session_name=用户名 的行属于那个节点,
|
|
3307
|
+
// 不能当成用户的未读、也不能替它 ack。只看当前 REST scope 内的节点。
|
|
3308
|
+
const userInboxAliasCollides = (username: string, scope: RestNetworkScope): boolean => {
|
|
3309
|
+
const p: any[] = [username];
|
|
3310
|
+
let q = "SELECT 1 AS hit FROM nodes WHERE alias = ?1";
|
|
3311
|
+
// 撞名检测要看见网络里**所有**节点(受限成员看不见的也算),否则撞名的节点待办会被当成用户未读。
|
|
3312
|
+
q = addHumanNetworkScope(q, p, scope);
|
|
3313
|
+
return !!db.get<{ hit: number }>(q + " LIMIT 1", ...p);
|
|
3314
|
+
};
|
|
3315
|
+
|
|
3316
|
+
// ── REST: per-user desktop inbox (#1459 ① P3) ──
|
|
3317
|
+
// 🔴 只在 ?scope=user 时走这条;下面按 alias 寻址的既有分支一行未动
|
|
3318
|
+
// (dashboard 依赖那条 inbox 查询)。
|
|
3319
|
+
if (url.pathname === "/api/messages" && url.searchParams.get("scope") === "user") {
|
|
3320
|
+
// 🔴 收件人取自**鉴权上下文**,不接受 query 指定 —— 否则任何人都能
|
|
3321
|
+
// ?user_id=<别人> 读走别人的私信。
|
|
3322
|
+
const callerUserId = restAuth?.userId ?? null;
|
|
3323
|
+
if (!callerUserId) {
|
|
3324
|
+
return withCors(req, Response.json({ ok: false, error: "auth_required" }, { status: 401 }));
|
|
3325
|
+
}
|
|
3326
|
+
const limit = Math.min(Number(url.searchParams.get("limit")) || 100, 500);
|
|
3327
|
+
const unackedOnly = url.searchParams.get("unacked") === "1";
|
|
3328
|
+
|
|
3329
|
+
const params: any[] = [callerUserId];
|
|
3330
|
+
let sql = `SELECT message_id, network_id, user_id, from_session, kind, title, content, severity,
|
|
3331
|
+
meta_json, acked, created_at, acked_at
|
|
3332
|
+
FROM user_inbox WHERE user_id = ?1`;
|
|
3333
|
+
if (unackedOnly) sql += " AND acked = 0";
|
|
3334
|
+
// 复用 alias 分支同一个 scope 助手:新读路径漏 scope 是已被审计证实的真风险。
|
|
3335
|
+
sql = addHumanNetworkScope(sql, params, restScope);
|
|
3336
|
+
sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
|
|
3337
|
+
params.push(limit);
|
|
3338
|
+
const rows = db.all<any>(sql, ...params);
|
|
3339
|
+
|
|
3340
|
+
// 未读数与列表用同一个 user_id + 同一个 scope 助手算,避免两处口径漂开。
|
|
3341
|
+
const countParams: any[] = [callerUserId];
|
|
3342
|
+
let countSql = "SELECT COUNT(*) AS cnt FROM user_inbox WHERE user_id = ?1 AND acked = 0";
|
|
3343
|
+
countSql = addHumanNetworkScope(countSql, countParams, restScope);
|
|
3344
|
+
const unread = db.get<{ cnt: number }>(countSql, ...countParams)?.cnt ?? 0;
|
|
3345
|
+
|
|
3346
|
+
// #1828 —— 按 agent 分的未读:user_inbox(agent 主动发给用户)+ inbox 里发给**这个用户名**的
|
|
3347
|
+
// reply/task/message 行(agent 对用户任务的回复;acked 从来没人置 1,见 app#260)。
|
|
3348
|
+
// `unread`/`pending_count` 口径不动(只算 user_inbox,qa-hub-14 钉着);两表之和另给 `unread_total`。
|
|
3349
|
+
// 🔴 用户名恰好也是某个节点 alias 时,inbox 里那些行是节点的待办,不是用户的 —— 跳过 inbox 半边。
|
|
3350
|
+
const byAgent: Record<string, number> = {};
|
|
3351
|
+
const addByAgent = (agentRows: Array<{ agent: string | null; n: number }>) => {
|
|
3352
|
+
for (const r of agentRows) byAgent[r.agent || "hub"] = (byAgent[r.agent || "hub"] ?? 0) + Number(r.n ?? 0);
|
|
3353
|
+
};
|
|
3354
|
+
const uiAgentParams: any[] = [callerUserId];
|
|
3355
|
+
// 人与人私信(kind=human_dm)的 from_session 是发信人的用户名,不是 Agent —— 不进按 Agent 的未读,
|
|
3356
|
+
// 否则客户端会把发私信的人画成一个幽灵 Agent。私信未读走 /api/dm/threads。
|
|
3357
|
+
let uiAgentSql = "SELECT from_session AS agent, COUNT(*) AS n FROM user_inbox WHERE user_id = ?1 AND acked = 0 AND kind != 'human_dm'";
|
|
3358
|
+
uiAgentSql = addHumanNetworkScope(uiAgentSql, uiAgentParams, restScope);
|
|
3359
|
+
addByAgent(db.all<{ agent: string | null; n: number }>(uiAgentSql + " GROUP BY from_session", ...uiAgentParams));
|
|
3360
|
+
const callerUsername = restAuth?.username ?? "";
|
|
3361
|
+
if (callerUsername && !userInboxAliasCollides(callerUsername, restScope)) {
|
|
3362
|
+
const ibParams: any[] = [callerUsername];
|
|
3363
|
+
let ibSql = "SELECT from_session AS agent, COUNT(*) AS n FROM inbox WHERE session_name = ?1 AND acked = 0 AND type IN ('reply', 'task', 'message')";
|
|
3364
|
+
ibSql = addHumanNetworkScope(ibSql, ibParams, restScope);
|
|
3365
|
+
addByAgent(db.all<{ agent: string | null; n: number }>(ibSql + " GROUP BY from_session", ...ibParams));
|
|
3366
|
+
}
|
|
3367
|
+
const unreadTotal = Object.values(byAgent).reduce((a, b) => a + b, 0);
|
|
3368
|
+
|
|
3369
|
+
// 🔴 redact-at-read:写入方是 agent,不受信任。存储侧已做跨主机路径脱敏,
|
|
3370
|
+
// 这一层单独防"有人把凭据塞进正文/标题/meta"。
|
|
3371
|
+
const messages = rows.map(redactMessageRow);
|
|
3372
|
+
|
|
3373
|
+
return withCors(req, Response.json({
|
|
3374
|
+
ok: true,
|
|
3375
|
+
messages,
|
|
3376
|
+
unread,
|
|
3377
|
+
// 同一个数的两个名字:`unread` 给角标读,`pending_count` 与 alias 分支
|
|
3378
|
+
// 的字段名保持一致。**一处计算**,不是两处实现。
|
|
3379
|
+
pending_count: unread,
|
|
3380
|
+
unread_by_agent: byAgent,
|
|
3381
|
+
unread_total: unreadTotal,
|
|
3382
|
+
}));
|
|
3383
|
+
}
|
|
3384
|
+
|
|
3385
|
+
// ── REST: recent messages (for Dashboard communication graph) ──
|
|
3386
|
+
if (url.pathname === "/api/messages") {
|
|
3387
|
+
const limit = Math.min(Number(url.searchParams.get("limit")) || 100, 500);
|
|
3388
|
+
const since = url.searchParams.get("since") ?? new Date(Date.now() - 3600000).toISOString().replace("T", " ").slice(0, 19);
|
|
3389
|
+
const alias = url.searchParams.get("alias")?.trim() || null;
|
|
3390
|
+
const params: any[] = [since];
|
|
3391
|
+
let sql = "SELECT id, session_name as to_alias, from_session as from_alias, type, priority, content, acked, created_at, network_id FROM inbox WHERE created_at >= ?1";
|
|
3392
|
+
if (alias) {
|
|
3393
|
+
params.push(alias);
|
|
3394
|
+
sql += ` AND session_name = ?${params.length}`;
|
|
3395
|
+
}
|
|
3396
|
+
sql = addOwnTrafficScope(sql, params, restScope, { from: "from_session", to: "session_name" });
|
|
3397
|
+
sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
|
|
3398
|
+
params.push(limit);
|
|
3399
|
+
const rows = db.all(sql, ...params);
|
|
3400
|
+
|
|
3401
|
+
let pendingCount: number | undefined;
|
|
3402
|
+
if (alias) {
|
|
3403
|
+
const countParams: any[] = [alias];
|
|
3404
|
+
let countSql = "SELECT COUNT(*) AS cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
|
|
3405
|
+
countSql = addOwnTrafficScope(countSql, countParams, restScope, { from: "from_session", to: "session_name" });
|
|
3406
|
+
pendingCount = db.get<{ cnt: number }>(countSql, ...countParams)?.cnt ?? 0;
|
|
3407
|
+
}
|
|
3408
|
+
|
|
3409
|
+
return withCors(req, Response.json({ ok: true, messages: rows, ...(alias ? { pending_count: pendingCount ?? 0 } : {}) }));
|
|
3410
|
+
}
|
|
3411
|
+
|
|
3412
|
+
// ── REST: ack per-user desktop messages (#1459 ① P3) ──
|
|
3413
|
+
if (url.pathname === "/api/messages/ack" && req.method === "POST") {
|
|
3414
|
+
const callerUserId = restAuth?.userId ?? null;
|
|
3415
|
+
if (!callerUserId) {
|
|
3416
|
+
return withCors(req, Response.json({ ok: false, error: "auth_required" }, { status: 401 }));
|
|
3417
|
+
}
|
|
3418
|
+
let body: any = null;
|
|
3419
|
+
try { body = await req.json(); } catch { /* 下面按缺参处理 */ }
|
|
3420
|
+
const ids: string[] = Array.isArray(body?.message_ids)
|
|
3421
|
+
? body.message_ids.filter((x: unknown) => typeof x === "string" && x.length > 0)
|
|
3422
|
+
: (typeof body?.message_id === "string" && body.message_id ? [body.message_id] : []);
|
|
3423
|
+
// #1909 —— 按 agent 整体 ack:`{ agent: "<alias>" }`。角标的分母是 `unread_by_agent`,
|
|
3424
|
+
// 它数的是**全部**未 ack 行,而客户端一页只看得到最近 200/300 条 ⇒ 老行永远 ack 不到,
|
|
3425
|
+
// 红点永远清不掉(生产 admin:user_inbox 522 + inbox 5660 行)。这一形态把调用者
|
|
3426
|
+
// 收到的、来自该 agent 的两张表全部标已读;同样只动自己的行。
|
|
3427
|
+
const agentRaw = typeof body?.agent === "string" ? body.agent.trim() : "";
|
|
3428
|
+
if (agentRaw && ids.length > 0) {
|
|
3429
|
+
return withCors(req, Response.json({ ok: false, error: "ambiguous_ack" }, { status: 400 }));
|
|
3430
|
+
}
|
|
3431
|
+
if (agentRaw) {
|
|
3432
|
+
const uiParams: any[] = [callerUserId, agentRaw];
|
|
3433
|
+
let uiSql = `UPDATE user_inbox SET acked = 1, acked_at = datetime('now')
|
|
3434
|
+
WHERE user_id = ?1 AND from_session = ?2 AND acked = 0`;
|
|
3435
|
+
uiSql = addHumanNetworkScope(uiSql, uiParams, restScope);
|
|
3436
|
+
const uiAcked = db.run(uiSql, uiParams).changes ?? 0;
|
|
3437
|
+
let agentInboxAcked = 0;
|
|
3438
|
+
const agentCallerUsername = restAuth?.username ?? "";
|
|
3439
|
+
if (agentCallerUsername && !userInboxAliasCollides(agentCallerUsername, restScope)) {
|
|
3440
|
+
const ibAgentParams: any[] = [agentCallerUsername, agentRaw];
|
|
3441
|
+
let ibAgentSql = `UPDATE inbox SET acked = 1
|
|
3442
|
+
WHERE session_name = ?1 AND from_session = ?2 AND acked = 0 AND type IN ('reply', 'task', 'message')`;
|
|
3443
|
+
ibAgentSql = addHumanNetworkScope(ibAgentSql, ibAgentParams, restScope);
|
|
3444
|
+
agentInboxAcked = db.run(ibAgentSql, ibAgentParams).changes ?? 0;
|
|
3445
|
+
}
|
|
3446
|
+
return withCors(req, Response.json({
|
|
3447
|
+
ok: true, scope: "agent", agent: agentRaw,
|
|
3448
|
+
acked: uiAcked + agentInboxAcked, acked_user_inbox: uiAcked, acked_inbox: agentInboxAcked,
|
|
3449
|
+
}));
|
|
3450
|
+
}
|
|
3451
|
+
if (ids.length === 0) {
|
|
3452
|
+
return withCors(req, Response.json({ ok: false, error: "message_id_required" }, { status: 400 }));
|
|
3453
|
+
}
|
|
3454
|
+
if (ids.length > 500) {
|
|
3455
|
+
return withCors(req, Response.json({ ok: false, error: "too_many_ids", limit: 500 }, { status: 400 }));
|
|
3456
|
+
}
|
|
3457
|
+
// 🔴 `AND user_id = <ctx>`:只能 ack 自己的。别人的 message_id 传进来
|
|
3458
|
+
// 也匹配不到行,所以既不会改到别人的状态,也不会因此泄漏它是否存在。
|
|
3459
|
+
const params: any[] = [callerUserId, ...ids];
|
|
3460
|
+
const placeholders = ids.map((_, i) => `?${i + 2}`).join(", ");
|
|
3461
|
+
let sql = `UPDATE user_inbox SET acked = 1, acked_at = datetime('now')
|
|
3462
|
+
WHERE user_id = ?1 AND message_id IN (${placeholders}) AND acked = 0`;
|
|
3463
|
+
sql = addHumanNetworkScope(sql, params, restScope);
|
|
3464
|
+
const res = db.run(sql, params);
|
|
3465
|
+
// #1828 —— 同一批 id 也可能是 inbox 里发给这个用户名的 reply/task/message 行(agent 回复);
|
|
3466
|
+
// 用户看过就 ack 掉。只动 session_name = 调用者用户名 的行,别人的 id 匹配不到;
|
|
3467
|
+
// 用户名与节点 alias 撞名时跳过(那是节点的待办)。
|
|
3468
|
+
let inboxAcked = 0;
|
|
3469
|
+
const callerUsername = restAuth?.username ?? "";
|
|
3470
|
+
if (callerUsername && !userInboxAliasCollides(callerUsername, restScope)) {
|
|
3471
|
+
const ibParams: any[] = [callerUsername, ...ids];
|
|
3472
|
+
let ibSql = `UPDATE inbox SET acked = 1
|
|
3473
|
+
WHERE session_name = ?1 AND id IN (${placeholders}) AND acked = 0 AND type IN ('reply', 'task', 'message')`;
|
|
3474
|
+
ibSql = addHumanNetworkScope(ibSql, ibParams, restScope);
|
|
3475
|
+
inboxAcked = db.run(ibSql, ibParams).changes ?? 0;
|
|
3476
|
+
}
|
|
3477
|
+
return withCors(req, Response.json({ ok: true, scope: "ids", acked: (res.changes ?? 0) + inboxAcked, acked_user_inbox: res.changes ?? 0, acked_inbox: inboxAcked }));
|
|
3478
|
+
}
|
|
3479
|
+
|
|
3480
|
+
// ── REST: stats summary ──
|
|
3481
|
+
if (url.pathname === "/api/stats") {
|
|
3482
|
+
const taskStatsParams: any[] = [];
|
|
3483
|
+
let taskStatsSql = "SELECT status, COUNT(*) as count FROM tasks WHERE 1=1";
|
|
3484
|
+
taskStatsSql = addNetworkScope(taskStatsSql, taskStatsParams, restScope);
|
|
3485
|
+
taskStatsSql += " GROUP BY status";
|
|
3486
|
+
const taskStats = db.all<any>(taskStatsSql, ...taskStatsParams);
|
|
3487
|
+
|
|
3488
|
+
const sessionStatsParams: any[] = [];
|
|
3489
|
+
let sessionStatsSql = "SELECT status, COUNT(*) as count FROM sessions WHERE 1=1";
|
|
3490
|
+
sessionStatsSql = addNetworkScope(sessionStatsSql, sessionStatsParams, restScope);
|
|
3491
|
+
sessionStatsSql += " GROUP BY status";
|
|
3492
|
+
const sessionStats = db.all<any>(sessionStatsSql, ...sessionStatsParams);
|
|
3493
|
+
|
|
3494
|
+
const totalTasksParams: any[] = [];
|
|
3495
|
+
let totalTasksSql = "SELECT COUNT(*) as cnt FROM tasks WHERE 1=1";
|
|
3496
|
+
totalTasksSql = addNetworkScope(totalTasksSql, totalTasksParams, restScope);
|
|
3497
|
+
const totalTasks = db.get<{ cnt: number }>(totalTasksSql, ...totalTasksParams);
|
|
3498
|
+
|
|
3499
|
+
const totalNodesParams: any[] = [];
|
|
3500
|
+
let totalNodesSql = "SELECT COUNT(*) as cnt FROM nodes WHERE 1=1";
|
|
3501
|
+
totalNodesSql = addNetworkScope(totalNodesSql, totalNodesParams, restScope);
|
|
3502
|
+
const totalNodes = db.get<{ cnt: number }>(totalNodesSql, ...totalNodesParams);
|
|
3503
|
+
|
|
3504
|
+
const recentTasksParams: any[] = [];
|
|
3505
|
+
let recentTasksSql = "SELECT task_id, from_name, to_name, status, created_at FROM tasks WHERE 1=1";
|
|
3506
|
+
recentTasksSql = addNetworkScope(recentTasksSql, recentTasksParams, restScope);
|
|
3507
|
+
recentTasksSql += " ORDER BY created_at DESC LIMIT 5";
|
|
3508
|
+
const recentTasks = db.all<any>(recentTasksSql, ...recentTasksParams);
|
|
3509
|
+
return withCors(req, Response.json({
|
|
3510
|
+
ok: true,
|
|
3511
|
+
network_id: restScope.networkId || null,
|
|
3512
|
+
tasks: { total: totalTasks?.cnt || 0, by_status: taskStats },
|
|
3513
|
+
sessions: { by_status: sessionStats },
|
|
3514
|
+
nodes: { total: totalNodes?.cnt || 0 },
|
|
3515
|
+
recent_tasks: recentTasks,
|
|
3516
|
+
}));
|
|
3517
|
+
}
|
|
3518
|
+
|
|
3519
|
+
// ── REST: server log tail (in-memory ring buffer, last LOG_RING_CAP lines) ──
|
|
3520
|
+
// Admin-only because logs may include user names + task content.
|
|
3521
|
+
if (url.pathname === "/api/server-logs") {
|
|
3522
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
3523
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
3524
|
+
const resolved = resolveToken(token);
|
|
3525
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
3526
|
+
if (!isHubAdminCredential(resolved)) return withCors(req, Response.json({ ok: false, error: "admin only" }, { status: 403 }));
|
|
3527
|
+
const limit = Math.min(Number(url.searchParams.get("limit")) || 200, LOG_RING_CAP);
|
|
3528
|
+
const since = url.searchParams.get("since"); // ISO timestamp; only return logs newer
|
|
3529
|
+
let entries = logRing.slice(-limit);
|
|
3530
|
+
if (since) entries = entries.filter(e => e.ts > since);
|
|
3531
|
+
// Newest first
|
|
3532
|
+
entries = entries.slice().reverse();
|
|
3533
|
+
return withCors(req, Response.json({ ok: true, logs: entries, capacity: LOG_RING_CAP }));
|
|
3534
|
+
}
|
|
3535
|
+
|
|
3536
|
+
// ── REST: audit log (V3) ──
|
|
3537
|
+
if (url.pathname === "/api/audit-log") {
|
|
3538
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
3539
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
3540
|
+
const resolved = resolveToken(token);
|
|
3541
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
3542
|
+
// 审计日志是账号的记录;节点令牌不能读签发者(常是管理员)的审计日志。
|
|
3543
|
+
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
3544
|
+
const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 200);
|
|
3545
|
+
const action = url.searchParams.get("action");
|
|
3546
|
+
const userId = url.searchParams.get("user_id");
|
|
3547
|
+
let sql = `SELECT ${AUDIT_LOG_REST_SELECT} FROM audit_log WHERE 1=1`;
|
|
3548
|
+
const params: any[] = [];
|
|
3549
|
+
// Non-admin can only see own logs
|
|
3550
|
+
if (resolved.user.role !== "admin") { sql += ` AND user_id = ?${params.length + 1}`; params.push(resolved.user.user_id); }
|
|
3551
|
+
if (action) { sql += ` AND action = ?${params.length + 1}`; params.push(action); }
|
|
3552
|
+
if (userId && resolved.user.role === "admin") { sql += ` AND user_id = ?${params.length + 1}`; params.push(userId); }
|
|
3553
|
+
sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
|
|
3554
|
+
params.push(limit);
|
|
3555
|
+
const logs = db.all(sql, ...params);
|
|
3556
|
+
return withCors(req, Response.json({ ok: true, logs, count: logs.length }));
|
|
3557
|
+
}
|
|
3558
|
+
|
|
3559
|
+
// ── REST: task events (V2 Sprint 2) ──
|
|
3560
|
+
if (url.pathname === "/api/task_events") {
|
|
3561
|
+
const taskId = url.searchParams.get("task_id");
|
|
3562
|
+
const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 500);
|
|
3563
|
+
let sql = `SELECT ${TASK_EVENT_REST_SELECT} FROM task_events WHERE 1=1`;
|
|
3564
|
+
const params: any[] = [];
|
|
3565
|
+
if (restScope.agentRestriction) {
|
|
3566
|
+
// 受限成员:只看得到自己与授权 Agent 往来的那些任务的事件。
|
|
3567
|
+
sql += " AND task_id IN (SELECT task_id FROM tasks WHERE 1=1";
|
|
3568
|
+
sql = addOwnTrafficScope(sql, params, restScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" }) + ")";
|
|
3569
|
+
} else {
|
|
3570
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
3571
|
+
}
|
|
3572
|
+
if (taskId) { sql += ` AND task_id = ?${params.length + 1}`; params.push(taskId); }
|
|
3573
|
+
sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
|
|
3574
|
+
params.push(limit);
|
|
3575
|
+
const rows = db.all(sql, ...params);
|
|
3576
|
+
return withCors(req, Response.json({ ok: true, events: rows, count: rows.length }));
|
|
3577
|
+
}
|
|
3578
|
+
|
|
3579
|
+
// ── REST: delete node (Dashboard/CLI remote cleanup) ──
|
|
3580
|
+
const nodeDeleteMatch = url.pathname.match(/^\/api\/nodes\/([^/]+)$/);
|
|
3581
|
+
if (nodeDeleteMatch && req.method === "DELETE") {
|
|
3582
|
+
const ref = decodeURIComponent(nodeDeleteMatch[1]);
|
|
3583
|
+
const params: any[] = [ref, ref, ref];
|
|
3584
|
+
let sql = "SELECT node_id, node_name, alias, network_id FROM nodes WHERE (node_id = ?1 OR node_name = ?2 OR alias = ?3)";
|
|
3585
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
3586
|
+
sql += " ORDER BY updated_at DESC LIMIT 1";
|
|
3587
|
+
const node = db.get<any>(sql, ...params);
|
|
3588
|
+
if (!node) return withCors(req, Response.json({ ok: false, error: "node not found" }, { status: 404 }));
|
|
3589
|
+
|
|
3590
|
+
const nodeNetId = node.network_id ?? singleNetworkId(restScope);
|
|
3591
|
+
if (!canRestWriteNetwork(restAuth, nodeNetId, isAdmin)) {
|
|
3592
|
+
return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
|
|
3593
|
+
}
|
|
3594
|
+
|
|
3595
|
+
db.transaction(() => {
|
|
3596
|
+
db.run("DELETE FROM nodes WHERE node_id = ?1", [node.node_id]);
|
|
3597
|
+
if (node.alias) {
|
|
3598
|
+
db.run(
|
|
3599
|
+
"DELETE FROM sessions WHERE alias = ?1 AND (network_id = ?2 OR (CAST(?2 AS TEXT) IS NULL AND network_id IS NULL))",
|
|
3600
|
+
[node.alias, node.network_id ?? null]
|
|
3601
|
+
);
|
|
3602
|
+
}
|
|
3603
|
+
});
|
|
3604
|
+
|
|
3605
|
+
if (node.alias) {
|
|
3606
|
+
pushEvent(node.alias, {
|
|
3607
|
+
type: "node_deleted",
|
|
3608
|
+
node_id: node.node_id,
|
|
3609
|
+
node_name: node.node_name,
|
|
3610
|
+
alias: node.alias,
|
|
3611
|
+
network_id: node.network_id ?? null,
|
|
3612
|
+
}, node.network_id ?? null);
|
|
3613
|
+
}
|
|
3614
|
+
|
|
3615
|
+
return withCors(req, Response.json({
|
|
3616
|
+
ok: true,
|
|
3617
|
+
deleted: true,
|
|
3618
|
+
node_id: node.node_id,
|
|
3619
|
+
node_name: node.node_name,
|
|
3620
|
+
alias: node.alias,
|
|
3621
|
+
network_id: node.network_id ?? null,
|
|
3622
|
+
}));
|
|
3623
|
+
}
|
|
3624
|
+
|
|
3625
|
+
// ── REST: single node config snapshot (RFC-024 B5) ──
|
|
3626
|
+
// Dashboard calls this for the snapshot path. Returns the masked
|
|
3627
|
+
// {model, flags, config_revision} the node last posted via
|
|
3628
|
+
// report_status (B6). NEVER reads per-node files. Network-scoped
|
|
3629
|
+
// via addNetworkScope so a netA viewer can't read a netB node.
|
|
3630
|
+
const nodeConfigMatch = url.pathname.match(/^\/api\/nodes\/([^/]+)\/config$/);
|
|
3631
|
+
if (nodeConfigMatch && req.method === "GET") {
|
|
3632
|
+
const nodeId = decodeURIComponent(nodeConfigMatch[1] ?? "");
|
|
3633
|
+
const params: any[] = [nodeId];
|
|
3634
|
+
let sql = "SELECT node_id, alias, network_id, config_revision, config_snapshot FROM nodes WHERE node_id = ?1";
|
|
3635
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
3636
|
+
sql += " LIMIT 1";
|
|
3637
|
+
const node = db.get<any>(sql, ...params);
|
|
3638
|
+
if (!node) {
|
|
3639
|
+
return withCors(req, Response.json({ ok: false, error: "node_not_found", node_id: nodeId }, { status: 404 }));
|
|
3640
|
+
}
|
|
3641
|
+
let snapshot: any = {};
|
|
3642
|
+
if (node.config_snapshot) {
|
|
3643
|
+
try { snapshot = JSON.parse(node.config_snapshot); } catch { snapshot = {}; }
|
|
3644
|
+
}
|
|
3645
|
+
return withCors(req, Response.json({
|
|
3646
|
+
ok: true,
|
|
3647
|
+
node_id: node.node_id,
|
|
3648
|
+
alias: node.alias,
|
|
3649
|
+
network_id: node.network_id,
|
|
3650
|
+
config_revision: node.config_revision || 0,
|
|
3651
|
+
model: snapshot.model ?? null,
|
|
3652
|
+
flags: snapshot.flags ?? {},
|
|
3653
|
+
config_update_capable: snapshot.config_update_capable ?? false,
|
|
3654
|
+
}));
|
|
3655
|
+
}
|
|
3656
|
+
|
|
3657
|
+
// ── #462 REST: set / clear a node's custom avatar ──
|
|
3658
|
+
// PUT /api/nodes/:ref/avatar body: { "avatar_url": "https://…" | null }
|
|
3659
|
+
//
|
|
3660
|
+
// Deliberately NOT part of the RFC-024 config-apply pipeline: avatar
|
|
3661
|
+
// is a pure display attribute — nothing for agent-node to consume, no
|
|
3662
|
+
// config_revision / ack round-trip, no restart tier. Persisted on the
|
|
3663
|
+
// nodes row and served back via GET /api/nodes (cross-device).
|
|
3664
|
+
//
|
|
3665
|
+
// Auth: same write gate as node delete above — network-scoped lookup
|
|
3666
|
+
// + canRestWriteNetwork (member with role above viewer, or admin, or
|
|
3667
|
+
// legacy master token). Value passes validateAvatarUrl (http/https
|
|
3668
|
+
// only, ≤ 2048 chars, no control chars — XSS boundary, see
|
|
3669
|
+
// avatar-validate.ts).
|
|
3670
|
+
// ── REST: node DISPLAY attributes (display_name / team / tags) ──
|
|
3671
|
+
// Companion to the avatar route below: hub-side presentation fields that
|
|
3672
|
+
// agent-node does not consume, so they deliberately bypass the RFC-024
|
|
3673
|
+
// config-apply pipeline (no doorbell, no ack, no config_revision bump).
|
|
3674
|
+
//
|
|
3675
|
+
// Unlike avatar, these are multi-field and multi-editor, so they carry
|
|
3676
|
+
// their OWN optimistic lock (`attrs_revision`). A stale base revision is
|
|
3677
|
+
// a 409 with the current value, mirroring update_node_config's contract
|
|
3678
|
+
// so the dashboard can reuse its conflict-resolution UX.
|
|
3679
|
+
//
|
|
3680
|
+
// NOT editable here: `alias` (message-routing key) and `node_name`
|
|
3681
|
+
// (addressing key). Renaming those must go through the rename 2PC at
|
|
3682
|
+
// /api/node-rename/* which cascades sessions + api_tokens + SSE cleanup;
|
|
3683
|
+
// a single-table UPDATE would strand the node (see #146).
|
|
3684
|
+
const nodeAttrsMatch = url.pathname.match(/^\/api\/nodes\/([^/]+)\/attrs$/);
|
|
3685
|
+
if (nodeAttrsMatch && req.method === "PUT") {
|
|
3686
|
+
let attrsBody: any;
|
|
3687
|
+
try {
|
|
3688
|
+
attrsBody = await req.json();
|
|
3689
|
+
} catch {
|
|
3690
|
+
return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 }));
|
|
3691
|
+
}
|
|
3692
|
+
const ref = decodeURIComponent(nodeAttrsMatch[1]);
|
|
3693
|
+
const params: any[] = [ref, ref, ref];
|
|
3694
|
+
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)";
|
|
3695
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
3696
|
+
sql += " ORDER BY updated_at DESC LIMIT 1";
|
|
3697
|
+
const node = db.get<any>(sql, ...params);
|
|
3698
|
+
if (!node) return withCors(req, Response.json({ ok: false, error: "node not found" }, { status: 404 }));
|
|
3699
|
+
|
|
3700
|
+
const nodeNetId = node.network_id ?? singleNetworkId(restScope);
|
|
3701
|
+
if (!canRestWriteNetwork(restAuth, nodeNetId, isAdmin)) {
|
|
3702
|
+
return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
|
|
3703
|
+
}
|
|
3704
|
+
|
|
3705
|
+
// Optimistic lock BEFORE any narrowing work, so a losing writer never
|
|
3706
|
+
// has side effects.
|
|
3707
|
+
const currentRev = Number(node.attrs_revision ?? 0);
|
|
3708
|
+
const baseRev = attrsBody?.base_attrs_revision;
|
|
3709
|
+
if (typeof baseRev !== "number" || !Number.isInteger(baseRev) || baseRev < 0) {
|
|
3710
|
+
return withCors(req, Response.json({
|
|
3711
|
+
ok: false, error: "base_attrs_revision required (integer >= 0)",
|
|
3712
|
+
}, { status: 400 }));
|
|
3713
|
+
}
|
|
3714
|
+
if (baseRev !== currentRev) {
|
|
3715
|
+
return withCors(req, Response.json({
|
|
3716
|
+
ok: false,
|
|
3717
|
+
error: "attrs_revision_conflict",
|
|
3718
|
+
current_attrs_revision: currentRev,
|
|
3719
|
+
message: "another editor changed this node's attributes; re-read and retry",
|
|
3720
|
+
}, { status: 409 }));
|
|
3721
|
+
}
|
|
3722
|
+
|
|
3723
|
+
// Scalars reject on wrong type (never coerce); tags drop junk per item.
|
|
3724
|
+
const nextDisplay = "display_name" in attrsBody
|
|
3725
|
+
? validateScalarAttr(attrsBody.display_name, "display_name")
|
|
3726
|
+
: null;
|
|
3727
|
+
if (nextDisplay && !nextDisplay.ok) {
|
|
3728
|
+
return withCors(req, Response.json({ ok: false, error: "invalid_display_name", reason: nextDisplay.reason }, { status: 400 }));
|
|
3729
|
+
}
|
|
3730
|
+
const nextTeam = "team" in attrsBody ? validateScalarAttr(attrsBody.team, "team") : null;
|
|
3731
|
+
if (nextTeam && !nextTeam.ok) {
|
|
3732
|
+
return withCors(req, Response.json({ ok: false, error: "invalid_team", reason: nextTeam.reason }, { status: 400 }));
|
|
3733
|
+
}
|
|
3734
|
+
const nextTags = "tags" in attrsBody ? narrowTags(attrsBody.tags) : null;
|
|
3735
|
+
|
|
3736
|
+
// Only the supplied fields move; the rest keep their stored value.
|
|
3737
|
+
const sets: string[] = [];
|
|
3738
|
+
const vals: any[] = [];
|
|
3739
|
+
if (nextDisplay) { sets.push(`display_name = ?${vals.length + 1}`); vals.push(nextDisplay.value); }
|
|
3740
|
+
if (nextTeam) { sets.push(`team = ?${vals.length + 1}`); vals.push(nextTeam.value); }
|
|
3741
|
+
if (nextTags) { sets.push(`tags = ?${vals.length + 1}`); vals.push(JSON.stringify(nextTags)); }
|
|
3742
|
+
// A no-op patch still bumps the revision: the caller observed a state
|
|
3743
|
+
// and asserted it, so honouring the CAS keeps client counters in step.
|
|
3744
|
+
sets.push(`attrs_revision = ?${vals.length + 1}`); vals.push(currentRev + 1);
|
|
3745
|
+
sets.push(`updated_at = datetime('now')`);
|
|
3746
|
+
vals.push(node.node_id);
|
|
3747
|
+
// Guard the UPDATE with the same revision so two concurrent writers
|
|
3748
|
+
// that both passed the read above cannot both land.
|
|
3749
|
+
vals.push(currentRev);
|
|
3750
|
+
const res = db.run(
|
|
3751
|
+
`UPDATE nodes SET ${sets.join(", ")} WHERE node_id = ?${vals.length - 1} AND attrs_revision = ?${vals.length}`,
|
|
3752
|
+
vals,
|
|
3753
|
+
);
|
|
3754
|
+
if (!res.changes) {
|
|
3755
|
+
const fresh = db.get<any>("SELECT attrs_revision FROM nodes WHERE node_id = ?1", node.node_id);
|
|
3756
|
+
return withCors(req, Response.json({
|
|
3757
|
+
ok: false,
|
|
3758
|
+
error: "attrs_revision_conflict",
|
|
3759
|
+
current_attrs_revision: Number(fresh?.attrs_revision ?? currentRev),
|
|
3760
|
+
message: "another editor changed this node's attributes; re-read and retry",
|
|
3761
|
+
}, { status: 409 }));
|
|
3762
|
+
}
|
|
3763
|
+
logAudit(restAuth?.userId ?? null, restAuth?.username ?? null, "node_attrs_updated", "node", node.node_id,
|
|
3764
|
+
JSON.stringify({ display_name: nextDisplay?.value ?? undefined, team: nextTeam?.value ?? undefined, tags: nextTags ?? undefined }),
|
|
3765
|
+
undefined, nodeNetId ?? undefined);
|
|
3766
|
+
const after = db.get<any>(
|
|
3767
|
+
"SELECT display_name, team, tags, attrs_revision FROM nodes WHERE node_id = ?1", node.node_id);
|
|
3768
|
+
return withCors(req, Response.json({
|
|
3769
|
+
ok: true,
|
|
3770
|
+
node_id: node.node_id,
|
|
3771
|
+
alias: node.alias,
|
|
3772
|
+
display_name: after?.display_name ?? null,
|
|
3773
|
+
team: after?.team ?? null,
|
|
3774
|
+
tags: parseStoredTags(after?.tags),
|
|
3775
|
+
attrs_revision: Number(after?.attrs_revision ?? currentRev + 1),
|
|
3776
|
+
}));
|
|
3777
|
+
}
|
|
3778
|
+
|
|
3779
|
+
const nodeAvatarMatch = url.pathname.match(/^\/api\/nodes\/([^/]+)\/avatar$/);
|
|
3780
|
+
if (nodeAvatarMatch && req.method === "PUT") {
|
|
3781
|
+
let avatarBody: any;
|
|
3782
|
+
try {
|
|
3783
|
+
avatarBody = await req.json();
|
|
3784
|
+
} catch {
|
|
3785
|
+
return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 }));
|
|
3786
|
+
}
|
|
3787
|
+
const validated = validateAvatarUrl(avatarBody?.avatar_url);
|
|
3788
|
+
if (!validated.ok) {
|
|
3789
|
+
return withCors(req, Response.json({ ok: false, error: "invalid_avatar_url", reason: validated.reason }, { status: 400 }));
|
|
3790
|
+
}
|
|
3791
|
+
const ref = decodeURIComponent(nodeAvatarMatch[1]);
|
|
3792
|
+
const params: any[] = [ref, ref, ref];
|
|
3793
|
+
let sql = "SELECT node_id, alias, network_id FROM nodes WHERE (node_id = ?1 OR node_name = ?2 OR alias = ?3)";
|
|
3794
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
3795
|
+
sql += " ORDER BY updated_at DESC LIMIT 1";
|
|
3796
|
+
const node = db.get<any>(sql, ...params);
|
|
3797
|
+
if (!node) return withCors(req, Response.json({ ok: false, error: "node not found" }, { status: 404 }));
|
|
3798
|
+
|
|
3799
|
+
const nodeNetId = node.network_id ?? singleNetworkId(restScope);
|
|
3800
|
+
if (!canRestWriteNetwork(restAuth, nodeNetId, isAdmin)) {
|
|
3801
|
+
return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
|
|
3802
|
+
}
|
|
3803
|
+
|
|
3804
|
+
db.run(
|
|
3805
|
+
"UPDATE nodes SET avatar_url = ?1, updated_at = datetime('now') WHERE node_id = ?2",
|
|
3806
|
+
[validated.value, node.node_id]
|
|
3807
|
+
);
|
|
3808
|
+
return withCors(req, Response.json({ ok: true, node_id: node.node_id, alias: node.alias, avatar_url: validated.value }));
|
|
3809
|
+
}
|
|
3810
|
+
|
|
3811
|
+
// ── REST: nodes table (V2 Sprint 2) ──
|
|
3812
|
+
// Explicit column list — NOT `SELECT *`. Two reasons:
|
|
3813
|
+
// 1. `SELECT *` silently broadcasts any column added later to the
|
|
3814
|
+
// response (RFC-024 added config_revision/config_snapshot which
|
|
3815
|
+
// the dashboard's GET /api/nodes/:id/config endpoint owns;
|
|
3816
|
+
// RFC-028 will add more vault-flavored columns). Tests asserting
|
|
3817
|
+
// response shape break on every ALTER; integrations that key
|
|
3818
|
+
// off Object.keys() can pick up internals.
|
|
3819
|
+
// 2. The list endpoint and the per-node config snapshot endpoint
|
|
3820
|
+
// have different consumer contracts; this list should be the
|
|
3821
|
+
// stable, dashboard-facing fields only.
|
|
3822
|
+
// Add a new column to this list explicitly when a real client needs
|
|
3823
|
+
// it; do NOT switch back to SELECT *.
|
|
3824
|
+
// ── REST: list host_supervisor daemons (RFC-026 §9.2.2 / #338 PR2) ──
|
|
3825
|
+
// Mirror of the `list_host_supervisors` MCP tool for non-MCP callers.
|
|
3826
|
+
// Same SQL + role-extract + member-脱敏 logic; SEC-1 scoped via REST
|
|
3827
|
+
// auth pipeline (restScope). Returns {ok, daemons:[...], count}.
|
|
3828
|
+
// ── REST: one node_create_request's outcome (app#… 桌面向导) ──
|
|
3829
|
+
// 向导 POST create_node 后只会轮询「子节点注册了没」,daemon 回的失败原因(ack_create_request 写进
|
|
3830
|
+
// node_create_requests.status/error)此前没有任何读接口 —— 用户只看到「24s 没注册」。
|
|
3831
|
+
// 这里按 request_id 读一行;网络作用域与其它 REST 相同(addNetworkScope),无用户上下文 401。
|
|
3832
|
+
if (url.pathname === "/api/node-create-requests" && req.method === "GET") {
|
|
3833
|
+
if (!restAuth) {
|
|
3834
|
+
return withCors(req, Response.json({ ok: false, error: "auth_required" }, { status: 401 }));
|
|
3835
|
+
}
|
|
3836
|
+
const requestId = url.searchParams.get("request_id")?.trim() ?? "";
|
|
3837
|
+
if (!requestId) {
|
|
3838
|
+
return withCors(req, Response.json({ ok: false, error: "request_id_required" }, { status: 400 }));
|
|
3839
|
+
}
|
|
3840
|
+
const params: any[] = [requestId];
|
|
3841
|
+
let sql = `SELECT request_id, daemon_node_id, child_name, network_id, runtime, model, status, error,
|
|
3842
|
+
created_at, delivered_at, acked_at
|
|
3843
|
+
FROM node_create_requests WHERE request_id = ?1`;
|
|
3844
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
3845
|
+
const row = db.get<Record<string, unknown>>(sql, ...params);
|
|
3846
|
+
if (!row) {
|
|
3847
|
+
// 不存在与不在你作用域内长一样:不泄漏别的网络里有没有这个 id。
|
|
3848
|
+
return withCors(req, Response.json({ ok: false, error: "request_not_found" }, { status: 404 }));
|
|
3849
|
+
}
|
|
3850
|
+
return withCors(req, Response.json({ ok: true, request: row }));
|
|
3851
|
+
}
|
|
3852
|
+
|
|
3853
|
+
if (url.pathname === "/api/host-supervisors") {
|
|
3854
|
+
// #380 — utok callers with exactly one accessible network get a
|
|
3855
|
+
// safe fallback so the create-node wizard doesn't have to bake
|
|
3856
|
+
// network_id into every request (it was hard-4xxing when the
|
|
3857
|
+
// dashboard omitted the query param — that's what "hub 400" was).
|
|
3858
|
+
// `singleNetworkId` returns:
|
|
3859
|
+
// - scope.networkId when it's set (ntok binding, or an
|
|
3860
|
+
// explicitly-requested network the user has verified access to)
|
|
3861
|
+
// - the sole member of scope.networkIds when the utok user
|
|
3862
|
+
// belongs to exactly one network (safe unambiguous fallback)
|
|
3863
|
+
// - null when the user belongs to 0 or 2+ networks (authz
|
|
3864
|
+
// boundary — refuse to guess which one, mirrors 通信龙 spec
|
|
3865
|
+
// "别 fallback 到错 network")
|
|
3866
|
+
// The 400 branch is retained for the ambiguous / no-access cases
|
|
3867
|
+
// and distinguishes them in the error message so the client can
|
|
3868
|
+
// recover (send network_id explicitly).
|
|
3869
|
+
const effectiveNetId = singleNetworkId(restScope);
|
|
3870
|
+
if (!effectiveNetId) {
|
|
3871
|
+
const memberships = restScope.networkIds?.length ?? 0;
|
|
3872
|
+
const error = memberships > 1
|
|
3873
|
+
? "network_id_required_multi"
|
|
3874
|
+
: "missing_network_id";
|
|
3875
|
+
return withCors(req, Response.json({ ok: false, error, memberships }, { status: 400 }));
|
|
3876
|
+
}
|
|
3877
|
+
// 多用户 Agent 权限:daemon 也是 Agent,受限成员看不到。
|
|
3878
|
+
if (restAuth && !isAdmin && isAgentRestricted(restAuth.userId, effectiveNetId)) {
|
|
3879
|
+
return withCors(req, Response.json({ ok: true, daemons: [], count: 0 }));
|
|
3880
|
+
}
|
|
3881
|
+
// Role for member-脱敏 (admin/owner = full host_telemetry, others = masked)
|
|
3882
|
+
let isPrivileged = false;
|
|
3883
|
+
if (restAuth?.userId) {
|
|
3884
|
+
const role = getUserNetworkRole(restAuth.userId, effectiveNetId);
|
|
3885
|
+
isPrivileged = role === "admin" || role === "owner";
|
|
3886
|
+
} else {
|
|
3887
|
+
// Network-token caller (no user) — treat as privileged within its network
|
|
3888
|
+
isPrivileged = true;
|
|
3889
|
+
}
|
|
3890
|
+
|
|
3891
|
+
// EXISTS subquery handles BOTH revoked_at SET and DELETEd token
|
|
3892
|
+
// rows (revokeToken DELETEs the row in production — PR2 v1
|
|
3893
|
+
// SHOULD-FIX per 通信龙 audit). EXISTS naturally dedupes daemons
|
|
3894
|
+
// with rotated tokens.
|
|
3895
|
+
const sqlRows = db.all<Record<string, any>>(`
|
|
3896
|
+
SELECT
|
|
3897
|
+
n.node_id, n.alias, n.hostname, n.network_id,
|
|
3898
|
+
n.runtimes_supported, n.allowed_secret_keys,
|
|
3899
|
+
n.created_at, n.updated_at,
|
|
3900
|
+
s.last_seen_at AS session_last_seen,
|
|
3901
|
+
s.cpu_cores AS session_cpu_cores,
|
|
3902
|
+
s.mem_total_gb AS session_mem_total_gb,
|
|
3903
|
+
s.ip AS session_ip,
|
|
3904
|
+
n.config_snapshot
|
|
3905
|
+
FROM nodes n
|
|
3906
|
+
LEFT JOIN sessions s ON s.alias = n.alias AND (s.network_id = n.network_id OR s.network_id IS NULL)
|
|
3907
|
+
WHERE n.network_id = ?1
|
|
3908
|
+
AND EXISTS (
|
|
3909
|
+
SELECT 1 FROM api_tokens t
|
|
3910
|
+
WHERE t.network_id = n.network_id
|
|
3911
|
+
AND t.name = 'node:' || n.alias
|
|
3912
|
+
AND t.revoked_at IS NULL
|
|
3913
|
+
)
|
|
3914
|
+
ORDER BY n.updated_at DESC
|
|
3915
|
+
`, effectiveNetId);
|
|
3916
|
+
|
|
3917
|
+
const nowMs = Date.now();
|
|
3918
|
+
// 心跳周期是 3 分钟(agent-node/src/cli.ts 的 report_status 定时器),
|
|
3919
|
+
// 窗口必须大于它,否则每次心跳后的 60s~180s 之间必然抖成 offline
|
|
3920
|
+
// (通信评审牛 #1279 独审④)。取 5min,与仓内既有 stale 口径一致。
|
|
3921
|
+
const ONLINE_MS = 5 * 60_000;
|
|
3922
|
+
const daemons = sqlRows
|
|
3923
|
+
.map(r => {
|
|
3924
|
+
let snapRole: string | null = null;
|
|
3925
|
+
// #1353 Fix ② — surface daemon self-reported create-node
|
|
3926
|
+
// capability so dashboard's "选服务器" picker can filter or
|
|
3927
|
+
// dim broken daemons instead of listing them as online.
|
|
3928
|
+
// Fix ① (hub-side dispatch gate, PR #1510) refuses the
|
|
3929
|
+
// request but the picker still lists the daemon and the user
|
|
3930
|
+
// finds out only after clicking. Exposing the fact one layer
|
|
3931
|
+
// earlier lets the picker never surface the daemon in the
|
|
3932
|
+
// first place.
|
|
3933
|
+
//
|
|
3934
|
+
// 🔴 Pre-#1371 daemons don't report either field. Leave the
|
|
3935
|
+
// response keys UNDEFINED in that case (not `false`, not
|
|
3936
|
+
// `null`) — mirrors the Fix ① rule "known-blocked, NOT
|
|
3937
|
+
// unknown-treated-as-blocked". Emitting a fabricated `false`
|
|
3938
|
+
// would silently start dimming every daemon that hasn't
|
|
3939
|
+
// upgraded past preview.55.
|
|
3940
|
+
let snapCanCreate: boolean | undefined;
|
|
3941
|
+
let snapBlockedReason: string | undefined;
|
|
3942
|
+
// #1545 —— 见 report_status schema 里 create_capability_observed_ms_ago 的注释:
|
|
3943
|
+
// 上报侧**故意收得宽**(无 int/min/max,免得一个诊断字段能让整份 report 被拒),
|
|
3944
|
+
// 消毒放在这里。非有限数、负数、超过 1 年的一律当作「没报」——
|
|
3945
|
+
// 🔴 不是 clamp 成边界值:clamp 会把一个坏值变成一个**看起来正常的年龄**,
|
|
3946
|
+
// 而这一格存在的全部意义就是让人分辨新鲜和陈旧。
|
|
3947
|
+
let snapObservedMsAgo: number | undefined;
|
|
3948
|
+
if (r.config_snapshot) {
|
|
3949
|
+
try {
|
|
3950
|
+
const parsed = typeof r.config_snapshot === "string" ? JSON.parse(r.config_snapshot) : r.config_snapshot;
|
|
3951
|
+
snapRole = typeof parsed?.role === "string" ? parsed.role : null;
|
|
3952
|
+
const caps = parsed?.daemon_capabilities;
|
|
3953
|
+
if (caps && typeof caps.can_create_nodes === "boolean") {
|
|
3954
|
+
snapCanCreate = caps.can_create_nodes;
|
|
3955
|
+
}
|
|
3956
|
+
if (caps && typeof caps.create_nodes_blocked_reason === "string"
|
|
3957
|
+
&& caps.create_nodes_blocked_reason.length > 0) {
|
|
3958
|
+
snapBlockedReason = caps.create_nodes_blocked_reason;
|
|
3959
|
+
}
|
|
3960
|
+
const rawAge = caps?.create_capability_observed_ms_ago;
|
|
3961
|
+
if (typeof rawAge === "number" && Number.isFinite(rawAge)
|
|
3962
|
+
&& rawAge >= 0 && rawAge <= 365 * 24 * 60 * 60 * 1000) {
|
|
3963
|
+
snapObservedMsAgo = rawAge;
|
|
3964
|
+
}
|
|
3965
|
+
} catch { /* malformed */ }
|
|
3966
|
+
}
|
|
3967
|
+
return { row: r, role: snapRole, canCreate: snapCanCreate, blockedReason: snapBlockedReason,
|
|
3968
|
+
observedMsAgo: snapObservedMsAgo };
|
|
3969
|
+
})
|
|
3970
|
+
.filter(({ role }) => role === "host_supervisor")
|
|
3971
|
+
.map(({ row: r, canCreate, blockedReason, observedMsAgo }) => {
|
|
3972
|
+
let online = false;
|
|
3973
|
+
let lastSeenAt: string | null = null;
|
|
3974
|
+
if (r.session_last_seen) {
|
|
3975
|
+
lastSeenAt = r.session_last_seen;
|
|
3976
|
+
const t = parseDbTimestampMs(r.session_last_seen);
|
|
3977
|
+
if (!isNaN(t)) online = (nowMs - t) <= ONLINE_MS;
|
|
3978
|
+
}
|
|
3979
|
+
let runtimes: string[] = [];
|
|
3980
|
+
let secrets: string[] = [];
|
|
3981
|
+
try {
|
|
3982
|
+
if (r.runtimes_supported) {
|
|
3983
|
+
const parsed = JSON.parse(r.runtimes_supported);
|
|
3984
|
+
runtimes = Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string") : [];
|
|
3985
|
+
}
|
|
3986
|
+
} catch {}
|
|
3987
|
+
try {
|
|
3988
|
+
if (r.allowed_secret_keys) {
|
|
3989
|
+
const parsed = JSON.parse(r.allowed_secret_keys);
|
|
3990
|
+
secrets = Array.isArray(parsed) ? parsed.filter((s: unknown) => typeof s === "string") : [];
|
|
3991
|
+
}
|
|
3992
|
+
} catch {}
|
|
3993
|
+
const telemetry: Record<string, unknown> = {
|
|
3994
|
+
alert_level: online ? "green" : "gray",
|
|
3995
|
+
};
|
|
3996
|
+
if (isPrivileged) {
|
|
3997
|
+
telemetry.cpu_cores = r.session_cpu_cores ?? null;
|
|
3998
|
+
telemetry.mem_gb = r.session_mem_total_gb ?? null;
|
|
3999
|
+
telemetry.ip_internal = r.session_ip ?? null;
|
|
4000
|
+
}
|
|
4001
|
+
const out: Record<string, unknown> = {
|
|
4002
|
+
daemon_node_id: r.node_id,
|
|
4003
|
+
alias: r.alias,
|
|
4004
|
+
hostname: r.hostname,
|
|
4005
|
+
online,
|
|
4006
|
+
last_seen_at: lastSeenAt,
|
|
4007
|
+
runtimes_supported: runtimes,
|
|
4008
|
+
allowed_secret_keys: secrets,
|
|
4009
|
+
host_telemetry: telemetry,
|
|
4010
|
+
};
|
|
4011
|
+
// #1353 Fix ② — only emit the two capability keys when the
|
|
4012
|
+
// daemon actually reported them. Undefined vs. false is a
|
|
4013
|
+
// real distinction here (see Fix ② comment above).
|
|
4014
|
+
if (typeof canCreate === "boolean") {
|
|
4015
|
+
out.can_create_nodes = canCreate;
|
|
4016
|
+
// Blocked reason only meaningful when blocked. Some legacy
|
|
4017
|
+
// shapes could carry a reason without can_create_nodes=false
|
|
4018
|
+
// (defensive fallback in Fix ①); mirror the same choice —
|
|
4019
|
+
// don't confuse consumers with a reason on a healthy daemon.
|
|
4020
|
+
if (canCreate === false && blockedReason) {
|
|
4021
|
+
out.create_nodes_blocked_reason = blockedReason;
|
|
4022
|
+
}
|
|
4023
|
+
// #1545 —— 只在 daemon 真的报了这一格时才出现。
|
|
4024
|
+
// 🔴 缺席**不能**当成 0:那等于替一个从不重算的旧 daemon 宣称
|
|
4025
|
+
// 「这是刚测的」,正好朝「没问题」方向说谎。缺席就让它缺席,
|
|
4026
|
+
// 读的人据此说「年龄未知」(与 can_create_nodes 本身
|
|
4027
|
+
// undefined-vs-false 的处理同一条规矩,见上面 #1353 Fix ②)。
|
|
4028
|
+
//
|
|
4029
|
+
// 语义:该能力值是在**这份 report 发出前 N 毫秒**测得的。
|
|
4030
|
+
// 绝对年龄 = (now - last_seen_at) + create_capability_observed_ms_ago。
|
|
4031
|
+
// 用节点自己的钟只量了「多久以前」,绝对时间全部由 hub 的钟出。
|
|
4032
|
+
if (typeof observedMsAgo === "number") {
|
|
4033
|
+
out.create_capability_observed_ms_ago = observedMsAgo;
|
|
4034
|
+
}
|
|
4035
|
+
}
|
|
4036
|
+
// app「新建节点」确认页的默认工作目录根。出现即表示该 daemon 认 node_spec.workdir;
|
|
4037
|
+
// 缺席(老 daemon / 老 hub 的 zod 剥掉了它)时 app 藏起那一行、不发 workdir。
|
|
4038
|
+
// 🔴 它是那台机器的家目录路径 —— 与 ip_internal 同级,只给 admin/owner。
|
|
4039
|
+
if (isPrivileged) {
|
|
4040
|
+
const workdirRoot = daemonDefaultWorkdirRoot(r.config_snapshot);
|
|
4041
|
+
if (workdirRoot) out.default_workdir_root = workdirRoot;
|
|
4042
|
+
}
|
|
4043
|
+
return out;
|
|
4044
|
+
});
|
|
4045
|
+
return withCors(req, Response.json({ ok: true, daemons, count: daemons.length }));
|
|
4046
|
+
}
|
|
4047
|
+
|
|
4048
|
+
if (url.pathname === "/api/nodes") {
|
|
4049
|
+
const nodeId = url.searchParams.get("node_id");
|
|
4050
|
+
const alias = url.searchParams.get("alias");
|
|
4051
|
+
// `config_snapshot` SELECTed ONLY to extract the daemon `role` field
|
|
4052
|
+
// for dashboard discovery — full snapshot is NOT broadcast (would
|
|
4053
|
+
// re-introduce the #312 SELECT * fragility). Mapping step pulls
|
|
4054
|
+
// `role` out then drops `config_snapshot` from the response row.
|
|
4055
|
+
// RFC-027 PR2 dashboard prereq: lifecycle_state is needed
|
|
4056
|
+
// client-side to drive the ⋮ menu (active → "停止/删除";
|
|
4057
|
+
// stopped → "重启/删除"). Hub schema gained the column at PR1
|
|
4058
|
+
// (#345) but /api/nodes never SELECTed it. Following #312
|
|
4059
|
+
// "explicit SELECT" discipline — extend the projection here,
|
|
4060
|
+
// do NOT switch to SELECT *.
|
|
4061
|
+
// #462: avatar_url added to the projection so custom avatars are
|
|
4062
|
+
// cross-device (dashboard hydrates from here; localStorage was the
|
|
4063
|
+
// only store before). Nullable — null means "use default set".
|
|
4064
|
+
let sql = `SELECT node_id, node_name, alias, runtime, model,
|
|
4065
|
+
config_path, channels, server, hostname,
|
|
4066
|
+
network_id, created_at, updated_at,
|
|
4067
|
+
config_snapshot, lifecycle_state, avatar_url,
|
|
4068
|
+
display_name, team, tags, attrs_revision
|
|
4069
|
+
FROM nodes WHERE 1=1`;
|
|
4070
|
+
const params: any[] = [];
|
|
4071
|
+
sql = addAgentNetworkScope(sql, params, restScope, { alias: "alias", nodeId: "node_id" });
|
|
4072
|
+
if (nodeId) { sql += ` AND node_id = ?${params.length + 1}`; params.push(nodeId); }
|
|
4073
|
+
if (alias) { sql += ` AND alias = ?${params.length + 1}`; params.push(alias); }
|
|
4074
|
+
sql += " ORDER BY updated_at DESC";
|
|
4075
|
+
const rawRows = db.all<Record<string, any>>(sql, ...params);
|
|
4076
|
+
// Add a single new field `role: string | null` per row, extracted from
|
|
4077
|
+
// config_snapshot JSON. Daemon nodes that posted role='host_supervisor'
|
|
4078
|
+
// via report_status surface here; dashboard uses this for daemon
|
|
4079
|
+
// discovery (replacing the prior `node_daemon_` prefix heuristic).
|
|
4080
|
+
// RFC-026 P1 P2 §9 will supersede this REST proxy with the
|
|
4081
|
+
// `list_host_supervisors` MCP tool.
|
|
4082
|
+
// app#196 —— 哪些节点能走 stop_node / restart_node / delete_node。
|
|
4083
|
+
//
|
|
4084
|
+
// 背景:那三个工具的 schema 是 `regex(/^node_[a-z0-9_-]+$/)`,而节点 id 有两种:
|
|
4085
|
+
// node_xxxx daemon 建出来的子节点(+ daemon 自己)
|
|
4086
|
+
// n_xxxx 人手工 `anet node start` 起的
|
|
4087
|
+
// 2026-08-28 对生产 hub 实测:218 个节点里 **207 个是 n_**,
|
|
4088
|
+
// 在桌面端点「停止」只会看到一段 zod 正则原文。
|
|
4089
|
+
//
|
|
4090
|
+
// 🔴 这不是「正则写窄了」——那三个工具的做法是**让那台机器上的 daemon 去停它
|
|
4091
|
+
// 自己建的子进程**。手工起的节点没有 daemon 可问,放宽正则只会把失败推到更深处。
|
|
4092
|
+
//
|
|
4093
|
+
// 所以这里给出一个显式标志,**让前端不必靠 id 前缀去猜**。判据不是前缀,
|
|
4094
|
+
// 是 node_create_requests 里真的有对应的创建记录 ——
|
|
4095
|
+
// 子节点 id 由 request_id 确定性推导:`node_${request_id.replace(/^cr_/,"")}`
|
|
4096
|
+
// (2026-08-28 用真实一对验过:cr_59723b24-… → node_59723b24-…)。
|
|
4097
|
+
const controllable = (() => {
|
|
4098
|
+
try {
|
|
4099
|
+
const crParams: any[] = [];
|
|
4100
|
+
let crSql = "SELECT request_id, daemon_node_id FROM node_create_requests WHERE 1=1";
|
|
4101
|
+
crSql = addNetworkScope(crSql, crParams, restScope);
|
|
4102
|
+
return buildControllableMap(db.all<Record<string, any>>(crSql, ...crParams));
|
|
4103
|
+
} catch {
|
|
4104
|
+
// 表不存在/查询失败 ⇒ 全部按不可控处理,不让这一格拖垮整个列表
|
|
4105
|
+
return new Map<string, string>();
|
|
4106
|
+
}
|
|
4107
|
+
})();
|
|
4108
|
+
|
|
4109
|
+
const rows = rawRows.map(r => {
|
|
4110
|
+
let role: string | null = null;
|
|
4111
|
+
const snap = r.config_snapshot;
|
|
4112
|
+
if (snap) {
|
|
4113
|
+
try { role = (typeof snap === "string" ? JSON.parse(snap) : snap)?.role ?? null; }
|
|
4114
|
+
catch { /* malformed snapshot — leave role null */ }
|
|
4115
|
+
}
|
|
4116
|
+
const { config_snapshot, ...rest } = r;
|
|
4117
|
+
// `tags` is stored as JSON text; hand the dashboard a real array so
|
|
4118
|
+
// it can map() without a guard, and normalise legacy/absent values
|
|
4119
|
+
// to [] rather than null. attrs_revision is normalised to a number
|
|
4120
|
+
// so pre-migration rows (NULL) read as 0.
|
|
4121
|
+
// app#196 —— 显式告诉前端这个节点能不能走生命周期操作,以及该找哪个 daemon。
|
|
4122
|
+
// daemon 自己(role=host_supervisor)也能被 restart/stop,它的 id 同样是 node_ 前缀
|
|
4123
|
+
// 且在 node_create_requests 里没有记录,所以单独放行。
|
|
4124
|
+
const lifecycle_controllable = isLifecycleControllable(r.node_id, role, controllable);
|
|
4125
|
+
return {
|
|
4126
|
+
...rest,
|
|
4127
|
+
tags: parseStoredTags(r.tags),
|
|
4128
|
+
attrs_revision: Number(r.attrs_revision ?? 0),
|
|
4129
|
+
role,
|
|
4130
|
+
lifecycle_controllable,
|
|
4131
|
+
lifecycle_daemon_node_id: controllable.get(r.node_id) ?? null,
|
|
4132
|
+
};
|
|
4133
|
+
});
|
|
4134
|
+
return withCors(req, Response.json({ ok: true, nodes: rows, count: rows.length }));
|
|
4135
|
+
}
|
|
4136
|
+
|
|
4137
|
+
// ── REST: single task lookup (V2) ──
|
|
4138
|
+
const taskPathMatch = url.pathname.match(/^\/api\/tasks?\/([^/]+)$/);
|
|
4139
|
+
if (taskPathMatch && req.method === "GET") {
|
|
4140
|
+
const taskId = decodeURIComponent(taskPathMatch[1] ?? "");
|
|
4141
|
+
const params: any[] = [taskId];
|
|
4142
|
+
let sql = `SELECT ${TASK_REST_SELECT} FROM tasks WHERE task_id = ?1`;
|
|
4143
|
+
sql = addOwnTrafficScope(sql, params, restScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
|
|
4144
|
+
sql += " LIMIT 1";
|
|
4145
|
+
const task = db.get(sql, ...params);
|
|
4146
|
+
if (!task) {
|
|
4147
|
+
return withCors(req, Response.json({ ok: false, error: "task_not_found", task_id: taskId }, { status: 404 }));
|
|
4148
|
+
}
|
|
4149
|
+
const taskNetworkId = typeof task.network_id === "string" ? task.network_id : null;
|
|
4150
|
+
const canonicalTarget = task.to_node_id
|
|
4151
|
+
? null
|
|
4152
|
+
: resolveCanonicalAlias(taskNetworkId, String(task.to_name ?? "")).alias;
|
|
4153
|
+
const targetParams: unknown[] = [];
|
|
4154
|
+
let targetSql = "SELECT alias, status FROM sessions WHERE ";
|
|
4155
|
+
if (task.to_node_id) {
|
|
4156
|
+
targetSql += "node_id = ?1";
|
|
4157
|
+
targetParams.push(task.to_node_id);
|
|
4158
|
+
} else {
|
|
4159
|
+
targetSql += "alias = ?1";
|
|
4160
|
+
targetParams.push(canonicalTarget);
|
|
4161
|
+
}
|
|
4162
|
+
if (taskNetworkId) {
|
|
4163
|
+
targetSql += " AND network_id = ?2";
|
|
4164
|
+
targetParams.push(taskNetworkId);
|
|
4165
|
+
} else {
|
|
4166
|
+
targetSql += " AND network_id IS NULL";
|
|
4167
|
+
}
|
|
4168
|
+
targetSql += " ORDER BY updated_at DESC LIMIT 1";
|
|
4169
|
+
const targetSession = db.get<{ alias: string; status: string }>(targetSql, ...targetParams);
|
|
4170
|
+
const targetAlias = targetSession?.alias ?? canonicalTarget ?? String(task.to_name ?? "");
|
|
4171
|
+
const liveSseConnections = getSSEStats().sessions[`${taskNetworkId || "global"}:${targetAlias}`] ?? 0;
|
|
4172
|
+
// 🔴 #1083:这个目标【历史上有没有报过任何一次】运行时证据。
|
|
4173
|
+
// 只用 Hub 自己已有的数据算,不需要 agent 侧任何改动。
|
|
4174
|
+
// 为空时 runtime_submitted_at/consumed_at 是「不会走到」而不是「还没走到」,
|
|
4175
|
+
// 两者处置相反(别等去接线 vs 等)。参数编号沿用上面 targetSql 的写法。
|
|
4176
|
+
const evidenceParams: unknown[] = [];
|
|
4177
|
+
let evidenceSql = "SELECT 1 AS seen FROM tasks WHERE runtime_submitted_at IS NOT NULL AND ";
|
|
4178
|
+
if (task.to_node_id) {
|
|
4179
|
+
evidenceSql += "to_node_id = ?1";
|
|
4180
|
+
evidenceParams.push(task.to_node_id);
|
|
4181
|
+
} else {
|
|
4182
|
+
evidenceSql += "to_node_id IS NULL AND to_name = ?1";
|
|
4183
|
+
evidenceParams.push(canonicalTarget ?? String(task.to_name ?? ""));
|
|
4184
|
+
}
|
|
4185
|
+
if (taskNetworkId) {
|
|
4186
|
+
evidenceSql += " AND network_id = ?2";
|
|
4187
|
+
evidenceParams.push(taskNetworkId);
|
|
4188
|
+
} else {
|
|
4189
|
+
evidenceSql += " AND network_id IS NULL";
|
|
4190
|
+
}
|
|
4191
|
+
evidenceSql += " LIMIT 1";
|
|
4192
|
+
const targetEverReportedRuntimeEvidence =
|
|
4193
|
+
Boolean(db.get<{ seen: number }>(evidenceSql, ...evidenceParams));
|
|
4194
|
+
const diagnostic = diagnoseTask({
|
|
4195
|
+
status: String(task.status ?? "unknown"),
|
|
4196
|
+
runtimeSubmittedAt: typeof task.runtime_submitted_at === "string" ? task.runtime_submitted_at : null,
|
|
4197
|
+
consumedAt: typeof task.consumed_at === "string" ? task.consumed_at : null,
|
|
4198
|
+
targetSessionStatus: targetSession?.status ?? null,
|
|
4199
|
+
targetSessionExists: Boolean(targetSession),
|
|
4200
|
+
liveSseConnections,
|
|
4201
|
+
targetEverReportedRuntimeEvidence,
|
|
4202
|
+
});
|
|
4203
|
+
return withCors(req, Response.json({ ok: true, task, diagnostic }));
|
|
4204
|
+
}
|
|
4205
|
+
|
|
4206
|
+
// ── REST: tasks table (V2) ──
|
|
4207
|
+
if (url.pathname === "/api/tasks") {
|
|
4208
|
+
const taskId = url.searchParams.get("task_id");
|
|
4209
|
+
const status = url.searchParams.get("status");
|
|
4210
|
+
const toName = url.searchParams.get("to_name");
|
|
4211
|
+
const fromName = url.searchParams.get("from_name");
|
|
4212
|
+
const rawBefore = url.searchParams.get("before");
|
|
4213
|
+
const before = rawBefore === null ? null : normalizeTaskBeforeCursor(rawBefore);
|
|
4214
|
+
if (rawBefore !== null && before === null) {
|
|
4215
|
+
return withCors(req, Response.json({ ok: false, error: "invalid_before" }, { status: 400 }));
|
|
4216
|
+
}
|
|
4217
|
+
const rawBeforeTaskId = url.searchParams.get("before_task_id");
|
|
4218
|
+
const beforeTaskId = rawBeforeTaskId === null ? null : normalizeTaskBeforeTaskId(rawBeforeTaskId);
|
|
4219
|
+
if (rawBeforeTaskId !== null && (before === null || beforeTaskId === null)) {
|
|
4220
|
+
return withCors(req, Response.json({ ok: false, error: "invalid_before_task_id" }, { status: 400 }));
|
|
4221
|
+
}
|
|
4222
|
+
const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 200);
|
|
4223
|
+
// #248 — opt-out of the stats GROUP-BY scan. The dashboard chat panel
|
|
4224
|
+
// never consumes `stats`; it's a global per-status table scan that
|
|
4225
|
+
// dominates the request time on a large DB (270 MB / many tasks).
|
|
4226
|
+
// Existing callers that don't pass `skip_stats=1` get the same
|
|
4227
|
+
// {ok, tasks, count, stats} shape — backwards-compatible.
|
|
4228
|
+
const skipStats = url.searchParams.get("skip_stats") === "1";
|
|
4229
|
+
|
|
4230
|
+
let sql = `SELECT ${TASK_REST_SELECT} FROM tasks WHERE 1=1`;
|
|
4231
|
+
const params: any[] = [];
|
|
4232
|
+
sql = addOwnTrafficScope(sql, params, restScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
|
|
4233
|
+
if (taskId) { sql += ` AND task_id = ?${params.length + 1}`; params.push(taskId); }
|
|
4234
|
+
if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
|
|
4235
|
+
if (toName) { sql += ` AND to_name = ?${params.length + 1}`; params.push(toName); }
|
|
4236
|
+
if (fromName) { sql += ` AND from_name = ?${params.length + 1}`; params.push(fromName); }
|
|
4237
|
+
if (before && beforeTaskId) {
|
|
4238
|
+
const createdAtParam = params.length + 1;
|
|
4239
|
+
const taskIdParam = params.length + 2;
|
|
4240
|
+
sql += ` AND (created_at < ?${createdAtParam} OR (created_at = ?${createdAtParam} AND task_id < ?${taskIdParam}))`;
|
|
4241
|
+
params.push(before, beforeTaskId);
|
|
4242
|
+
} else if (before) {
|
|
4243
|
+
sql += ` AND created_at < ?${params.length + 1}`;
|
|
4244
|
+
params.push(before);
|
|
4245
|
+
}
|
|
4246
|
+
sql += ` ORDER BY created_at DESC, task_id DESC LIMIT ?${params.length + 1}`;
|
|
4247
|
+
params.push(limit);
|
|
4248
|
+
|
|
4249
|
+
const rows = db.all(sql, ...params);
|
|
4250
|
+
if (skipStats) {
|
|
4251
|
+
return withCors(req, Response.json({ ok: true, tasks: rows, count: rows.length }));
|
|
4252
|
+
}
|
|
4253
|
+
const statsParams: any[] = [];
|
|
4254
|
+
let statsSql = "SELECT status, COUNT(*) as count FROM tasks WHERE 1=1";
|
|
4255
|
+
statsSql = addOwnTrafficScope(statsSql, statsParams, restScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
|
|
4256
|
+
statsSql += " GROUP BY status";
|
|
4257
|
+
const stats = db.all<any>(statsSql, ...statsParams);
|
|
4258
|
+
return withCors(req, Response.json({ ok: true, tasks: rows, count: rows.length, stats }));
|
|
4259
|
+
}
|
|
4260
|
+
|
|
4261
|
+
// ── REST: recent completions ──
|
|
4262
|
+
if (url.pathname === "/api/completions") {
|
|
4263
|
+
const since = url.searchParams.get("since") ?? new Date(Date.now() - 86400000).toISOString();
|
|
4264
|
+
const params: any[] = [since];
|
|
4265
|
+
let sql = `SELECT ${COMPLETION_REST_SELECT} FROM completions WHERE completed_at >= ?1`;
|
|
4266
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
4267
|
+
sql += " ORDER BY completed_at DESC LIMIT 100";
|
|
4268
|
+
const rows = db.all(sql, ...params);
|
|
4269
|
+
return withCors(req, Response.json({ ok: true, completions: rows }));
|
|
4270
|
+
}
|
|
4271
|
+
|
|
4272
|
+
return withCors(req, new Response(
|
|
4273
|
+
`CommHub MCP Server v${SERVER_VERSION} (Streamable HTTP + SSE Push)
|
|
4274
|
+
|
|
4275
|
+
Endpoints:
|
|
4276
|
+
POST /mcp - MCP Streamable HTTP (for Claude Code / Codex)
|
|
4277
|
+
GET /events/:session - SSE realtime push (Agent subscribes here)
|
|
4278
|
+
GET /health - Health check
|
|
4279
|
+
GET /api/status - All sessions (auth required)
|
|
4280
|
+
POST /api/task - Send task via REST (auth required)
|
|
4281
|
+
GET /api/tasks - Tasks table (V2) (auth required)
|
|
4282
|
+
GET /api/completions - Recent completions (auth required)
|
|
4283
|
+
GET /api/tmux/:name - Capture tmux pane output (${TMUX_ENABLED ? "admin + localhost/allowlist required" : "disabled"})
|
|
4284
|
+
POST /api/tmux/:name/send - Send keys to tmux (${TMUX_ENABLED ? "admin + localhost/allowlist required" : "disabled"})
|
|
4285
|
+
|
|
4286
|
+
Security: ${SECURITY_LABEL}
|
|
4287
|
+
`,
|
|
4288
|
+
{ status: 200, headers: { "Content-Type": "text/plain; charset=utf-8" } } // #426
|
|
4289
|
+
));
|
|
4290
|
+
})())));
|
|
4291
|
+
},
|
|
4292
|
+
|
|
4293
|
+
// ── WebSocket handler for tmux terminal streaming ──
|
|
4294
|
+
websocket: {
|
|
4295
|
+
open(ws) {
|
|
4296
|
+
const { tmuxName } = ws.data as unknown as { tmuxName: string };
|
|
4297
|
+
console.log(`[ws] tmux terminal opened: ${tmuxName}`);
|
|
4298
|
+
let lastOutput = "";
|
|
4299
|
+
|
|
4300
|
+
// Poll capture-pane every 200ms and send diffs
|
|
4301
|
+
const interval = setInterval(async () => {
|
|
4302
|
+
try {
|
|
4303
|
+
const proc = Bun.spawn(["tmux", "capture-pane", "-t", tmuxName, "-p", "-e"], {
|
|
4304
|
+
stdout: "pipe", stderr: "pipe",
|
|
4305
|
+
});
|
|
4306
|
+
const output = await new Response(proc.stdout).text();
|
|
4307
|
+
const code = await proc.exited;
|
|
4308
|
+
if (code !== 0) return;
|
|
4309
|
+
|
|
4310
|
+
if (output !== lastOutput) {
|
|
4311
|
+
lastOutput = output;
|
|
4312
|
+
ws.send(JSON.stringify({ type: "output", data: output }));
|
|
4313
|
+
}
|
|
4314
|
+
} catch { /* session gone */ }
|
|
4315
|
+
}, 200);
|
|
4316
|
+
|
|
4317
|
+
wsTmuxIntervals.set(ws, interval);
|
|
4318
|
+
|
|
4319
|
+
// Send initial capture immediately
|
|
4320
|
+
(async () => {
|
|
4321
|
+
try {
|
|
4322
|
+
const proc = Bun.spawn(["tmux", "capture-pane", "-t", tmuxName, "-p", "-e"], {
|
|
4323
|
+
stdout: "pipe", stderr: "pipe",
|
|
4324
|
+
});
|
|
4325
|
+
const output = await new Response(proc.stdout).text();
|
|
4326
|
+
const code = await proc.exited;
|
|
4327
|
+
if (code === 0) {
|
|
4328
|
+
lastOutput = output;
|
|
4329
|
+
ws.send(JSON.stringify({ type: "output", data: output }));
|
|
4330
|
+
} else {
|
|
4331
|
+
const err = await new Response(proc.stderr).text();
|
|
4332
|
+
ws.send(JSON.stringify({ type: "error", data: err.trim() || "tmux session not found" }));
|
|
4333
|
+
}
|
|
4334
|
+
} catch (e) {
|
|
4335
|
+
ws.send(JSON.stringify({ type: "error", data: (e as Error).message }));
|
|
4336
|
+
}
|
|
4337
|
+
})();
|
|
4338
|
+
},
|
|
4339
|
+
|
|
4340
|
+
async message(ws, message) {
|
|
4341
|
+
const { tmuxName } = ws.data as unknown as { tmuxName: string };
|
|
4342
|
+
try {
|
|
4343
|
+
const msg = JSON.parse(typeof message === "string" ? message : new TextDecoder().decode(message));
|
|
4344
|
+
|
|
4345
|
+
if (msg.type === "input" && typeof msg.data === "string") {
|
|
4346
|
+
// Send individual characters/sequences via send-keys
|
|
4347
|
+
const proc = Bun.spawn(["tmux", "send-keys", "-t", tmuxName, "-l", msg.data], {
|
|
4348
|
+
stdout: "pipe", stderr: "pipe",
|
|
4349
|
+
});
|
|
4350
|
+
await proc.exited;
|
|
4351
|
+
} else if (msg.type === "key" && typeof msg.data === "string") {
|
|
4352
|
+
// Send special key names (Enter, C-c, etc.)
|
|
4353
|
+
const proc = Bun.spawn(["tmux", "send-keys", "-t", tmuxName, msg.data], {
|
|
4354
|
+
stdout: "pipe", stderr: "pipe",
|
|
4355
|
+
});
|
|
4356
|
+
await proc.exited;
|
|
4357
|
+
} else if (msg.type === "resize" && msg.cols && msg.rows) {
|
|
4358
|
+
// Resize tmux pane
|
|
4359
|
+
Bun.spawn(["tmux", "resize-window", "-t", tmuxName, "-x", String(msg.cols), "-y", String(msg.rows)], {
|
|
4360
|
+
stdout: "pipe", stderr: "pipe",
|
|
4361
|
+
});
|
|
4362
|
+
}
|
|
4363
|
+
} catch { /* ignore malformed messages */ }
|
|
4364
|
+
},
|
|
4365
|
+
|
|
4366
|
+
close(ws) {
|
|
4367
|
+
const { tmuxName } = ws.data as unknown as { tmuxName: string };
|
|
4368
|
+
console.log(`[ws] tmux terminal closed: ${tmuxName}`);
|
|
4369
|
+
const interval = wsTmuxIntervals.get(ws);
|
|
4370
|
+
if (interval) { clearInterval(interval); wsTmuxIntervals.delete(ws); }
|
|
4371
|
+
},
|
|
4372
|
+
},
|
|
4373
|
+
});
|
|
4374
|
+
} // end bootServer
|
|
4375
|
+
|
|
4376
|
+
// ── Explicit startup (#438 corrective / 通信龙派单) ──────────────────
|
|
4377
|
+
//
|
|
4378
|
+
// Importing this module NO LONGER binds a port. The two failure modes
|
|
4379
|
+
// this design dodges, both field-verified:
|
|
4380
|
+
//
|
|
4381
|
+
// 1. `if (import.meta.main)` guard — the published package's process
|
|
4382
|
+
// entry is bin/commhub.ts which DYNAMICALLY imports this module, so
|
|
4383
|
+
// import.meta.main is always false there: the process comes up but
|
|
4384
|
+
// listens on nothing (#438 review finding; would have shipped dead).
|
|
4385
|
+
// 2. Module-level `export const server = bootServer()` side effect —
|
|
4386
|
+
// fixes (1) but makes EVERY import bind a port: tooling scripts,
|
|
4387
|
+
// typechecks, and unrelated tests all grab the port by accident.
|
|
4388
|
+
//
|
|
4389
|
+
// The contract instead: bin/commhub.ts (and anything else that wants a
|
|
4390
|
+
// running hub) calls `startHub()` explicitly. Tests that need a private
|
|
4391
|
+
// throwaway instance keep using `bootServer({ port: 0 })`, which stays
|
|
4392
|
+
// side-effect-free (no sweepers / signal handlers / banner).
|
|
4393
|
+
//
|
|
4394
|
+
// startHub is SINGLE-SHOT and observable: a second call throws with the
|
|
4395
|
+
// first boot's coordinates rather than silently no-op'ing — "I thought I
|
|
4396
|
+
// started it but nothing happened" is the same failure family as (1)
|
|
4397
|
+
// (承 通信测试马 verification design requirement).
|
|
4398
|
+
|
|
4399
|
+
let startedHub: { server: ReturnType<typeof Bun.serve>; startedAt: string } | null = null;
|
|
4400
|
+
|
|
4401
|
+
/** The hub instance startHub() booted, or null before startHub() runs.
|
|
4402
|
+
* Read-only introspection for diagnostics; do not boot through this. */
|
|
4403
|
+
export function getStartedHub(): { server: ReturnType<typeof Bun.serve>; startedAt: string } | null {
|
|
4404
|
+
return startedHub;
|
|
4405
|
+
}
|
|
4406
|
+
|
|
4407
|
+
export function startHub(opts?: { port?: number; hostname?: string }): ReturnType<typeof Bun.serve> {
|
|
4408
|
+
if (startedHub) {
|
|
4409
|
+
throw new Error(
|
|
4410
|
+
`startHub() already ran at ${startedHub.startedAt} (listening on ` +
|
|
4411
|
+
`${startedHub.server.hostname}:${startedHub.server.port}) — it is single-shot ` +
|
|
4412
|
+
`by design (sweepers + signal handlers must register exactly once). ` +
|
|
4413
|
+
`For an additional throwaway instance use bootServer({ port: 0 }).`
|
|
4414
|
+
);
|
|
4415
|
+
}
|
|
4416
|
+
// The scheduler needs the occurrence claim + task creation to be one
|
|
4417
|
+
// transaction. On an adapter whose transaction() is not atomic, refuse
|
|
4418
|
+
// before opening a listening socket instead of running an unsafe
|
|
4419
|
+
// scheduler.
|
|
4420
|
+
assertScheduledTaskBackendSupported();
|
|
4421
|
+
const server = bootServer(opts);
|
|
4422
|
+
|
|
4423
|
+
// Round-2/4 review ② — periodic retention sweep + incremental VACUUM.
|
|
4424
|
+
// Sweeps every hour by default. Operators can disable any single table
|
|
4425
|
+
// by setting COMMHUB_RETENTION_*_DAYS to a negative value, or shorten
|
|
4426
|
+
// the sweep window via COMMHUB_RETENTION_SWEEP_MINUTES.
|
|
4427
|
+
const sweepIntervalMinutes = Number(process.env.COMMHUB_RETENTION_SWEEP_MINUTES);
|
|
4428
|
+
const sweepIntervalMs = Number.isFinite(sweepIntervalMinutes) && sweepIntervalMinutes > 0
|
|
4429
|
+
? sweepIntervalMinutes * 60 * 1000
|
|
4430
|
+
: 60 * 60 * 1000;
|
|
4431
|
+
const retentionSweeperTimer = startRetentionSweeper(sweepIntervalMs);
|
|
4432
|
+
|
|
4433
|
+
// Round-2/4 review ③ — stale session sweeper (coexists with the
|
|
4434
|
+
// retention sweeper above; different concerns + different cadences).
|
|
4435
|
+
// Replaces the per-request UPDATE in GET /api/status (+ /api/servers,
|
|
4436
|
+
// /api/server-detail/*, MCP get_all_status). Each of those endpoints
|
|
4437
|
+
// used to run UPDATE on the sessions table just to maintain the
|
|
4438
|
+
// derived `offline` status; with the dashboard polling fast that was
|
|
4439
|
+
// 99% no-op write-amp under hot read paths. Now done globally once
|
|
4440
|
+
// every COMMHUB_STALE_SWEEP_SECONDS (default 60s).
|
|
4441
|
+
const staleSweeperTimer = startStaleSessionSweeper();
|
|
4442
|
+
|
|
4443
|
+
// #476 — the two legacy module-level intervals, now owned by startHub
|
|
4444
|
+
// like every other periodic job: rate-limit map cleanup (memory only)
|
|
4445
|
+
// and task expiration patrol (writes DB — must never run on import).
|
|
4446
|
+
// Periods env-overridable so tests can observe a real firing without
|
|
4447
|
+
// waiting minutes (mirrors COMMHUB_RETENTION_SWEEP_MINUTES above).
|
|
4448
|
+
const rateLimitSweepMs = Number(process.env.COMMHUB_RATELIMIT_SWEEP_MS) > 0
|
|
4449
|
+
? Number(process.env.COMMHUB_RATELIMIT_SWEEP_MS) : 300000;
|
|
4450
|
+
const taskPatrolMs = Number(process.env.COMMHUB_TASK_PATROL_MS) > 0
|
|
4451
|
+
? Number(process.env.COMMHUB_TASK_PATROL_MS) : 5 * 60 * 1000;
|
|
4452
|
+
const deliveredStalePatrolMs = Number(process.env.COMMHUB_DELIVERED_STALE_PATROL_MS) > 0
|
|
4453
|
+
? Number(process.env.COMMHUB_DELIVERED_STALE_PATROL_MS) : 5 * 1000;
|
|
4454
|
+
const rateLimitSweepTimer = setInterval(sweepStaleRateLimits, rateLimitSweepMs);
|
|
4455
|
+
const taskPatrolTimer = setInterval(patrolExpiredTasks, taskPatrolMs);
|
|
4456
|
+
patrolDeliveredStaleTasks();
|
|
4457
|
+
const deliveredStalePatrolTimer = setInterval(patrolDeliveredStaleTasks, deliveredStalePatrolMs);
|
|
4458
|
+
const scheduledTaskTimer = startScheduledTaskScheduler();
|
|
4459
|
+
|
|
4460
|
+
// The Bun.serve socket is what keeps the process alive; the periodic
|
|
4461
|
+
// jobs must not — otherwise a test (or a future caller) that stops the
|
|
4462
|
+
// server would still hang on live intervals it cannot reach.
|
|
4463
|
+
(retentionSweeperTimer as any)?.unref?.();
|
|
4464
|
+
(staleSweeperTimer as any)?.unref?.();
|
|
4465
|
+
(rateLimitSweepTimer as any)?.unref?.();
|
|
4466
|
+
(taskPatrolTimer as any)?.unref?.();
|
|
4467
|
+
(deliveredStalePatrolTimer as any)?.unref?.();
|
|
4468
|
+
(scheduledTaskTimer as any)?.unref?.();
|
|
4469
|
+
|
|
4470
|
+
// ── Graceful shutdown ───────────────────────────────
|
|
4471
|
+
function shutdown() {
|
|
4472
|
+
console.log("[commhub] shutting down...");
|
|
4473
|
+
clearInterval(retentionSweeperTimer);
|
|
4474
|
+
clearInterval(staleSweeperTimer);
|
|
4475
|
+
clearInterval(rateLimitSweepTimer);
|
|
4476
|
+
clearInterval(taskPatrolTimer);
|
|
4477
|
+
clearInterval(deliveredStalePatrolTimer);
|
|
4478
|
+
clearInterval(scheduledTaskTimer);
|
|
4479
|
+
db.close();
|
|
4480
|
+
process.exit(0);
|
|
4481
|
+
}
|
|
4482
|
+
process.on("SIGTERM", shutdown);
|
|
4483
|
+
process.on("SIGINT", shutdown);
|
|
4484
|
+
|
|
4485
|
+
// Banner reports the ACTUAL bound address (matters for port 0).
|
|
4486
|
+
const bHost = server.hostname ?? HOST;
|
|
4487
|
+
const bPort = server.port ?? PORT;
|
|
4488
|
+
console.log(`
|
|
4489
|
+
╔══════════════════════════════════════════════════╗
|
|
4490
|
+
║ CommHub MCP Server v${SERVER_VERSION} ║
|
|
4491
|
+
║ Transport: Streamable HTTP (Bun native) ║
|
|
4492
|
+
║ Security: ${SECURITY_LABEL}${" ".repeat(Math.max(0, 33 - SECURITY_LABEL.length))}║
|
|
4493
|
+
║ Tmux: ${TMUX_ENABLED ? "ENABLED (admin + localhost/allowlist)" : "DISABLED (set COMMHUB_ENABLE_TMUX=1)"}${" ".repeat(Math.max(0, TMUX_ENABLED ? 0 : 2))}║
|
|
4494
|
+
║ ║
|
|
4495
|
+
║ MCP: http://${bHost}:${bPort}/mcp ║
|
|
4496
|
+
║ REST: http://${bHost}:${bPort}/api ║
|
|
4497
|
+
║ Health: http://${bHost}:${bPort}/health ║
|
|
4498
|
+
╚══════════════════════════════════════════════════╝
|
|
4499
|
+
`);
|
|
4500
|
+
|
|
4501
|
+
// RFC-028 P1 boot banner — vault key configuration status.
|
|
4502
|
+
// F2 invariant: hub MUST boot regardless of vault state. Banner-only
|
|
4503
|
+
// (informational), fire-and-forget so startHub stays synchronous;
|
|
4504
|
+
// errors are raised lazily at vault op time. needsKeyToOp=true means
|
|
4505
|
+
// the operator should set ANET_HUB_SECRET_VAULT_KEY before
|
|
4506
|
+
// vault/provider features will work.
|
|
4507
|
+
import("./vault.js").then(({ vaultStatusForBoot }) => {
|
|
4508
|
+
const s = vaultStatusForBoot();
|
|
4509
|
+
if (s.needsKeyToOp) {
|
|
4510
|
+
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).");
|
|
4511
|
+
} else if (s.configured) {
|
|
4512
|
+
console.log(`[rfc-028 vault] master key configured (tables_have_data=${s.tablesHaveData})`);
|
|
4513
|
+
} else {
|
|
4514
|
+
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.");
|
|
4515
|
+
}
|
|
4516
|
+
}).catch((e: any) => {
|
|
4517
|
+
console.warn(`[rfc-028 vault] boot banner skipped (${e?.message || e})`);
|
|
4518
|
+
});
|
|
4519
|
+
|
|
4520
|
+
startedHub = { server, startedAt: new Date().toISOString() };
|
|
4521
|
+
return server;
|
|
4522
|
+
}
|