@sleep2agi/commhub-server 0.9.0-preview.136 → 0.9.0-preview.138

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.136",
3
+ "version": "0.9.0-preview.138",
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",
@@ -237,7 +237,8 @@ export type NodeCallerResolution =
237
237
  | { ok: true; kind: "unregistered"; alias: string; networkId: string }
238
238
  | { ok: false; reason: "not_a_node_token" | "bound_node_missing" | "ambiguous" | "not_owner" };
239
239
 
240
- type NodeCallerRow = { node_id: string; alias: string; network_id: string; owner_user_id: string | null };
240
+ export type NodeCallerRow = { node_id: string; alias: string; network_id: string; owner_user_id: string | null };
241
+ export type NodeCallerToken = { name: string; network_id: string | null; user_id: string | null; bound_node_id: string | null; node_identity_epoch: number };
241
242
 
242
243
  // nodes has no unique (network, alias). An unbound token that hits several
243
244
  // rows is not rejected outright: the row owned by the token's issuer wins;
@@ -246,18 +247,12 @@ type NodeCallerRow = { node_id: string; alias: string; network_id: string; owner
246
247
  function disambiguateUnboundAlias(
247
248
  rows: NodeCallerRow[],
248
249
  userId: string | null,
249
- alias: string,
250
- networkId: string,
250
+ recentNodeId: string | null,
251
251
  ): NodeCallerRow | undefined {
252
252
  const owned = userId ? rows.filter((row) => row.owner_user_id === userId) : [];
253
253
  if (owned.length === 1) return owned[0]; // board679-unbound-dup
254
254
  const pool = owned.length > 1 ? owned : rows;
255
- const recent = db.get<{ node_id: string | null }>(
256
- `SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2 AND node_id IS NOT NULL ORDER BY updated_at DESC LIMIT 1`,
257
- alias,
258
- networkId,
259
- );
260
- const hit = recent?.node_id ? pool.filter((row) => row.node_id === recent.node_id) : [];
255
+ const hit = recentNodeId ? pool.filter((row) => row.node_id === recentNodeId) : [];
261
256
  return hit.length === 1 ? hit[0] : undefined;
262
257
  }
263
258
 
@@ -268,7 +263,7 @@ function disambiguateUnboundAlias(
268
263
  // do not get a second exception.
269
264
  export function resolveNodeCaller(tokenId: string | null | undefined): NodeCallerResolution {
270
265
  if (!tokenId) return { ok: false, reason: "not_a_node_token" };
271
- const tokRow = db.get<{ name: string; network_id: string | null; user_id: string | null; bound_node_id: string | null; node_identity_epoch: number }>(
266
+ const tokRow = db.get<NodeCallerToken>(
272
267
  `SELECT name, network_id, user_id, bound_node_id, node_identity_epoch FROM api_tokens WHERE token_id = ?1 AND revoked_at IS NULL`,
273
268
  tokenId,
274
269
  );
@@ -280,10 +275,26 @@ export function resolveNodeCaller(tokenId: string | null | undefined): NodeCalle
280
275
  const rows = tokRow.bound_node_id
281
276
  ? db.all<DaemonRow>(`SELECT node_id, alias, network_id, owner_user_id FROM nodes WHERE node_id = ?1 AND network_id = ?2`, tokRow.bound_node_id, tokRow.network_id)
282
277
  : db.all<DaemonRow>(`SELECT node_id, alias, network_id, owner_user_id FROM nodes WHERE alias = ?1 AND network_id = ?2`, tokenAlias, tokRow.network_id);
278
+ const recent = !tokRow.bound_node_id && rows.length > 1
279
+ ? db.get<{ node_id: string | null }>(
280
+ `SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2 AND node_id IS NOT NULL ORDER BY updated_at DESC LIMIT 1`,
281
+ tokenAlias, tokRow.network_id,
282
+ ) : null;
283
+ return resolveNodeCallerFromRows(tokRow, rows, recent?.node_id ?? null);
284
+ }
285
+
286
+ /** Shared identity contract for individual callers and scoped, batched list reads. */
287
+ export function resolveNodeCallerFromRows(
288
+ tokRow: NodeCallerToken, rows: NodeCallerRow[], recentNodeId: string | null,
289
+ ): NodeCallerResolution {
290
+ if (!tokRow.name?.startsWith("node:") || !tokRow.network_id) {
291
+ return { ok: false, reason: "not_a_node_token" };
292
+ }
293
+ const tokenAlias = tokRow.name.slice(5);
283
294
  const nodeRow = rows.length === 1
284
295
  ? rows[0]
285
296
  : (!tokRow.bound_node_id && rows.length > 1
286
- ? disambiguateUnboundAlias(rows, tokRow.user_id, tokenAlias, tokRow.network_id)
297
+ ? disambiguateUnboundAlias(rows, tokRow.user_id, recentNodeId)
287
298
  : undefined);
288
299
  // board679-bound-row-missing: a bound token with no row is refused. Do not look up by name.
289
300
  if (tokRow.bound_node_id && !nodeRow) return { ok: false, reason: "bound_node_missing" };
@@ -0,0 +1,136 @@
1
+ import { afterAll, beforeAll, beforeEach, expect, test } from "bun:test";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { db } from "./db.js";
4
+ import { register } from "./auth.js";
5
+ import { registerTools } from "./tools.js";
6
+ import { hostSupervisorIdentityRows } from "./host-supervisor-identity.js";
7
+
8
+ if (!process.env.COMMHUB_DB && !process.env.COMMHUB_TEST_PG_URL) throw Error("explicit fixture DB required");
9
+ let net: string, foreign: string, user: string, token: string, hub: any, base: string;
10
+ let sequence = 0;
11
+ const next = () => `n_lhs1109_${++sequence}`;
12
+ beforeAll(async () => {
13
+ const auth = register(`lhs1109_${Date.now()}`, "FixturePassword1109!", undefined, "fixture");
14
+ if (!auth.ok || !auth.token || !auth.user || !auth.network_id) throw Error("fixture registration failed");
15
+ user = auth.user.user_id; token = auth.token; net = auth.network_id;
16
+ foreign = `net_foreign_lhs1109_${Date.now()}`;
17
+ db.run("INSERT INTO networks(network_id,network_name,owner_id) VALUES(?1,'foreign',?2)", [foreign, user]);
18
+ process.env.HOST = "127.0.0.1";
19
+ const { bootServer } = await import("./server.js");
20
+ hub = await bootServer({ port: 0 }); base = `http://127.0.0.1:${hub.port}`;
21
+ }, 30_000);
22
+ beforeEach(() => {
23
+ db.run("DELETE FROM api_tokens WHERE name LIKE 'node:lhs1109_%'");
24
+ db.run("DELETE FROM sessions WHERE alias LIKE 'lhs1109_%'");
25
+ db.run("DELETE FROM nodes WHERE alias LIKE 'lhs1109_%'");
26
+ });
27
+ afterAll(() => hub?.stop(true));
28
+
29
+ function node(alias: string, network = net, owner: string | null = user) {
30
+ const id = next();
31
+ db.run(`INSERT INTO nodes(node_id,node_name,alias,network_id,owner_user_id,config_snapshot)
32
+ VALUES(?1,?2,?2,?3,?4,'{"role":"host_supervisor"}')`, [id, `lhs1109_${alias}`, network, owner]);
33
+ return id;
34
+ }
35
+ function credential(id: string, opts: { bound?: string | null; name?: string; issuer?: string; epoch?: number; network?: string; scope?: string; expired?: boolean; revoked?: boolean } = {}) {
36
+ const n = db.get<any>("SELECT alias, network_id FROM nodes WHERE node_id=?1", id)!;
37
+ const tid = `tok_lhs1109_${++sequence}`;
38
+ db.run(`INSERT INTO api_tokens(token_id,token_hash,user_id,network_id,scope,name,bound_node_id,node_identity_epoch,expires_at,revoked_at)
39
+ VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)`, [tid, `hash_${tid}`, opts.issuer ?? user,
40
+ opts.network ?? n.network_id, opts.scope ?? "network", `node:${opts.name ?? n.alias}`,
41
+ opts.bound === undefined ? id : opts.bound, opts.epoch ?? 2,
42
+ opts.expired ? "2000-01-01 00:00:00" : null, opts.revoked ? "2000-01-01 00:00:00" : null]);
43
+ return tid;
44
+ }
45
+ function heartbeat(id: string, opts: { bound?: string | null; network?: string | null; alias?: string; age?: number } = {}) {
46
+ const n = db.get<any>("SELECT alias,network_id FROM nodes WHERE node_id=?1", id)!;
47
+ db.run(`INSERT INTO sessions(resume_id,alias,network_id,node_id,last_seen_at,status,cpu_cores,mem_total_gb,ip)
48
+ VALUES(?1,?2,?3,?4,?5,'idle',8,16,'10.1.1.1')`, [`s_lhs1109_${++sequence}`, opts.alias ?? n.alias,
49
+ opts.network === undefined ? n.network_id : opts.network, opts.bound === undefined ? id : opts.bound,
50
+ new Date(Date.now() - (opts.age ?? 0)).toISOString()]);
51
+ }
52
+ async function list(entry: "MCP" | "REST") {
53
+ if (entry === "REST") {
54
+ const r = await fetch(`${base}/api/host-supervisors?network_id=${net}`, { headers: { Authorization: `Bearer ${token}` } });
55
+ expect(r.status).toBe(200); return (await r.json() as any).daemons as any[];
56
+ }
57
+ const server = new McpServer({ name: "fixture", version: "0" }) as any;
58
+ let handler: any;
59
+ const original = server.tool.bind(server);
60
+ server.tool = (name: string, desc: string, schema: any, fn: any) => {
61
+ if (name === "list_host_supervisors") handler = fn;
62
+ return original(name, desc, schema, fn);
63
+ };
64
+ registerTools(server, undefined, null, user, null, false, null);
65
+ const result = await handler({ network_id: net });
66
+ const parsed = JSON.parse(result.content[0].text); expect(parsed.ok).toBe(true); return parsed.daemons as any[];
67
+ }
68
+ for (const entry of ["MCP", "REST"] as const) {
69
+ test(`${entry}: bound credentials survive stale token names and rotations dedupe`, async () => {
70
+ const id = node("renamed"); credential(id, { name: "lhs1109_oldname" }); credential(id); heartbeat(id);
71
+ const rows = await list(entry); expect(rows.length).toBe(1);
72
+ expect(rows[0]).toMatchObject({ daemon_node_id: id, online: true });
73
+ expect(JSON.stringify(rows)).not.toContain("hash_");
74
+ });
75
+ test(`${entry}: same-name foreign binding and missing bound node cannot authenticate daemon`, async () => {
76
+ const id = node("victim"), other = node("victim", foreign);
77
+ credential(id, { bound: other }); credential(id, { bound: "n_lhs1109_missing" }); heartbeat(id);
78
+ expect(await list(entry)).toEqual([]);
79
+ });
80
+ test(`${entry}: legacy owner and epoch-zero ownerless keep compatibility; squatter and new ownerless rejected`, async () => {
81
+ const good = node("owned"), old = node("old", net, null), squatter = node("squatter"), recent = node("recent", net, null);
82
+ credential(good, { bound: null }); credential(old, { bound: null, epoch: 0 });
83
+ credential(squatter, { bound: null, issuer: "u_foreign_fixture" }); credential(recent, { bound: null, epoch: 1 });
84
+ const ids = (await list(entry)).map(r => r.daemon_node_id).sort(); expect(ids).toEqual([good, old].sort());
85
+ });
86
+ test(`${entry}: same-alias sibling heartbeat cannot make stopped daemon online`, async () => {
87
+ const stopped = node("duplicate"), live = node("duplicate"); credential(stopped); credential(live); heartbeat(live);
88
+ const rows = await list(entry);
89
+ expect(rows.find(r => r.daemon_node_id === stopped)).toMatchObject({ online: false, last_seen_at: null });
90
+ expect(rows.find(r => r.daemon_node_id === live)).toMatchObject({ online: true });
91
+ });
92
+ test(`${entry}: null-ID heartbeat requires unique alias in its network`, async () => {
93
+ const unique = node("unique"), a = node("ambiguous"), b = node("ambiguous");
94
+ for (const id of [unique,a,b]) credential(id);
95
+ heartbeat(unique, { bound: null }); heartbeat(a, { bound: null });
96
+ const rows = await list(entry);
97
+ expect(rows.find(r => r.daemon_node_id === unique).online).toBe(true);
98
+ expect(rows.filter(r => r.daemon_node_id !== unique).map(r => r.online)).toEqual([false,false]);
99
+ });
100
+ test(`${entry}: null-network legacy heartbeat is accepted only for globally unique aliases`, async () => {
101
+ const unique = node("globalunique"), shared = node("globalshared"); node("globalshared", foreign);
102
+ credential(unique); credential(shared);
103
+ heartbeat(unique, { bound: null, network: null }); heartbeat(shared, { bound: null, network: null });
104
+ const rows = await list(entry);
105
+ expect(rows.find(r => r.daemon_node_id === unique).online).toBe(true);
106
+ expect(rows.find(r => r.daemon_node_id === shared).online).toBe(false);
107
+ });
108
+ test(`${entry}: bound heartbeat ignores stale alias but refuses wrong network and stale timestamps`, async () => {
109
+ const renamed = node("newalias"), wrong = node("wrongnet"), stale = node("stale");
110
+ for (const id of [renamed,wrong,stale]) credential(id);
111
+ heartbeat(renamed, { alias: "lhs1109_stalealias" }); heartbeat(wrong, { network: foreign }); heartbeat(stale, { age: 360_000 });
112
+ const rows = await list(entry);
113
+ expect(rows.find(r => r.daemon_node_id === renamed).online).toBe(true);
114
+ expect(rows.filter(r => r.daemon_node_id !== renamed).map(r => r.online)).toEqual([false,false]);
115
+ });
116
+ test(`${entry}: unbound duplicate-owner disambiguation uses authoritative recent session`, async () => {
117
+ const a = node("unboundduplicate"), b = node("unboundduplicate"); credential(a, { bound: null }); heartbeat(b);
118
+ const rows = await list(entry); expect(rows.length).toBe(1); expect(rows[0]).toMatchObject({ daemon_node_id: b, online: true });
119
+ });
120
+ test(`${entry}: deleted, revoked, expired and wrong-scope credentials excluded`, async () => {
121
+ for (const [label,opts] of [["expired",{ expired:true }],["revoked",{ revoked:true }],["scope",{ scope:"full" }],["deleted",{}]] as const) {
122
+ const id = node(label), tid = credential(id, opts); heartbeat(id);
123
+ if (label === "deleted") db.run("DELETE FROM api_tokens WHERE token_id=?1", [tid]);
124
+ }
125
+ expect(await list(entry)).toEqual([]);
126
+ });
127
+ }
128
+ test("batched list performs three reads for many daemons and multiple scoped networks", () => {
129
+ for (let i=0;i<30;i++) { const id=node(`fleet${i}`, i%2 ? net : foreign); credential(id); heartbeat(id); }
130
+ const original = db.all.bind(db); let reads=0;
131
+ db.all = ((sql: string, ...params: any[]) => { reads++; return original(sql, ...params); }) as any;
132
+ try {
133
+ const rows = hostSupervisorIdentityRows("SELECT * FROM nodes WHERE network_id=?1 OR network_id=?2", [net,foreign]);
134
+ expect(rows.length).toBe(30); expect(reads).toBe(3);
135
+ } finally { db.all = original; }
136
+ });
@@ -0,0 +1,92 @@
1
+ import { db } from "./db.js";
2
+ import { resolveNodeCallerFromRows, type NodeCallerRow, type NodeCallerToken } from "./create-node.js";
3
+ import { parseDbTimestampMs } from "./db-timestamp.js";
4
+
5
+ type NodeRow = NodeCallerRow & Record<string, any>;
6
+ type SessionRow = {
7
+ node_id: string | null; alias: string; network_id: string | null;
8
+ last_seen_at: string | null; status: string | null;
9
+ cpu_cores: number | null; mem_total_gb: number | null; ip: string | null;
10
+ legacy_alias_count: number;
11
+ };
12
+ const key = (network: string, alias: string) => JSON.stringify([network, alias]);
13
+ const push = <T>(map: Map<string, T[]>, id: string, value: T) => {
14
+ const list = map.get(id) ?? []; list.push(value); map.set(id, list);
15
+ };
16
+
17
+ /** The caller supplies a scoped nodes query, never an unchecked network id.
18
+ * Three SQL reads regardless of fleet size; no token secrets are selected.
19
+ * All node roles participate in legacy alias disambiguation.
20
+ */
21
+ export function hostSupervisorIdentityRows(scopedNodesSql: string, params: any[]): NodeRow[] {
22
+ const nodes = db.all<NodeRow>(scopedNodesSql, ...params);
23
+ if (!nodes.length) return [];
24
+ const tokens = db.all<NodeCallerToken>(`
25
+ SELECT t.name, t.network_id, t.user_id, t.bound_node_id, t.node_identity_epoch
26
+ FROM api_tokens t
27
+ WHERE t.network_id IN (SELECT scoped.network_id FROM (${scopedNodesSql}) scoped)
28
+ AND t.scope='network' AND t.revoked_at IS NULL
29
+ AND (t.expires_at IS NULL OR t.expires_at > datetime('now'))
30
+ AND t.name LIKE 'node:%'
31
+ `, ...params);
32
+ const sessions = db.all<SessionRow>(`
33
+ SELECT s.node_id, s.alias, s.network_id, s.last_seen_at, s.status,
34
+ s.cpu_cores, s.mem_total_gb, s.ip,
35
+ CASE WHEN s.network_id IS NULL AND s.node_id IS NULL THEN
36
+ (SELECT COUNT(*) FROM nodes n WHERE n.alias=s.alias)
37
+ ELSE 0 END AS legacy_alias_count
38
+ FROM sessions s
39
+ WHERE s.network_id IN (SELECT scoped.network_id FROM (${scopedNodesSql}) scoped)
40
+ OR (s.network_id IS NULL AND (
41
+ s.alias IN (SELECT scoped.alias FROM (${scopedNodesSql}) scoped)
42
+ OR s.node_id IN (SELECT scoped.node_id FROM (${scopedNodesSql}) scoped)))
43
+ ORDER BY s.updated_at DESC
44
+ `, ...params);
45
+ const byId = new Map(nodes.map(n => [n.node_id, n]));
46
+ const byAlias = new Map<string, NodeRow[]>();
47
+ const byGlobalAlias = new Map<string, NodeRow[]>();
48
+ for (const n of nodes) {
49
+ push(byAlias, key(n.network_id, n.alias), n);
50
+ push(byGlobalAlias, n.alias, n);
51
+ }
52
+ const recent = new Map<string, string>();
53
+ for (const s of sessions) {
54
+ if (!s.network_id || !s.node_id) continue;
55
+ const id = key(s.network_id, s.alias);
56
+ if (!recent.has(id)) recent.set(id, s.node_id);
57
+ }
58
+ const credentialed = new Set<string>();
59
+ for (const t of tokens) {
60
+ if (!t.network_id) continue;
61
+ const bound = t.bound_node_id ? byId.get(t.bound_node_id) : null;
62
+ const rows = t.bound_node_id
63
+ ? (bound?.network_id === t.network_id ? [bound] : [])
64
+ : byAlias.get(key(t.network_id, t.name.slice(5))) ?? [];
65
+ const caller = resolveNodeCallerFromRows(t, rows, recent.get(key(t.network_id, t.name.slice(5))) ?? null);
66
+ if (caller.ok && caller.kind === "node") credentialed.add(caller.nodeId);
67
+ }
68
+ const heartbeat = new Map<string, SessionRow>();
69
+ for (const s of sessions) {
70
+ let node: NodeRow | undefined;
71
+ if (s.node_id) {
72
+ node = byId.get(s.node_id);
73
+ if (s.network_id !== null && node?.network_id !== s.network_id) continue;
74
+ } else if (s.network_id) {
75
+ const matches = byAlias.get(key(s.network_id, s.alias)) ?? [];
76
+ if (matches.length === 1) node = matches[0];
77
+ } else if (Number(s.legacy_alias_count) === 1) {
78
+ node = byGlobalAlias.get(s.alias)?.[0];
79
+ }
80
+ if (!node || !s.last_seen_at || !Number.isFinite(parseDbTimestampMs(s.last_seen_at))) continue;
81
+ const previous = heartbeat.get(node.node_id);
82
+ if (!previous || parseDbTimestampMs(s.last_seen_at) > parseDbTimestampMs(previous.last_seen_at!)) {
83
+ heartbeat.set(node.node_id, s);
84
+ }
85
+ }
86
+ return nodes.filter(n => credentialed.has(n.node_id)).map(n => {
87
+ const s = heartbeat.get(n.node_id);
88
+ return { ...n, session_last_seen: s?.last_seen_at ?? null, session_status: s?.status ?? null,
89
+ session_cpu_cores: s?.cpu_cores ?? null, session_mem_total_gb: s?.mem_total_gb ?? null,
90
+ session_ip: s?.ip ?? null };
91
+ });
92
+ }
@@ -40,6 +40,7 @@ function cleanup() {
40
40
  try { db.run("DELETE FROM users WHERE user_id LIKE 'u_lhs_%'"); } catch {}
41
41
  }
42
42
 
43
+ // Each case starts empty; plain INSERT keeps these fixtures portable to PostgreSQL.
43
44
  beforeEach(cleanup);
44
45
  afterAll(cleanup);
45
46
 
@@ -52,7 +53,7 @@ function seedUser(user_id: string) {
52
53
  }
53
54
  function seedNetwork(net_id: string, owner_id = "u_lhs_owner") {
54
55
  db.run(
55
- `INSERT OR REPLACE INTO networks (network_id, network_name, owner_id, created_at)
56
+ `INSERT INTO networks (network_id, network_name, owner_id, created_at)
56
57
  VALUES (?1, ?2, ?3, datetime('now'))`,
57
58
  [net_id, net_id, owner_id],
58
59
  );
@@ -81,7 +82,7 @@ function seedDaemon(opts: {
81
82
  allowed_secret_keys: opts.secrets ?? [],
82
83
  });
83
84
  db.run(
84
- `INSERT OR REPLACE INTO nodes (
85
+ `INSERT INTO nodes (
85
86
  node_id, node_name, alias, network_id,
86
87
  runtimes_supported, allowed_secret_keys,
87
88
  config_snapshot, hostname,
@@ -95,14 +96,14 @@ function seedDaemon(opts: {
95
96
  if (opts.sessionAgeMs !== undefined) {
96
97
  const ts = new Date(Date.now() - opts.sessionAgeMs).toISOString().replace("T", " ").replace(/\..+/, "");
97
98
  db.run(
98
- `INSERT OR REPLACE INTO sessions (resume_id, alias, network_id, last_seen_at, status, cpu_cores, mem_total_gb, ip)
99
+ `INSERT INTO sessions (resume_id, alias, network_id, last_seen_at, status, cpu_cores, mem_total_gb, ip)
99
100
  VALUES (?1, ?2, ?3, ?4, 'idle', 8, 16, '10.0.0.5')`,
100
101
  [`s_${node_id}`, opts.alias, opts.network_id, ts],
101
102
  );
102
103
  }
103
104
  if (opts.mintToken !== false && !opts.tokenDeleted) {
104
105
  db.run(
105
- `INSERT OR REPLACE INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
106
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
106
107
  VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, ?6)`,
107
108
  [`tok_${node_id}`, `u_seed_${node_id}`, opts.network_id, `node:${opts.alias}`,
108
109
  `hash_${node_id}`,
@@ -286,3 +286,72 @@ describe("GET /api/requirements/stats", () => {
286
286
  expect(r.body.requirement).toBeUndefined();
287
287
  });
288
288
  });
289
+
290
+ describe("tag_status current snapshot #1091", () => {
291
+ let project = "";
292
+ test("current unarchived four states, distinct memberships and separate null bucket", async () => {
293
+ const p = await send(admin.token, "POST", "/api/requirements/projects", { network_id: NET, name: "tag-status" });
294
+ expect(p.status).toBe(201); project = p.body.project.id;
295
+ const seed = (name: string, extra: Record<string, unknown>) => card(admin.token, name, { project_id: project, ...extra });
296
+ await seed("tag-pool", { tags: ["a", "b", "a"] });
297
+ await seed("tag-done", { tags: ["a", "b"], column: "done" });
298
+ await seed("tag-doing", { tags: ["b"], column: "doing" });
299
+ await seed("tag-abandoned", { column: "abandoned" });
300
+ await seed("tag-literal", { tags: ["无标签"] });
301
+ const invalid = await seed("tag-invalid-legacy", { column: "done" });
302
+ db.run("UPDATE requirements SET tags_json='invalid legacy json' WHERE requirement_id=?1", [invalid.id]);
303
+ await seed("tag-visible", { tags: ["visible"], column: "doing", owner: { kind: "user", id: alice.id } });
304
+ await seed("tag-secret", { tags: ["secret"], column: "done" });
305
+ const archived = await seed("tag-archived", { tags: ["archived-only"], column: "doing" });
306
+ expect((await send(admin.token, "PATCH", `/api/requirements/${archived.id}?network_id=${NET}`, { archived: true })).status).toBe(200);
307
+ const r = await stats(admin.token, `&project_id=${project}`);
308
+ expect(r.status).toBe(200);
309
+ expect(r.body.tag_status).toMatchObject({ basis: "current", complete: true, includes_archived: false, project_filter: project, total: 8 });
310
+ const buckets = new Map(r.body.tag_status.by_tag.map((x: any) => [x.tag, x]));
311
+ expect(buckets.get("a")).toEqual({ tag: "a", pool: 1, doing: 0, done: 1, abandoned: 0, total: 2 });
312
+ expect(buckets.get("b")).toEqual({ tag: "b", pool: 1, doing: 1, done: 1, abandoned: 0, total: 3 });
313
+ expect(buckets.get(null)).toEqual({ tag: null, pool: 0, doing: 0, done: 1, abandoned: 1, total: 2 });
314
+ expect(buckets.get("无标签")).toMatchObject({ pool: 1, total: 1 });
315
+ expect(buckets.has("archived-only")).toBe(false);
316
+ expect(r.body.tag_status.by_tag[0].tag).toBe("b");
317
+ expect(r.body.tag_status.by_tag.reduce((sum: number, x: any) => sum + x.total, 0)).toBe(10);
318
+ });
319
+ test("snapshot ignores time range; completion totals still follow it", async () => {
320
+ const future = new Date(Date.now() + 86_400_000).toISOString();
321
+ const r = await stats(admin.token, `&project_id=${project}&from=${encodeURIComponent(future)}&to=${encodeURIComponent(new Date(Date.now()+2*86_400_000).toISOString())}`);
322
+ expect(r.body.totals.done).toBe(0); expect(r.body.tag_status.total).toBe(8);
323
+ expect(r.body.tag_status.by_tag.find((x: any) => x.tag === "a").done).toBe(1);
324
+ });
325
+ test("same visibility scope hides inaccessible tags and project results", async () => {
326
+ const r = await stats(alice.token, `&project_id=${project}`);
327
+ expect(r.status).toBe(200); expect(r.body.tag_status.total).toBe(1);
328
+ expect(r.body.tag_status.by_tag).toEqual([{ tag: "visible", pool: 0, doing: 1, done: 0, abandoned: 0, total: 1 }]);
329
+ expect(r.text).not.toContain('"secret"'); expect(r.text).not.toContain('"archived-only"');
330
+ const empty = await stats(alice.token, `&project_id=${PROJ}`);
331
+ expect(empty.body.tag_status.total).toBe(0); expect(empty.body.tag_status.by_tag).toEqual([]);
332
+ });
333
+ test("project none, unknown project and invalid project query", async () => {
334
+ const r = await stats(admin.token, "&project_id=none");
335
+ expect(r.body.tag_status.project_filter).toBe("none");
336
+ expect(r.body.tag_status.by_tag.every((x: any) => x.tag === null)).toBe(true);
337
+ expect((await stats(admin.token, "&project_id=proj_missing")).body.tag_status).toMatchObject({ total: 0, by_tag: [] });
338
+ expect((await stats(admin.token, `&project_id=${"x".repeat(201)}`)).body.error).toBe("invalid_project_id");
339
+ const list = await send(admin.token, "GET", `/api/requirements?network_id=${NET}`);
340
+ expect(list.body.capabilities).toContain("tag_status_snapshot");
341
+ });
342
+ test("all visible rows counted beyond client 500 limit; another network excluded", async () => {
343
+ const p = await send(admin.token, "POST", "/api/requirements/projects", { network_id: NET, name: "large-tag-status" });
344
+ expect(p.status).toBe(201); const large = p.body.project.id;
345
+ const other = `net_tag_foreign_${Date.now()}`;
346
+ db.run("INSERT INTO networks(network_id,network_name,owner_id) VALUES(?1,'foreign',?2)", [other,admin.id]);
347
+ db.transaction(() => {
348
+ for (let i=0;i<502;i++) db.run(`INSERT INTO requirements(requirement_id,network_id,title,column_name,tags_json,project_id,archived,created_at,updated_at)
349
+ VALUES(?1,?2,'bulk','pool','["bulk"]',?3,0,datetime('now'),datetime('now'))`, [`req_tag_bulk_${large}_${i}`,i===501?other:NET,large]);
350
+ });
351
+ const r = await stats(admin.token, `&project_id=${large}&recent=0`);
352
+ expect(r.status).toBe(200); expect(r.body.tag_status.total).toBe(501);
353
+ expect(r.body.tag_status.by_tag).toEqual([{ tag: "bulk", pool: 501, doing: 0, done: 0, abandoned: 0, total: 501 }]);
354
+ const denied = await send(alice.token,"GET",`/api/requirements/stats?network_id=${other}`);
355
+ expect([403,404]).toContain(denied.status);
356
+ });
357
+ });
@@ -2,6 +2,7 @@
2
2
  // 这里只按时间范围 / 时区分桶、按项目 / 完成者计数。放在 JS 里而不是 SQL:按 IANA 时区切「哪一天」在
3
3
  // SQLite 和 PostgreSQL 上写法不同,而一个网络的卡片数是几百到几千,整表轻量列读一次就够。
4
4
  import { isoInstant } from "./requirements-migrate.js";
5
+ import { storedTags } from "./requirement-tags.js";
5
6
 
6
7
  export const STATS_DEFAULT_DAYS = 30;
7
8
  export const STATS_MAX_DAYS = 371; // 53 周:够画一整年的贡献热力图
@@ -21,9 +22,10 @@ export type StatsRow = {
21
22
  completed_at: string | null;
22
23
  completed_at_approx: number | null;
23
24
  completed_by_json: string | null;
25
+ tags_json?: string | null;
24
26
  };
25
27
 
26
- export type StatsQuery = { fromMs: number | null; toMs: number; tz: string; days: number; recent: number };
28
+ export type StatsQuery = { fromMs: number | null; toMs: number; tz: string; days: number; recent: number; projectId?: string | null };
27
29
  type Ref = { kind: "user" | "node"; id: string };
28
30
 
29
31
  /** 解析 from / to / tz / days。不合法 → 错误码(400)。to 缺省 = 现在;from 缺省 = 不设下限(全部)。 */
@@ -48,7 +50,10 @@ export function parseStatsQuery(q: URLSearchParams, nowMs: number): StatsQuery |
48
50
  const recentRaw = q.get("recent");
49
51
  const recent = recentRaw === null || recentRaw === "" ? STATS_DEFAULT_RECENT : Number(recentRaw);
50
52
  if (!Number.isInteger(recent) || recent < 0 || recent > STATS_MAX_RECENT) return { error: "invalid_recent" };
51
- return { fromMs: from, toMs, tz, days, recent };
53
+ const project = q.get("project_id");
54
+ if (project && (project.length > 200 || /[\u0000-\u001f\u007f]/u.test(project))) return { error: "invalid_project_id" };
55
+ return { fromMs: from, toMs, tz, days, recent,
56
+ ...(project ? { projectId: project === "none" ? null : project } : {}) };
52
57
  }
53
58
 
54
59
  function validTimeZone(tz: string): boolean {
@@ -86,6 +91,7 @@ const parseRef = (json: string | null): Ref | null => {
86
91
  * 但归到 unattributed,不出现在 by_completer 里 —— 不给他留「那个节点做了几张」的探测口。
87
92
  */
88
93
  export function aggregateStats(rows: StatsRow[], q: StatsQuery, hidden: ((nodeId: string) => boolean) | null) {
94
+ if (q.projectId !== undefined) rows = rows.filter(row => row.project_id === q.projectId);
89
95
  const inRange = (ms: number | null) => ms !== null && ms <= q.toMs && (q.fromMs === null || ms >= q.fromMs);
90
96
  const days = dayRange(localDate(q.toMs, q.tz), q.days);
91
97
  const firstDay = days[0];
@@ -97,8 +103,21 @@ export function aggregateStats(rows: StatsRow[], q: StatsQuery, hidden: ((nodeId
97
103
  const byProject = new Map<string | null, number>();
98
104
  const byCompleter = new Map<string, { ref: Ref; n: number; spark: Map<string, number> }>();
99
105
  const recent: { row: StatsRow; ms: number; by: Ref | null }[] = [];
106
+ const byTag = new Map<string | null, { tag: string | null; pool: number; doing: number; done: number; abandoned: number; total: number }>();
107
+ let snapshotTotal = 0;
100
108
 
101
109
  for (const row of rows) {
110
+ if (!row.archived && ["pool", "doing", "done", "abandoned"].includes(row.column_name)) {
111
+ snapshotTotal++;
112
+ const tags: (string | null)[] = storedTags(row.tags_json ?? null);
113
+ if (!tags.length) tags.push(null);
114
+ for (const tag of tags) {
115
+ let bucket = byTag.get(tag);
116
+ if (!bucket) byTag.set(tag, bucket = { tag, pool: 0, doing: 0, done: 0, abandoned: 0, total: 0 });
117
+ bucket[row.column_name as "pool" | "doing" | "done" | "abandoned"]++;
118
+ bucket.total++;
119
+ }
120
+ }
102
121
  // 当前还开着的卡(不看时间范围;归档的不算「进行中」)。
103
122
  if (!row.archived && row.column_name === "doing") doing++;
104
123
  if (!row.archived && row.column_name === "pool") pool++;
@@ -133,6 +152,15 @@ export function aggregateStats(rows: StatsRow[], q: StatsQuery, hidden: ((nodeId
133
152
 
134
153
  const spark = days.slice(-STATS_SPARK_DAYS);
135
154
  return {
155
+ tag_status: {
156
+ basis: "current", complete: true, includes_archived: false,
157
+ project_filter: q.projectId === undefined ? null : q.projectId ?? "none",
158
+ total: snapshotTotal,
159
+ // Membership counts: a multi-tag card counts once in each distinct tag.
160
+ by_tag: [...byTag.values()].sort((a, b) =>
161
+ (b.pool + b.doing) - (a.pool + a.doing) || b.total - a.total ||
162
+ String(a.tag ?? "\uffff").localeCompare(String(b.tag ?? "\uffff"))),
163
+ },
136
164
  range: { from: q.fromMs === null ? null : new Date(q.fromMs).toISOString(), to: new Date(q.toMs).toISOString(), tz: q.tz },
137
165
  totals: {
138
166
  done,
@@ -770,7 +770,7 @@ function ifNoneMatchHits(header: string | null, etag: string): boolean {
770
770
  // list_summary:GET 认 view=summary(不带描述正文与子任务条目,见 toSummary);changes:GET 认 changes=1 + updated_since
771
771
  // (改过的卡含归档的,加上 deleted 墓碑与 server_time,见 listChanges)。
772
772
  // events:GET /api/requirements/events —— 字段级的改动流水(谁、何时、旧值 → 新值,requirement-events.ts)。
773
- export const REQUIREMENT_CAPABILITIES = ["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging", "completed_at", "stats", "tag_ops", "list_summary", "changes", "events", "last_event", "column_abandoned"] as const;
773
+ export const REQUIREMENT_CAPABILITIES = ["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging", "completed_at", "stats", "tag_status_snapshot", "tag_ops", "list_summary", "changes", "events", "last_event", "column_abandoned"] as const;
774
774
 
775
775
  // ── 子需求 ──
776
776
  // parent_id:同一网络里的另一张卡;不能成环;最多 5 层(顶层是第 1 层)。删父卡 = 子卡保留、parent_id 置空。
@@ -1584,7 +1584,7 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
1584
1584
  if ("error" in sq) return jsonError(sq.error, 400);
1585
1585
  const statsParams: unknown[] = [networkId];
1586
1586
  const statsSql = addTaskVisibilityScope(
1587
- "SELECT requirement_id, seq, title, project_id, column_name, archived, created_at, completed_at, completed_at_approx, completed_by_json FROM requirements WHERE network_id = ?1",
1587
+ "SELECT requirement_id, seq, title, project_id, column_name, archived, created_at, completed_at, completed_at_approx, completed_by_json, tags_json FROM requirements WHERE network_id = ?1",
1588
1588
  statsParams, taskCaller(ctx),
1589
1589
  );
1590
1590
  const rows = db.all<StatsRow>(statsSql, ...statsParams);
package/src/server.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { hostSupervisorIdentityRows } from "./host-supervisor-identity.js";
1
2
  import { buildServeErrorResponse } from "./serve-error.js";
2
3
  import { sanitizeRuntimeReadiness } from "./runtime-readiness.js";
3
4
  import { markGzipReusable, maybeGzipResponse, trimLightTask } from "./http-gzip";
@@ -4574,31 +4575,10 @@ return Bun.serve({
4574
4575
  isPrivileged = true;
4575
4576
  }
4576
4577
 
4577
- // EXISTS subquery handles BOTH revoked_at SET and DELETEd token
4578
- // rows (revokeToken DELETEs the row in production — PR2 v1
4579
- // SHOULD-FIX per 通信龙 audit). EXISTS naturally dedupes daemons
4580
- // with rotated tokens.
4581
- const sqlRows = db.all<Record<string, any>>(`
4582
- SELECT
4583
- n.node_id, n.alias, n.hostname, n.network_id,
4578
+ const sqlRows = hostSupervisorIdentityRows(`SELECT n.node_id, n.alias, n.hostname, n.network_id, n.owner_user_id,
4584
4579
  n.runtimes_supported, n.allowed_secret_keys,
4585
- n.created_at, n.updated_at,
4586
- s.last_seen_at AS session_last_seen,
4587
- s.cpu_cores AS session_cpu_cores,
4588
- s.mem_total_gb AS session_mem_total_gb,
4589
- s.ip AS session_ip,
4590
- n.config_snapshot
4591
- FROM nodes n
4592
- LEFT JOIN sessions s ON s.alias = n.alias AND (s.network_id = n.network_id OR s.network_id IS NULL)
4593
- WHERE n.network_id = ?1
4594
- AND EXISTS (
4595
- SELECT 1 FROM api_tokens t
4596
- WHERE t.network_id = n.network_id
4597
- AND t.name = 'node:' || n.alias
4598
- AND t.revoked_at IS NULL
4599
- )
4600
- ORDER BY n.updated_at DESC
4601
- `, effectiveNetId);
4580
+ n.created_at, n.updated_at, n.config_snapshot
4581
+ FROM nodes n WHERE n.network_id = ?1 ORDER BY n.updated_at DESC`, [effectiveNetId]);
4602
4582
 
4603
4583
  const nowMs = Date.now();
4604
4584
  // 心跳周期是 3 分钟(agent-node/src/cli.ts 的 report_status 定时器),
package/src/tools.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { hostSupervisorIdentityRows } from "./host-supervisor-identity.js";
1
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
3
  import { markTaskActivity } from "./task-activity.js";
3
4
  import { requestProviderSnapshot, requestProviderWrite, requestProviderCollection, requestProviderApplication, requestProviderLogin, getProviderSnapshot, pullProviderSnapshots, ackProviderSnapshot } from "./daemon-provider-snapshots.js";
@@ -4277,35 +4278,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
4277
4278
  // (already gate-locked by enforceNetworkId path of resolveReadScope).
4278
4279
  const isPrivileged = callerRole === "admin" || callerRole === "owner" || (!enforceUserId);
4279
4280
 
4280
- // Pull candidate daemons. Active-token EXISTS subquery handles BOTH
4281
- // revoked daemon ntoks (revoked_at set) AND DELETEd token rows
4282
- // (the standard revokeToken path deletes the row, not flagging
4283
- // revoked_at — PR2 v1 SHOULD-FIX per 通信龙 audit). EXISTS naturally
4284
- // dedupes if a daemon ever had multiple tokens (e.g. rotation; nit ⚪).
4285
- let sql = `
4286
- SELECT
4287
- n.node_id, n.alias, n.hostname, n.network_id,
4281
+ // Scope first; batch immutable credential + heartbeat identity for both list entry points.
4282
+ let sql = `SELECT n.node_id, n.alias, n.hostname, n.network_id, n.owner_user_id,
4288
4283
  n.runtimes_supported, n.allowed_secret_keys,
4289
- n.created_at, n.updated_at,
4290
- s.last_seen_at AS session_last_seen,
4291
- s.status AS session_status,
4292
- s.cpu_cores AS session_cpu_cores,
4293
- s.mem_total_gb AS session_mem_total_gb,
4294
- s.ip AS session_ip,
4295
- n.config_snapshot
4296
- FROM nodes n
4297
- LEFT JOIN sessions s ON s.alias = n.alias AND (s.network_id = n.network_id OR s.network_id IS NULL)
4298
- WHERE EXISTS (
4299
- SELECT 1 FROM api_tokens t
4300
- WHERE t.network_id = n.network_id
4301
- AND t.name = 'node:' || n.alias
4302
- AND t.revoked_at IS NULL
4303
- )
4304
- `;
4284
+ n.created_at, n.updated_at, n.config_snapshot FROM nodes n WHERE 1=1`;
4305
4285
  const sqlParams: any[] = [];
4306
4286
  sql = addReadScope(sql, sqlParams, readScope, "n.network_id");
4307
4287
  sql += ` ORDER BY n.updated_at DESC`;
4308
- const rows = db.all<Record<string, any>>(sql, ...sqlParams);
4288
+ const rows = hostSupervisorIdentityRows(sql, sqlParams);
4309
4289
 
4310
4290
  // Filter to role=host_supervisor (read from config_snapshot;
4311
4291
  // schema-promoted columns runtimes_supported/allowed_secret_keys