@sleep2agi/commhub-server 0.9.0-preview.77 → 0.9.0-preview.78
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/package.json +1 -1
- package/src/mcp-route-label-http.test.ts +120 -0
- package/src/route-timing.test.ts +106 -9
- package/src/route-timing.ts +115 -32
- package/src/server.ts +39 -3
- package/src/status-node-id-filter-http.test.ts +161 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
3
|
+
"version": "0.9.0-preview.78",
|
|
4
4
|
"description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// POST /mcp in /api/stats/routes carries the JSON-RPC method and, for tools/call, the tool name
|
|
2
|
+
// ("POST /mcp tools/call report_status"). Production showed POST /mcp as the biggest route (15 req/s,
|
|
3
|
+
// ~13% of a core) with no way to tell which tool. This suite pins, over real HTTP:
|
|
4
|
+
// - the labels (tools/call <name>, tools/list, batch) and that MCP responses are unchanged in kind;
|
|
5
|
+
// - arguments never reach the stats (a marker in arguments / a hostile tool name never shows up);
|
|
6
|
+
// - odd bodies still get the transport's own answers (invalid JSON → -32700, oversized → parsed by
|
|
7
|
+
// the transport itself) and are labelled "?";
|
|
8
|
+
// - unauthenticated /mcp stays plain "POST /mcp" (the body is never read before auth).
|
|
9
|
+
|
|
10
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
11
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-mcp-route-label-"));
|
|
16
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
17
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
18
|
+
|
|
19
|
+
const PW = "McpRouteLabelPassw0rd!";
|
|
20
|
+
const MARKER = "zz-argument-marker-7f3a";
|
|
21
|
+
let BASE = "";
|
|
22
|
+
let hub: any = null;
|
|
23
|
+
let admin = "";
|
|
24
|
+
|
|
25
|
+
const MCP_HEADERS = { "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" };
|
|
26
|
+
async function mcpRaw(token: string, body: string): Promise<{ status: number; text: string }> {
|
|
27
|
+
const res = await fetch(`${BASE}/mcp`, { method: "POST", headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...MCP_HEADERS }, body });
|
|
28
|
+
return { status: res.status, text: await res.text() };
|
|
29
|
+
}
|
|
30
|
+
function rpc(text: string): any {
|
|
31
|
+
const lines = text.split("\n").filter(x => x.startsWith("data:"));
|
|
32
|
+
return lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(text);
|
|
33
|
+
}
|
|
34
|
+
async function routes(): Promise<{ text: string; keys: string[]; rows: any[] }> {
|
|
35
|
+
const res = await fetch(`${BASE}/api/stats/routes?minutes=5`, { headers: { Authorization: `Bearer ${admin}` } });
|
|
36
|
+
const text = await res.text();
|
|
37
|
+
const rows = JSON.parse(text).routes as any[];
|
|
38
|
+
return { text, keys: rows.map(r => r.route), rows };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
beforeAll(async () => {
|
|
42
|
+
process.env.HOST = "127.0.0.1";
|
|
43
|
+
const { register } = await import("./auth.js");
|
|
44
|
+
const { db } = await import("./db.js");
|
|
45
|
+
const a = register(`mrl_admin_${Date.now()}`, PW);
|
|
46
|
+
admin = a.token!;
|
|
47
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [a.user!.user_id]);
|
|
48
|
+
const mod: any = await import("./server.js");
|
|
49
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
50
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
51
|
+
}, 30_000);
|
|
52
|
+
|
|
53
|
+
afterAll(() => {
|
|
54
|
+
try { hub?.stop?.(true); } catch {}
|
|
55
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("POST /mcp route label", () => {
|
|
59
|
+
test("tools/call is labelled with the tool name and still answers normally", async () => {
|
|
60
|
+
const r = await mcpRaw(admin, JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "get_all_status", arguments: { note: MARKER } } }));
|
|
61
|
+
expect(r.status).toBe(200);
|
|
62
|
+
expect(rpc(r.text).result).toBeDefined();
|
|
63
|
+
const { keys } = await routes();
|
|
64
|
+
expect(keys).toContain("POST /mcp tools/call get_all_status");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("tools/list and a batch get their own labels", async () => {
|
|
68
|
+
const list = await mcpRaw(admin, JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list" }));
|
|
69
|
+
expect(list.status).toBe(200);
|
|
70
|
+
expect(rpc(list.text).result.tools.length).toBeGreaterThan(10);
|
|
71
|
+
await mcpRaw(admin, JSON.stringify([{ jsonrpc: "2.0", id: 3, method: "tools/list" }]));
|
|
72
|
+
const { keys } = await routes();
|
|
73
|
+
expect(keys).toContain("POST /mcp tools/list");
|
|
74
|
+
expect(keys).toContain("POST /mcp batch");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("arguments and hostile tool names never reach the stats", async () => {
|
|
78
|
+
await mcpRaw(admin, JSON.stringify({ jsonrpc: "2.0", id: 4, method: "tools/call", params: { name: `x ${MARKER}`, arguments: { a: MARKER } } }));
|
|
79
|
+
await mcpRaw(admin, JSON.stringify({ jsonrpc: "2.0", id: 5, method: "tools/call", params: { name: "n".repeat(500), arguments: {} } }));
|
|
80
|
+
await mcpRaw(admin, JSON.stringify({ jsonrpc: "2.0", id: 6, method: `tools/${MARKER} with spaces` }));
|
|
81
|
+
const { text, keys } = await routes();
|
|
82
|
+
expect(text).not.toContain(MARKER);
|
|
83
|
+
expect(text).not.toContain("n".repeat(65));
|
|
84
|
+
expect(keys).toContain("POST /mcp tools/call ?");
|
|
85
|
+
expect(keys).toContain("POST /mcp ?");
|
|
86
|
+
for (const k of keys) expect(k.length).toBeLessThan(200);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("invalid JSON still gets the transport's parse error, labelled ?", async () => {
|
|
90
|
+
const r = await mcpRaw(admin, `{"jsonrpc":"2.0","method":"tools/list",`);
|
|
91
|
+
expect(r.status).toBe(400);
|
|
92
|
+
expect(rpc(r.text).error.code).toBe(-32700);
|
|
93
|
+
expect(rpc(r.text).error.message).toBe("Parse error: Invalid JSON");
|
|
94
|
+
const notRpc = await mcpRaw(admin, "null");
|
|
95
|
+
expect(notRpc.status).toBe(400);
|
|
96
|
+
expect(rpc(notRpc.text).error.code).toBe(-32700);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("a body over the label limit is left to the transport and still works, labelled ?", async () => {
|
|
100
|
+
const { __resetRouteTimingForTest } = await import("./route-timing.js");
|
|
101
|
+
__resetRouteTimingForTest();
|
|
102
|
+
const padded = JSON.stringify({ jsonrpc: "2.0", id: 7, method: "tools/list" }).replace("{", "{" + " ".repeat(1024 * 1024 + 10));
|
|
103
|
+
const r = await mcpRaw(admin, padded);
|
|
104
|
+
expect(r.status).toBe(200);
|
|
105
|
+
expect(rpc(r.text).result.tools.length).toBeGreaterThan(10);
|
|
106
|
+
const { keys } = await routes();
|
|
107
|
+
expect(keys).toContain("POST /mcp ?");
|
|
108
|
+
expect(keys).not.toContain("POST /mcp tools/list");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("unauthenticated /mcp is refused as before and not labelled", async () => {
|
|
112
|
+
const { __resetRouteTimingForTest } = await import("./route-timing.js");
|
|
113
|
+
__resetRouteTimingForTest();
|
|
114
|
+
const r = await mcpRaw("", JSON.stringify({ jsonrpc: "2.0", id: 8, method: "tools/list" }));
|
|
115
|
+
expect(r.status).toBe(401);
|
|
116
|
+
const { keys } = await routes();
|
|
117
|
+
expect(keys).toContain("POST /mcp");
|
|
118
|
+
expect(keys.filter(k => k.startsWith("POST /mcp "))).toEqual([]);
|
|
119
|
+
});
|
|
120
|
+
});
|
package/src/route-timing.test.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
__resetRouteTimingForTest, labelRoute, mcpRouteLabel, recordRouteTiming, routeKey, routeStats,
|
|
4
|
+
ROUTE_BUCKET_MS, ROUTE_BUCKET_RETENTION, ROUTE_MAX_KEYS_PER_BUCKET, ROUTE_OVERFLOW_KEY,
|
|
5
|
+
} from "./route-timing";
|
|
3
6
|
|
|
4
7
|
const u = (s: string) => new URL(`http://hub${s}`);
|
|
8
|
+
const req = (path: string, method = "GET") => new Request(`http://hub${path}`, { method });
|
|
9
|
+
const res = (status = 200, len?: number) => new Response("x", { status, headers: len === undefined ? {} : { "Content-Length": String(len) } });
|
|
10
|
+
const at = (ms: number) => performance.now() - ms;
|
|
5
11
|
|
|
6
12
|
test("routeKey collapses ids, aliases and file names, keeps payload-shaping params", () => {
|
|
7
13
|
expect(routeKey("GET", u("/api/requirements?network_id=net_1&limit=500"))).toBe("GET /api/requirements");
|
|
@@ -13,12 +19,9 @@ test("routeKey collapses ids, aliases and file names, keeps payload-shaping para
|
|
|
13
19
|
expect(routeKey("GET", u("/api/messages?scope=user&limit=50"))).toBe("GET /api/messages?scope=user");
|
|
14
20
|
});
|
|
15
21
|
|
|
16
|
-
test("routeStats groups the window, sorts by total time, and
|
|
22
|
+
test("routeStats groups the window, sorts by total time, and counts every request (no sample cap)", () => {
|
|
17
23
|
__resetRouteTimingForTest();
|
|
18
|
-
const now =
|
|
19
|
-
const req = (path: string) => new Request(`http://hub${path}`);
|
|
20
|
-
const res = (status = 200, len?: number) => new Response("x", { status, headers: len === undefined ? {} : { "Content-Length": String(len) } });
|
|
21
|
-
const at = (ms: number) => performance.now() - ms;
|
|
24
|
+
const now = 1_000_000_000;
|
|
22
25
|
recordRouteTiming(req("/api/requirements"), at(30), res(200, 1000), now);
|
|
23
26
|
recordRouteTiming(req("/api/requirements"), at(10), res(200, 500), now);
|
|
24
27
|
recordRouteTiming(req("/api/status?light=1"), at(5), res(503), now);
|
|
@@ -32,9 +35,103 @@ test("routeStats groups the window, sorts by total time, and the ring keeps only
|
|
|
32
35
|
expect(r0.bytes).toBe(1500);
|
|
33
36
|
expect(r0.max_ms).toBeGreaterThanOrEqual(29);
|
|
34
37
|
expect(r0.total_ms).toBeGreaterThanOrEqual(39);
|
|
38
|
+
expect(r0.p95_ms).toBeLessThanOrEqual(r0.max_ms);
|
|
35
39
|
expect(stats.routes[1].errors).toBe(1);
|
|
36
|
-
|
|
40
|
+
// The old 4096-sample ring dropped everything past its size; the buckets count all of it.
|
|
41
|
+
for (let i = 0; i < 10_000; i++) recordRouteTiming(req("/api/tasks"), at(1), res(), now);
|
|
37
42
|
const after = routeStats(60_000, now);
|
|
38
|
-
expect(after.
|
|
39
|
-
expect(after.
|
|
43
|
+
expect(after.routes.find(r => r.route === "GET /api/tasks")!.count).toBe(10_000);
|
|
44
|
+
expect(after.samples).toBe(10_003);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("?minutes=10 really covers 10 minutes: a steady 15 req/s stream is fully counted, older traffic is not", () => {
|
|
48
|
+
__resetRouteTimingForTest();
|
|
49
|
+
const t0 = 2_000_000_000 - (2_000_000_000 % ROUTE_BUCKET_MS); // bucket-aligned
|
|
50
|
+
// 20 minutes at 15 req/s — the old ring filled in ~4.5 minutes of this.
|
|
51
|
+
for (let s = 0; s < 20 * 60; s++) {
|
|
52
|
+
for (let k = 0; k < 15; k++) recordRouteTiming(req("/mcp", "POST"), at(1), res(), t0 + s * 1000);
|
|
53
|
+
}
|
|
54
|
+
const now = t0 + 20 * 60_000;
|
|
55
|
+
const ten = routeStats(10 * 60_000, now);
|
|
56
|
+
// window starts at t0+10min → the ten minute buckets t0+10 … t0+19
|
|
57
|
+
expect(ten.samples).toBe(10 * 60 * 15);
|
|
58
|
+
expect(ten.oldest_at).toBe(new Date(t0 + 10 * 60_000).toISOString());
|
|
59
|
+
expect(routeStats(20 * 60_000, now).samples).toBe(20 * 60 * 15);
|
|
60
|
+
expect(routeStats(60 * 60_000, now).samples).toBe(20 * 60 * 15);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("buckets older than the retention are dropped", () => {
|
|
64
|
+
__resetRouteTimingForTest();
|
|
65
|
+
const now = 3_000_000_000;
|
|
66
|
+
const old = now - (ROUTE_BUCKET_RETENTION + 5) * ROUTE_BUCKET_MS;
|
|
67
|
+
recordRouteTiming(req("/api/old"), at(1), res(), old);
|
|
68
|
+
expect(routeStats(ROUTE_BUCKET_RETENTION * 2 * ROUTE_BUCKET_MS, old).samples).toBe(1);
|
|
69
|
+
recordRouteTiming(req("/api/new"), at(1), res(), now);
|
|
70
|
+
const s = routeStats(ROUTE_BUCKET_RETENTION * 2 * ROUTE_BUCKET_MS, now);
|
|
71
|
+
expect(s.routes.map(r => r.route)).toEqual(["GET /api/new"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("p95 comes from the histogram: close to the true value and never above max", () => {
|
|
75
|
+
__resetRouteTimingForTest();
|
|
76
|
+
const now = 4_000_000_000;
|
|
77
|
+
// 96 fast (~1 ms) and 4 slow (~200 ms): p95 (the 96th of 100, same rank as the old ring's
|
|
78
|
+
// sorted[floor(n·0.95)]) must be in the fast band, max in the slow band.
|
|
79
|
+
for (let i = 0; i < 96; i++) recordRouteTiming(req("/api/p"), at(1), res(), now);
|
|
80
|
+
for (let i = 0; i < 4; i++) recordRouteTiming(req("/api/p"), at(200), res(), now);
|
|
81
|
+
const r = routeStats(60_000, now).routes[0];
|
|
82
|
+
expect(r.p95_ms).toBeGreaterThanOrEqual(1);
|
|
83
|
+
expect(r.p95_ms).toBeLessThan(5);
|
|
84
|
+
expect(r.max_ms).toBeGreaterThanOrEqual(200);
|
|
85
|
+
__resetRouteTimingForTest();
|
|
86
|
+
recordRouteTiming(req("/api/q"), at(3), res(), now);
|
|
87
|
+
const one = routeStats(60_000, now).routes[0];
|
|
88
|
+
expect(one.p95_ms).toBe(one.max_ms);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("distinct routes per bucket are capped; the rest land in the overflow key", () => {
|
|
92
|
+
__resetRouteTimingForTest();
|
|
93
|
+
const now = 5_000_000_000;
|
|
94
|
+
for (let i = 0; i < ROUTE_MAX_KEYS_PER_BUCKET + 50; i++) {
|
|
95
|
+
const r = req("/mcp", "POST");
|
|
96
|
+
labelRoute(r, `tools/call t${String.fromCharCode(97 + (i % 26))}${Math.floor(i / 26)}x`);
|
|
97
|
+
recordRouteTiming(r, at(1), res(), now);
|
|
98
|
+
}
|
|
99
|
+
const s = routeStats(60_000, now);
|
|
100
|
+
expect(s.routes.length).toBe(ROUTE_MAX_KEYS_PER_BUCKET + 1);
|
|
101
|
+
expect(s.routes.find(r => r.route === ROUTE_OVERFLOW_KEY)!.count).toBe(50);
|
|
102
|
+
expect(s.samples).toBe(ROUTE_MAX_KEYS_PER_BUCKET + 50);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("a label is appended to the route shape of that request only", () => {
|
|
106
|
+
__resetRouteTimingForTest();
|
|
107
|
+
const now = 6_000_000_000;
|
|
108
|
+
const a = req("/mcp", "POST");
|
|
109
|
+
labelRoute(a, "tools/call report_status");
|
|
110
|
+
recordRouteTiming(a, at(1), res(), now);
|
|
111
|
+
recordRouteTiming(req("/mcp", "POST"), at(1), res(), now);
|
|
112
|
+
expect(routeStats(60_000, now).routes.map(r => r.route).sort()).toEqual(["POST /mcp", "POST /mcp tools/call report_status"]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("mcpRouteLabel: method, tool name, and nothing else", () => {
|
|
116
|
+
expect(mcpRouteLabel({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "report_status", arguments: { task: "secret" } } })).toBe("tools/call report_status");
|
|
117
|
+
expect(mcpRouteLabel({ jsonrpc: "2.0", id: 1, method: "tools/list" })).toBe("tools/list");
|
|
118
|
+
expect(mcpRouteLabel({ jsonrpc: "2.0", method: "notifications/initialized" })).toBe("notifications/initialized");
|
|
119
|
+
expect(mcpRouteLabel({ jsonrpc: "2.0", id: 1, method: "initialize", params: { name: "x" } })).toBe("initialize");
|
|
120
|
+
// odd shapes collapse to "?"
|
|
121
|
+
expect(mcpRouteLabel([{ method: "tools/list" }])).toBe("batch");
|
|
122
|
+
expect(mcpRouteLabel(null)).toBe("?");
|
|
123
|
+
expect(mcpRouteLabel("tools/list")).toBe("?");
|
|
124
|
+
expect(mcpRouteLabel(42)).toBe("?");
|
|
125
|
+
expect(mcpRouteLabel({})).toBe("?");
|
|
126
|
+
expect(mcpRouteLabel({ method: 7 })).toBe("?");
|
|
127
|
+
expect(mcpRouteLabel({ method: "x".repeat(65) })).toBe("?");
|
|
128
|
+
expect(mcpRouteLabel({ method: "tools/call x" })).toBe("?");
|
|
129
|
+
expect(mcpRouteLabel({ method: "通信" })).toBe("?");
|
|
130
|
+
expect(mcpRouteLabel({ method: "tools/call" })).toBe("tools/call ?");
|
|
131
|
+
expect(mcpRouteLabel({ method: "tools/call", params: "report_status" })).toBe("tools/call ?");
|
|
132
|
+
expect(mcpRouteLabel({ method: "tools/call", params: { name: { toString: () => "x" } } })).toBe("tools/call ?");
|
|
133
|
+
expect(mcpRouteLabel({ method: "tools/call", params: { name: "a b" } })).toBe("tools/call ?");
|
|
134
|
+
expect(mcpRouteLabel({ method: "tools/call", params: { name: "../../etc/passwd" } })).toBe("tools/call ?");
|
|
135
|
+
expect(mcpRouteLabel({ method: "tools/call", params: { name: "n".repeat(65) } })).toBe("tools/call ?");
|
|
136
|
+
expect(mcpRouteLabel({ method: "tools/call", params: { name: "" } })).toBe("tools/call ?");
|
|
40
137
|
});
|
package/src/route-timing.ts
CHANGED
|
@@ -1,11 +1,29 @@
|
|
|
1
|
-
//
|
|
2
|
-
// 说不清是谁在吃 CPU。这里在 fetch
|
|
3
|
-
// GET /api/stats/routes(管理员 / master)按时间窗汇总。只在内存里,重启清空;不记 query
|
|
4
|
-
|
|
1
|
+
// 每个路由的耗时统计(2026-09-30):生产 hub 平均 ~24% CPU,但没有任何按路由的计时,也没有访问日志,
|
|
2
|
+
// 说不清是谁在吃 CPU。这里在 fetch 入口按路由形状累计次数、耗时、状态、字节,
|
|
3
|
+
// GET /api/stats/routes(管理员 / master)按时间窗汇总。只在内存里,重启清空;不记 query 值、不记调用者、不记请求体。
|
|
4
|
+
//
|
|
5
|
+
// 2026-10-01 —— 原来是 4096 条样本的环:生产上 212 s 就写满,?minutes=10 实际只覆盖三分半钟。
|
|
6
|
+
// 改成按分钟分桶的累计器:每个桶里每个路由一行(次数 / 总耗时 / 最大值 / 字节 / 5xx / 耗时直方图),
|
|
7
|
+
// 保留 ROUTE_BUCKET_RETENTION 个桶(24 小时),窗口多长就真覆盖多长。p95 从直方图取(桶上沿,不超过 max)。
|
|
8
|
+
export const ROUTE_BUCKET_MS = 60_000;
|
|
9
|
+
export const ROUTE_BUCKET_RETENTION = 1440;
|
|
10
|
+
/** 每个分钟桶里最多这么多个不同的路由;再多的记到 ROUTE_OVERFLOW_KEY,防止怪路径 / 怪工具名把内存撑大。 */
|
|
11
|
+
export const ROUTE_MAX_KEYS_PER_BUCKET = 256;
|
|
12
|
+
export const ROUTE_OVERFLOW_KEY = "(other)";
|
|
5
13
|
|
|
6
|
-
|
|
7
|
-
const
|
|
8
|
-
|
|
14
|
+
// 耗时直方图:第 i 格的上沿 = HIST_BASE_MS · 2^(i/2),0.05 ms … ~52 s;最后一格收下更慢的。
|
|
15
|
+
const HIST_BASE_MS = 0.05;
|
|
16
|
+
const HIST_BINS = 41;
|
|
17
|
+
function histBin(ms: number): number {
|
|
18
|
+
if (!(ms > HIST_BASE_MS)) return 0;
|
|
19
|
+
return Math.min(HIST_BINS - 1, Math.ceil(2 * Math.log2(ms / HIST_BASE_MS)));
|
|
20
|
+
}
|
|
21
|
+
const histUpper = (bin: number) => HIST_BASE_MS * 2 ** (bin / 2);
|
|
22
|
+
|
|
23
|
+
type Agg = { count: number; totalMs: number; maxMs: number; bytes: number; errors: number; hist: Uint32Array };
|
|
24
|
+
type Bucket = { minute: number; routes: Map<string, Agg> };
|
|
25
|
+
// 按 minute 升序;新的一分钟追加在尾部,超出保留期从头部丢。
|
|
26
|
+
const buckets: Bucket[] = [];
|
|
9
27
|
|
|
10
28
|
/** 路由的形状:id / 别名 / 文件名这类段换成 :id,几个会改变载荷形状的参数保留(light / view / scope / changes)。 */
|
|
11
29
|
const SHAPE_PARAMS = ["changes", "light", "scope", "view"];
|
|
@@ -18,46 +36,112 @@ export function routeKey(method: string, url: URL): string {
|
|
|
18
36
|
return `${method} ${segments.join("/")}${shape ? `?${shape}` : ""}`;
|
|
19
37
|
}
|
|
20
38
|
|
|
39
|
+
// 处理器给某个请求补一段标签(比如 POST /mcp 的 JSON-RPC 方法名),recordRouteTiming 拼到路由形状后面。
|
|
40
|
+
const labels = new WeakMap<Request, string>();
|
|
41
|
+
export function labelRoute(req: Request, label: string): void {
|
|
42
|
+
labels.set(req, label);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const MCP_METHOD_RE = /^[A-Za-z][A-Za-z0-9_./-]{0,63}$/;
|
|
46
|
+
const MCP_TOOL_RE = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
47
|
+
/**
|
|
48
|
+
* POST /mcp 的标签:JSON-RPC 方法名,tools/call 再带工具名,如 "tools/call report_status"。
|
|
49
|
+
* 只读 method 和 params.name 两个字段,别的(尤其 arguments)一概不碰;形状不对的一律折成 "?",
|
|
50
|
+
* 批量请求记 "batch"。名字只放行短的 ASCII 标识符,防止拿请求体造出任意多、任意长的路由键。
|
|
51
|
+
*/
|
|
52
|
+
export function mcpRouteLabel(body: unknown): string {
|
|
53
|
+
if (Array.isArray(body)) return "batch";
|
|
54
|
+
if (!body || typeof body !== "object") return "?";
|
|
55
|
+
const method = (body as { method?: unknown }).method;
|
|
56
|
+
if (typeof method !== "string" || !MCP_METHOD_RE.test(method)) return "?";
|
|
57
|
+
if (method !== "tools/call") return method;
|
|
58
|
+
const params = (body as { params?: unknown }).params;
|
|
59
|
+
const name = params && typeof params === "object" ? (params as { name?: unknown }).name : undefined;
|
|
60
|
+
return `tools/call ${typeof name === "string" && MCP_TOOL_RE.test(name) ? name : "?"}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
21
63
|
/** 记一次请求;原样返回 res,方便套在 fetch 处理器的 return 上。WebSocket 升级(res 为空)不记。 */
|
|
22
64
|
export function recordRouteTiming<T extends Response | undefined>(req: Request, started: number, res: T, now: number = Date.now()): T {
|
|
23
65
|
if (!res) return res;
|
|
24
66
|
let url: URL;
|
|
25
67
|
try { url = new URL(req.url); } catch { return res; }
|
|
68
|
+
const label = labels.get(req);
|
|
69
|
+
const route = label ? `${routeKey(req.method, url)} ${label}` : routeKey(req.method, url);
|
|
26
70
|
const length = Number(res.headers.get("content-length"));
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
else ring[next] = sample;
|
|
30
|
-
next = (next + 1) % ROUTE_RING_SIZE;
|
|
71
|
+
const bytes = Number.isFinite(length) && res.headers.has("content-length") ? length : 0;
|
|
72
|
+
addSample(route, performance.now() - started, res.status, bytes, now);
|
|
31
73
|
return res;
|
|
32
74
|
}
|
|
33
75
|
|
|
76
|
+
function addSample(route: string, ms: number, status: number, bytes: number, now: number): void {
|
|
77
|
+
const minute = Math.floor(now / ROUTE_BUCKET_MS);
|
|
78
|
+
let bucket = buckets[buckets.length - 1];
|
|
79
|
+
if (!bucket || minute > bucket.minute) {
|
|
80
|
+
buckets.push(bucket = { minute, routes: new Map() });
|
|
81
|
+
const oldest = minute - ROUTE_BUCKET_RETENTION + 1;
|
|
82
|
+
let drop = 0;
|
|
83
|
+
while (drop < buckets.length && buckets[drop].minute < oldest) drop++;
|
|
84
|
+
if (drop) buckets.splice(0, drop);
|
|
85
|
+
} else if (minute < bucket.minute) {
|
|
86
|
+
// 时间戳比最新的桶早(时钟回拨,或测试里乱序记):放回它自己的那一分钟;比保留期还早的丢掉。
|
|
87
|
+
if (minute < bucket.minute - ROUTE_BUCKET_RETENTION + 1) return;
|
|
88
|
+
let i = buckets.length - 1;
|
|
89
|
+
while (i > 0 && buckets[i - 1].minute >= minute) i--;
|
|
90
|
+
if (buckets[i].minute !== minute) buckets.splice(i, 0, { minute, routes: new Map() });
|
|
91
|
+
bucket = buckets[i];
|
|
92
|
+
}
|
|
93
|
+
let agg = bucket.routes.get(route);
|
|
94
|
+
if (!agg) {
|
|
95
|
+
if (bucket.routes.size >= ROUTE_MAX_KEYS_PER_BUCKET) route = ROUTE_OVERFLOW_KEY;
|
|
96
|
+
agg = bucket.routes.get(route);
|
|
97
|
+
if (!agg) bucket.routes.set(route, agg = { count: 0, totalMs: 0, maxMs: 0, bytes: 0, errors: 0, hist: new Uint32Array(HIST_BINS) });
|
|
98
|
+
}
|
|
99
|
+
agg.count++;
|
|
100
|
+
agg.totalMs += ms;
|
|
101
|
+
if (ms > agg.maxMs) agg.maxMs = ms;
|
|
102
|
+
agg.bytes += bytes;
|
|
103
|
+
if (status >= 500) agg.errors++;
|
|
104
|
+
agg.hist[histBin(ms)]++;
|
|
105
|
+
}
|
|
106
|
+
|
|
34
107
|
export type RouteStat = { route: string; count: number; total_ms: number; avg_ms: number; p95_ms: number; max_ms: number; bytes: number; errors: number };
|
|
35
108
|
|
|
36
|
-
/**
|
|
109
|
+
/**
|
|
110
|
+
* 最近 windowMs 内的请求按路由汇总,按总耗时降序。粒度是分钟桶:窗口起点落在哪个桶,就从那个桶算起。
|
|
111
|
+
* oldest_at 是窗口里最早一个有数据的桶的起点 —— 它比 now - window_ms 晚很多,说明 hub 是那之后才起来的。
|
|
112
|
+
*/
|
|
37
113
|
export function routeStats(windowMs: number, now: number = Date.now()): { window_ms: number; samples: number; oldest_at: string | null; routes: RouteStat[] } {
|
|
38
|
-
const
|
|
39
|
-
const
|
|
114
|
+
const fromMinute = Math.floor((now - windowMs) / ROUTE_BUCKET_MS);
|
|
115
|
+
const merged = new Map<string, Agg>();
|
|
40
116
|
let oldest: number | null = null;
|
|
41
|
-
for (const
|
|
42
|
-
if (
|
|
43
|
-
if (oldest === null
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
117
|
+
for (const bucket of buckets) {
|
|
118
|
+
if (bucket.minute < fromMinute) continue;
|
|
119
|
+
if (oldest === null) oldest = bucket.minute * ROUTE_BUCKET_MS;
|
|
120
|
+
for (const [route, a] of bucket.routes) {
|
|
121
|
+
let m = merged.get(route);
|
|
122
|
+
if (!m) merged.set(route, m = { count: 0, totalMs: 0, maxMs: 0, bytes: 0, errors: 0, hist: new Uint32Array(HIST_BINS) });
|
|
123
|
+
m.count += a.count;
|
|
124
|
+
m.totalMs += a.totalMs;
|
|
125
|
+
if (a.maxMs > m.maxMs) m.maxMs = a.maxMs;
|
|
126
|
+
m.bytes += a.bytes;
|
|
127
|
+
m.errors += a.errors;
|
|
128
|
+
for (let i = 0; i < HIST_BINS; i++) m.hist[i] += a.hist[i];
|
|
129
|
+
}
|
|
47
130
|
}
|
|
48
131
|
const round = (n: number) => Math.round(n * 10) / 10;
|
|
49
|
-
const routes = [...
|
|
50
|
-
const
|
|
51
|
-
|
|
132
|
+
const routes = [...merged].map(([route, a]): RouteStat => {
|
|
133
|
+
const rank = Math.min(a.count, Math.floor(a.count * 0.95) + 1);
|
|
134
|
+
let seen = 0, bin = 0;
|
|
135
|
+
for (; bin < HIST_BINS - 1; bin++) { seen += a.hist[bin]; if (seen >= rank) break; }
|
|
52
136
|
return {
|
|
53
137
|
route,
|
|
54
|
-
count:
|
|
55
|
-
total_ms: round(
|
|
56
|
-
avg_ms: round(
|
|
57
|
-
p95_ms: round(
|
|
58
|
-
max_ms: round(
|
|
59
|
-
bytes:
|
|
60
|
-
errors:
|
|
138
|
+
count: a.count,
|
|
139
|
+
total_ms: round(a.totalMs),
|
|
140
|
+
avg_ms: round(a.totalMs / a.count),
|
|
141
|
+
p95_ms: round(Math.min(histUpper(bin), a.maxMs)),
|
|
142
|
+
max_ms: round(a.maxMs),
|
|
143
|
+
bytes: a.bytes,
|
|
144
|
+
errors: a.errors,
|
|
61
145
|
};
|
|
62
146
|
}).sort((a, b) => b.total_ms - a.total_ms);
|
|
63
147
|
return { window_ms: windowMs, samples: routes.reduce((n, r) => n + r.count, 0), oldest_at: oldest === null ? null : new Date(oldest).toISOString(), routes };
|
|
@@ -65,6 +149,5 @@ export function routeStats(windowMs: number, now: number = Date.now()): { window
|
|
|
65
149
|
|
|
66
150
|
/** Test-only. */
|
|
67
151
|
export function __resetRouteTimingForTest(): void {
|
|
68
|
-
|
|
69
|
-
next = 0;
|
|
152
|
+
buckets.length = 0;
|
|
70
153
|
}
|
package/src/server.ts
CHANGED
|
@@ -61,7 +61,7 @@ import { stampTaskAuthOrigin, type TaskAuthOrigin } from "./task-auth-origin.js"
|
|
|
61
61
|
import { diagnoseTask } from "./task-diagnostic.js";
|
|
62
62
|
import { assertScheduledTaskBackendSupported, handleScheduledTaskRequest, startScheduledTaskScheduler } from "./scheduled-tasks.js";
|
|
63
63
|
import { handleRequirementsRequest } from "./requirements.js";
|
|
64
|
-
import { recordRouteTiming, routeStats } from "./route-timing.js";
|
|
64
|
+
import { labelRoute, mcpRouteLabel, recordRouteTiming, routeStats } from "./route-timing.js";
|
|
65
65
|
import { handleExternalScheduleEditRequest } from "./external-schedule-edits.js";
|
|
66
66
|
import { recordDeliveredStaleEvents } from "./task-lifecycle-watcher.js";
|
|
67
67
|
import { SIDE_THREAD_FEATURE_FLAG, SideThreadCoordinator, SideThreadPortRegistry, SideThreadStore, type SideThreadActor, type SideThreadAttachmentRef, type SideThreadExecutionPort } from "./side-thread.js";
|
|
@@ -103,6 +103,9 @@ if (AUTH_TOKEN) {
|
|
|
103
103
|
console.warn("[commhub] COMMHUB_AUTH_TOKEN is deprecated and will be removed in v1.0. See RFC-001.");
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
// POST /mcp bodies up to this size are parsed once up front to label the route (see the /mcp handler).
|
|
107
|
+
const MCP_LABEL_MAX_BYTES = 1024 * 1024;
|
|
108
|
+
|
|
106
109
|
// Read version from package.json so banners and /health stay in sync.
|
|
107
110
|
const SERVER_VERSION = (() => {
|
|
108
111
|
try {
|
|
@@ -111,6 +114,9 @@ const SERVER_VERSION = (() => {
|
|
|
111
114
|
} catch { return "?"; }
|
|
112
115
|
})();
|
|
113
116
|
|
|
117
|
+
// Feature flags advertised on /health (see the /health handler). Exported for tests.
|
|
118
|
+
export const HUB_HEALTH_CAPABILITIES = ["status_node_id"] as const;
|
|
119
|
+
|
|
114
120
|
// In-memory log ring buffer — last N lines streamed via /api/server-logs.
|
|
115
121
|
// Wraps console.log/info/warn/error so EVERY existing log call lands here
|
|
116
122
|
// without source changes. Dashboard tails this buffer for "hub server log
|
|
@@ -941,9 +947,22 @@ return Bun.serve({
|
|
|
941
947
|
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
942
948
|
sessionIdGenerator: undefined,
|
|
943
949
|
});
|
|
950
|
+
// 2026-10-01 —— /api/stats/routes 里 POST /mcp 是最大的一项(生产 15 req/s),但看不出是哪个工具。
|
|
951
|
+
// 请求体我们自己读一次、解析一次,只取 method / params.name 做路由标签(mcpRouteLabel),
|
|
952
|
+
// 解析结果作为 parsedBody 交给 transport,不重复解析。没有 Content-Length 或超过 MCP_LABEL_MAX_BYTES
|
|
953
|
+
// 的请求体不碰,由 transport 照旧自己读。不是合法 JSON 时不传 parsedBody:transport 读已消费的
|
|
954
|
+
// body 失败,落进它自己的 catch,回的仍是原来那条 -32700「Parse error: Invalid JSON」(测试钉住)。
|
|
955
|
+
let mcpParsedBody: unknown;
|
|
956
|
+
if (req.method === "POST") {
|
|
957
|
+
const declared = Number(req.headers.get("content-length"));
|
|
958
|
+
if (req.headers.has("content-length") && Number.isFinite(declared) && declared <= MCP_LABEL_MAX_BYTES) {
|
|
959
|
+
try { mcpParsedBody = JSON.parse(await req.text()); } catch { mcpParsedBody = undefined; }
|
|
960
|
+
}
|
|
961
|
+
labelRoute(req, mcpParsedBody === undefined ? "?" : mcpRouteLabel(mcpParsedBody));
|
|
962
|
+
}
|
|
944
963
|
const mcpServer = createServer(clientIP, enforceNetId, authCtx?.userId || null, callerAlias, !!token?.startsWith("ntok_"), authCtx?.tokenId || null);
|
|
945
964
|
await mcpServer.connect(transport);
|
|
946
|
-
const response = await transport.handleRequest(req);
|
|
965
|
+
const response = await transport.handleRequest(req, mcpParsedBody === undefined ? undefined : { parsedBody: mcpParsedBody });
|
|
947
966
|
// Disconnect after response to prevent McpServer leak
|
|
948
967
|
setImmediate(() => mcpServer.close().catch(() => {}));
|
|
949
968
|
// #426: the MCP SDK's WebStandardStreamableHTTPServerTransport builds
|
|
@@ -2007,6 +2026,11 @@ return Bun.serve({
|
|
|
2007
2026
|
max_upload_bytes: MAX_UPLOAD_BYTES,
|
|
2008
2027
|
max_request_content_length: MAX_REQUEST_CONTENT_LENGTH,
|
|
2009
2028
|
},
|
|
2029
|
+
// Public, stable feature flags for REST reads whose own response can't carry them.
|
|
2030
|
+
// status_node_id: GET /api/status accepts ?node_id=<id> (one node's rows; with light=1 the
|
|
2031
|
+
// row also carries node_id). The /api/status body stays byte-identical without the param, so
|
|
2032
|
+
// the flag lives here — agent-nodes already read /health once at boot.
|
|
2033
|
+
capabilities: HUB_HEALTH_CAPABILITIES,
|
|
2010
2034
|
};
|
|
2011
2035
|
if (scopedSessions !== undefined) body.sse_sessions = scopedSessions;
|
|
2012
2036
|
return withCors(req, Response.json(body));
|
|
@@ -2180,9 +2204,10 @@ return Bun.serve({
|
|
|
2180
2204
|
// is unchanged so the dashboard / scripts that read full telemetry are
|
|
2181
2205
|
// unaffected.
|
|
2182
2206
|
const isLight = url.searchParams.get("light") === "1";
|
|
2207
|
+
const nodeIdFilter = url.searchParams.get("node_id")?.trim();
|
|
2183
2208
|
const params: any[] = [];
|
|
2184
2209
|
let sql = isLight
|
|
2185
|
-
?
|
|
2210
|
+
? `SELECT alias, status, agent, task, server, updated_at, network_id${nodeIdFilter ? ", node_id" : ""} FROM sessions WHERE 1=1`
|
|
2186
2211
|
: `SELECT ${SESSION_REST_SELECT} FROM sessions WHERE 1=1`;
|
|
2187
2212
|
sql = addAgentNetworkScope(sql, params, restScope, { alias: "alias", nodeId: "node_id" });
|
|
2188
2213
|
// `?alias=<name>` narrows to that one agent's rows (same projection, same scope). The app's
|
|
@@ -2195,6 +2220,15 @@ return Bun.serve({
|
|
|
2195
2220
|
params.push(aliasFilter);
|
|
2196
2221
|
sql += ` AND alias = ?${params.length}`;
|
|
2197
2222
|
}
|
|
2223
|
+
// `?node_id=<id>` narrows to one node's rows the same way (same scope, same projection). Every
|
|
2224
|
+
// agent-node resolves its own current alias every 30 s; unfiltered that is the whole network's
|
|
2225
|
+
// full projection (~91 KB gzip per call on production) to read one row. Advertised in /health
|
|
2226
|
+
// `capabilities` ("status_node_id") because old hubs ignore the parameter and — in the light
|
|
2227
|
+
// projection, which has no node_id — the caller could not tell its row from anyone else's.
|
|
2228
|
+
if (nodeIdFilter) {
|
|
2229
|
+
params.push(nodeIdFilter);
|
|
2230
|
+
sql += ` AND node_id = ?${params.length}`;
|
|
2231
|
+
}
|
|
2198
2232
|
sql += " ORDER BY updated_at DESC";
|
|
2199
2233
|
// `model` comes straight from the explicit sessions projection; `runtime` is
|
|
2200
2234
|
// derived from the raw `agent` field. Both default to null for old nodes
|
|
@@ -2211,6 +2245,8 @@ return Bun.serve({
|
|
|
2211
2245
|
updated_at: s.updated_at ?? null,
|
|
2212
2246
|
runtime: normalizeRuntime(s.agent),
|
|
2213
2247
|
network_id: s.network_id ?? null,
|
|
2248
|
+
// Only when filtering by node_id, so the unfiltered light response keeps its exact bytes.
|
|
2249
|
+
...(nodeIdFilter ? { node_id: s.node_id ?? null } : {}),
|
|
2214
2250
|
};
|
|
2215
2251
|
}
|
|
2216
2252
|
const externalSchedules = (() => {
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// GET /api/status?node_id=<id> — one node's rows instead of the whole network.
|
|
2
|
+
// Every agent-node resolves its own current alias every 30 s (CurrentAliasResolver). Unfiltered that
|
|
3
|
+
// is the whole network's full projection (~91 KB gzip per call on production, ~90 nodes ⇒ ~3 req/s)
|
|
4
|
+
// to read one row. This suite pins that the filter narrows, never widens (it is exactly the caller's
|
|
5
|
+
// unfiltered rows with that node_id, for every kind of caller), that it combines with light=1 (which
|
|
6
|
+
// then carries node_id so the caller can check the row is its own), that an unknown id is an empty
|
|
7
|
+
// list rather than an error, that no/blank node_id is byte-identical to the old response, and that
|
|
8
|
+
// /health advertises it.
|
|
9
|
+
|
|
10
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
11
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
import { tmpdir } from "os";
|
|
14
|
+
import { register, createNetworkTokenForNode } from "./auth.js";
|
|
15
|
+
import { db } from "./db.js";
|
|
16
|
+
|
|
17
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-status-node-id-"));
|
|
18
|
+
let BASE = "";
|
|
19
|
+
let hub: any = null;
|
|
20
|
+
const PW = "StatusNodeIdPassw0rd!";
|
|
21
|
+
let aToken = "", aId = "", NET_A = "";
|
|
22
|
+
let bToken = "", bId = "", NET_B = "";
|
|
23
|
+
let nodeToken = "";
|
|
24
|
+
|
|
25
|
+
async function raw(token: string | null, path: string) {
|
|
26
|
+
const res = await fetch(`${BASE}${path}`, { headers: token ? { Authorization: `Bearer ${token}` } : {} });
|
|
27
|
+
return { status: res.status, text: await res.text() };
|
|
28
|
+
}
|
|
29
|
+
async function get(token: string, path: string) {
|
|
30
|
+
const r = await raw(token, path);
|
|
31
|
+
return { status: r.status, body: JSON.parse(r.text) as any };
|
|
32
|
+
}
|
|
33
|
+
function seed(alias: string, node: string, net: string, owner: string, minutesAgo: number) {
|
|
34
|
+
db.run(`INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id) VALUES (?1, ?2, ?2, ?3, ?4)`, [node, alias, net, owner]);
|
|
35
|
+
db.run(
|
|
36
|
+
`INSERT INTO sessions (resume_id, alias, node_id, status, network_id, task, updated_at, last_seen_at) VALUES (?1, ?2, ?3, 'idle', ?4, ?5, datetime('now', ?6), datetime('now'))`,
|
|
37
|
+
[`r_${node}`, alias, node, net, `task of ${alias}`, `-${minutesAgo} minutes`],
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
const nodeIds = (rows: any[]) => rows.map(r => r.node_id);
|
|
41
|
+
|
|
42
|
+
beforeAll(async () => {
|
|
43
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
44
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
45
|
+
process.env.HOST = "127.0.0.1";
|
|
46
|
+
const a = register(`sn_owner_a_${Date.now()}`, PW);
|
|
47
|
+
aToken = a.token!; aId = a.user!.user_id; NET_A = a.network_id!;
|
|
48
|
+
const b = register(`sn_owner_b_${Date.now()}`, PW);
|
|
49
|
+
bToken = b.token!; bId = b.user!.user_id; NET_B = b.network_id!;
|
|
50
|
+
seed("sn-one", "node_sn_one", NET_A, aId, 1);
|
|
51
|
+
seed("sn-two", "node_sn_two", NET_A, aId, 2);
|
|
52
|
+
seed("sn-three", "node_sn_three", NET_A, aId, 3);
|
|
53
|
+
seed("sn-b-only", "node_sn_b_only", NET_B, bId, 0);
|
|
54
|
+
const minted = createNetworkTokenForNode(aId, NET_A, "sn-two", "node_sn_two");
|
|
55
|
+
nodeToken = minted.token!;
|
|
56
|
+
const mod: any = await import("./server.js");
|
|
57
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
58
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
59
|
+
}, 30_000);
|
|
60
|
+
|
|
61
|
+
afterAll(() => {
|
|
62
|
+
try { hub?.stop?.(true); } catch {}
|
|
63
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("/api/status?node_id=", () => {
|
|
67
|
+
test("node_id narrows the full projection to that node", async () => {
|
|
68
|
+
const r = await get(aToken, `/api/status?network_id=${NET_A}&node_id=node_sn_two`);
|
|
69
|
+
expect(r.status).toBe(200);
|
|
70
|
+
expect(nodeIds(r.body.sessions)).toEqual(["node_sn_two"]);
|
|
71
|
+
expect(r.body.sessions[0].alias).toBe("sn-two");
|
|
72
|
+
expect(typeof r.body.sessions[0].rules_file_capable).toBe("boolean");
|
|
73
|
+
expect(r.body.sessions[0].host).toBeDefined();
|
|
74
|
+
expect(r.body.summary.total).toBe(1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("combines with light=1: light projection, one row, and it carries node_id", async () => {
|
|
78
|
+
const r = await get(aToken, `/api/status?light=1&network_id=${NET_A}&node_id=node_sn_three`);
|
|
79
|
+
expect(r.status).toBe(200);
|
|
80
|
+
expect(r.body.sessions).toHaveLength(1);
|
|
81
|
+
expect(r.body.sessions[0].alias).toBe("sn-three");
|
|
82
|
+
expect(r.body.sessions[0].node_id).toBe("node_sn_three");
|
|
83
|
+
expect(r.body.sessions[0].host).toBeUndefined();
|
|
84
|
+
expect(Object.keys(r.body.sessions[0]).sort()).toEqual(
|
|
85
|
+
["agent", "alias", "network_id", "node_id", "runtime", "server", "status", "task", "updated_at"],
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("unknown id is an empty list with 200, not a 404", async () => {
|
|
90
|
+
for (const path of [`/api/status?network_id=${NET_A}&node_id=node_nobody`, `/api/status?light=1&node_id=node_nobody`]) {
|
|
91
|
+
const r = await get(aToken, path);
|
|
92
|
+
expect(r.status).toBe(200);
|
|
93
|
+
expect(r.body.ok).toBe(true);
|
|
94
|
+
expect(r.body.sessions).toEqual([]);
|
|
95
|
+
expect(r.body.summary.total).toBe(0);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("absent or blank node_id: response bytes identical to the old read (full and light)", async () => {
|
|
100
|
+
for (const base of [`/api/status?network_id=${NET_A}`, `/api/status?light=1&network_id=${NET_A}`, `/api/status`]) {
|
|
101
|
+
const plain = await raw(aToken, base);
|
|
102
|
+
const blank = await raw(aToken, `${base}${base.includes("?") ? "&" : "?"}node_id=%20`);
|
|
103
|
+
expect(plain.status).toBe(200);
|
|
104
|
+
expect(blank.text).toBe(plain.text);
|
|
105
|
+
}
|
|
106
|
+
// the light rows without the param never gained a node_id field
|
|
107
|
+
const light = await get(aToken, `/api/status?light=1&network_id=${NET_A}`);
|
|
108
|
+
expect(light.body.sessions.length).toBe(3);
|
|
109
|
+
for (const s of light.body.sessions) expect("node_id" in s).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("never widens: for every caller, filtered == that caller's unfiltered rows with that node_id", async () => {
|
|
113
|
+
const callers = [aToken, bToken, nodeToken];
|
|
114
|
+
const ids = ["node_sn_one", "node_sn_two", "node_sn_three", "node_sn_b_only", "node_nobody"];
|
|
115
|
+
for (const tok of callers) {
|
|
116
|
+
const full = await get(tok, `/api/status`);
|
|
117
|
+
expect(full.status).toBe(200);
|
|
118
|
+
for (const id of ids) {
|
|
119
|
+
const one = await get(tok, `/api/status?node_id=${id}`);
|
|
120
|
+
expect(one.status).toBe(full.status);
|
|
121
|
+
expect(one.body.sessions).toEqual(full.body.sessions.filter((s: any) => s.node_id === id));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("a member of another network gets nothing for a node outside their network", async () => {
|
|
127
|
+
const r = await get(bToken, `/api/status?node_id=node_sn_one`);
|
|
128
|
+
expect(r.status).toBe(200);
|
|
129
|
+
expect(r.body.sessions).toEqual([]);
|
|
130
|
+
const own = await get(bToken, `/api/status?node_id=node_sn_b_only`);
|
|
131
|
+
expect(nodeIds(own.body.sessions)).toEqual(["node_sn_b_only"]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("a node token reads its own row (light) and nothing from another network", async () => {
|
|
135
|
+
const own = await get(nodeToken, `/api/status?light=1&node_id=node_sn_two`);
|
|
136
|
+
expect(own.status).toBe(200);
|
|
137
|
+
expect(own.body.sessions.map((s: any) => [s.node_id, s.alias])).toEqual([["node_sn_two", "sn-two"]]);
|
|
138
|
+
const foreign = await get(nodeToken, `/api/status?light=1&node_id=node_sn_b_only`);
|
|
139
|
+
expect(foreign.status).toBe(200);
|
|
140
|
+
expect(foreign.body.sessions).toEqual([]);
|
|
141
|
+
// explicit other network_id is refused exactly as without the filter
|
|
142
|
+
const cross = await raw(nodeToken, `/api/status?network_id=${NET_B}&node_id=node_sn_b_only`);
|
|
143
|
+
const crossNoFilter = await raw(nodeToken, `/api/status?network_id=${NET_B}`);
|
|
144
|
+
expect(cross.status).toBe(crossNoFilter.status);
|
|
145
|
+
if (cross.status !== 200) expect(cross.text).toBe(crossNoFilter.text);
|
|
146
|
+
else expect(JSON.parse(cross.text).sessions).toEqual([]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("unauthenticated read is refused exactly as without the filter", async () => {
|
|
150
|
+
const withFilter = await raw(null, `/api/status?node_id=node_sn_one`);
|
|
151
|
+
const without = await raw(null, `/api/status`);
|
|
152
|
+
expect(withFilter.status).toBe(without.status);
|
|
153
|
+
expect(withFilter.text).toBe(without.text);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("/health advertises status_node_id", async () => {
|
|
157
|
+
const r = await raw(null, `/health`);
|
|
158
|
+
expect(r.status).toBe(200);
|
|
159
|
+
expect(JSON.parse(r.text).capabilities).toContain("status_node_id");
|
|
160
|
+
});
|
|
161
|
+
});
|