@sleep2agi/commhub-server 0.9.0-preview.151 → 0.9.0-preview.153

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.151",
3
+ "version": "0.9.0-preview.153",
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",
package/src/auth.ts CHANGED
@@ -516,6 +516,9 @@ export function deleteNetwork(userId: string, networkId: string): { ok: boolean;
516
516
  // deleted network. Remove memberships with the network.
517
517
  db.run("DELETE FROM network_members WHERE network_id = ?1", [networkId]);
518
518
  deleteNetworkAgentGroups(networkId); deleteDepartmentsForNetwork(networkId); for (const t of AGENT_TEAM_TABLES) if (tableExists(t)) db.run(`DELETE FROM ${t} WHERE network_id = ?1`, [networkId]); // #2144, #419, #764 (one line: docs pin auth.ts line numbers)
519
+ // #993: the rules text belongs to the network; leaving it behind would make a
520
+ // later network that reuses the id inherit somebody else's rules.
521
+ if (tableExists("network_rules")) db.run("DELETE FROM network_rules WHERE network_id = ?1", [networkId]);
519
522
  });
520
523
  return { ok: true };
521
524
  }
@@ -966,6 +969,8 @@ const NETWORK_CLEANUP_TABLES = [
966
969
  "task_events", "task_terminal_events", "task_late_replies", "scheduled_task_runs", "external_schedule_edits",
967
970
  // 标签颜色:网络本身的元数据(标签本体在 requirements 行里,那张表是阻断项)。
968
971
  "network_tags",
972
+ // #993/#1542 全团队规则:规则文本是网络元数据,网络删了它不该留成孤儿行。
973
+ "network_rules",
969
974
  // 组织架构(board #419):部门是网络本身的元数据。
970
975
  "network_departments",
971
976
  // Agent 团队(#764):Agent 自己的组织树,同样是网络元数据。
@@ -102,11 +102,63 @@ describe("#993 — network rules storage + CAS write", () => {
102
102
  });
103
103
 
104
104
  test("oversized content is refused (bytes, not UTF-16 units)", async () => {
105
- const r = await send(adminToken, "PUT", `/api/networks/${netId}/rules`, { content: "a".repeat(200 * 1024 + 1), expected_revision: 1 });
105
+ const r = await send(adminToken, "PUT", `/api/networks/${netId}/rules`, { content: "a".repeat(256 * 1024 + 1), expected_revision: 1 });
106
106
  expect(r.status).toBe(413);
107
107
  expect(r.body.error).toBe("rules_too_large");
108
108
  });
109
109
 
110
+ test("re-writing identical bytes is idempotent (revision does not move, changed=false)", async () => {
111
+ // The next slice injects a managed block keyed by revision; a no-op save that
112
+ // bumped the number would make every node rewrite its file for nothing.
113
+ const before = (await send(adminToken, "GET", `/api/networks/${netId}/rules`)).body.rules;
114
+ const w = await send(adminToken, "PUT", `/api/networks/${netId}/rules`, {
115
+ content: before.content, expected_revision: before.revision,
116
+ });
117
+ expect(w.status).toBe(200);
118
+ expect(w.body.changed).toBe(false);
119
+ expect(w.body.rules.revision).toBe(before.revision);
120
+ // …and a real edit still moves it.
121
+ const w2 = await send(adminToken, "PUT", `/api/networks/${netId}/rules`, {
122
+ content: before.content + "\n- one more line\n", expected_revision: before.revision,
123
+ });
124
+ expect(w2.status).toBe(200);
125
+ expect(w2.body.changed).toBe(true);
126
+ expect(w2.body.rules.revision).toBe(before.revision + 1);
127
+ });
128
+
129
+ test("the 256 KiB cap is the same ruler as write_node_rules_file", async () => {
130
+ // 200 KiB used to be the cap here while node rules allowed 256 KiB — the same text
131
+ // could fit in the Hub and then be refused at the node.
132
+ const atCap = "a".repeat(256 * 1024);
133
+ const rev = (await send(adminToken, "GET", `/api/networks/${netId}/rules`)).body.rules.revision;
134
+ const ok = await send(adminToken, "PUT", `/api/networks/${netId}/rules`, { content: atCap, expected_revision: rev });
135
+ expect(ok.status).toBe(200);
136
+ const rev2 = ok.body.rules.revision;
137
+ const over = await send(adminToken, "PUT", `/api/networks/${netId}/rules`, { content: atCap + "a", expected_revision: rev2 });
138
+ expect(over.status).toBe(413);
139
+ });
140
+
141
+ test("deleting the network deletes its rules row (no orphans)", async () => {
142
+ const own = register(`nr_del_${Date.now()}`, "DelPw123Aa!", undefined, "del");
143
+ expect(own.ok).toBe(true);
144
+ const put = await send(own.token!, "PUT", `/api/networks/${own.network_id}/rules`, { content: "team rules", expected_revision: 0 });
145
+ expect(put.status).toBe(200);
146
+ const del = await send(own.token!, "DELETE", `/api/networks/${own.network_id}`);
147
+ expect(del.status).toBe(200);
148
+ const left = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM network_rules WHERE network_id = ?1", own.network_id!);
149
+ expect(left!.cnt).toBe(0);
150
+ });
151
+
152
+ test("a node token cannot read another network's rules", async () => {
153
+ // The node token belongs to a hub admin (that is how it was minted), so the
154
+ // hubAdmin branch alone would have allowed it. A node stays inside its network.
155
+ const other = register(`nr_net2_${Date.now()}`, "OtherPw123Aa!", undefined, "net2");
156
+ expect(other.ok).toBe(true);
157
+ const r = await send(nodeToken, "GET", `/api/networks/${other.network_id}/rules`);
158
+ expect(r.status).toBe(403);
159
+ expect(r.body.error).toBe("not_a_member");
160
+ });
161
+
110
162
  test("a non-member cannot read", async () => {
111
163
  const other = register(`nr_other_${Date.now()}`, "OtherPw123Aa!", undefined, "other");
112
164
  expect(other.ok).toBe(true);
@@ -16,8 +16,12 @@
16
16
  // content is a file the node will write to disk.
17
17
  import type { DbAdapter } from "./db-adapter.js";
18
18
 
19
- /** 200 KiB. A team rule file is small; anything near this is a mistake. */
20
- export const NETWORK_RULES_MAX_BYTES = 200 * 1024;
19
+ /**
20
+ * 256 KiB — deliberately the same ruler as `write_node_rules_file` (tools.ts), because
21
+ * these two texts are meant to end up next to each other on a node. Two different caps
22
+ * for the same kind of document is how "it fit in the Hub but not on the node" happens.
23
+ */
24
+ export const NETWORK_RULES_MAX_BYTES = 256 * 1024;
21
25
 
22
26
  export type NetworkRules = {
23
27
  network_id: string;
@@ -76,7 +80,7 @@ export function readNetworkRules(database: DbAdapter, networkId: string): Networ
76
80
  }
77
81
 
78
82
  export type WriteResult =
79
- | { ok: true; rules: NetworkRules }
83
+ | { ok: true; rules: NetworkRules; changed: boolean }
80
84
  | { ok: false; status: number; error: "invalid_rules_content" | "rules_too_large" | "rules_revision_conflict" };
81
85
 
82
86
  /**
@@ -102,6 +106,12 @@ export function writeNetworkRules(
102
106
  const row = current(database, networkId);
103
107
  const revision = row ? Number(row.revision) || 0 : 0;
104
108
  if (revision !== expectedRevision) return { ok: false, status: 409, error: "rules_revision_conflict" };
109
+ // Idempotent revision: writing the same bytes must not mint a new revision.
110
+ // The next slice injects a managed block keyed by revision, so a no-op save that
111
+ // bumped the number would make every node rewrite its file for nothing.
112
+ if (row && row.content === content) {
113
+ return { ok: true, rules: readNetworkRules(database, networkId), changed: false };
114
+ }
105
115
  const next = revision + 1;
106
116
  const actorJson = JSON.stringify({ user_id: actor.user_id, username: actor.username });
107
117
  if (row) {
@@ -115,5 +125,5 @@ export function writeNetworkRules(
115
125
  [networkId, content, next, now, actorJson],
116
126
  );
117
127
  }
118
- return { ok: true, rules: readNetworkRules(database, networkId) };
128
+ return { ok: true, rules: readNetworkRules(database, networkId), changed: true };
119
129
  }
package/src/server.ts CHANGED
@@ -1903,6 +1903,12 @@ return Bun.serve({
1903
1903
  const role = getUserNetworkRole(resolved.user.user_id, netId);
1904
1904
  ensureNetworkRulesOnce(db);
1905
1905
  if (req.method === "GET") {
1906
+ // A node token resolves to the human who created it, which may be a hub
1907
+ // admin — that must not turn a node into a reader of *every* network's
1908
+ // rules. A node token is confined to its own network.
1909
+ if (resolved.networkId && resolved.networkId !== netId) {
1910
+ return withCors(req, Response.json({ ok: false, error: "not_a_member" }, { status: 403 }));
1911
+ }
1906
1912
  // Reading is for anyone in the network: the rules are what the nodes obey.
1907
1913
  if (!hubAdmin && !role) return withCors(req, Response.json({ ok: false, error: "not_a_member" }, { status: 403 }));
1908
1914
  return withCors(req, Response.json({ ok: true, rules: readNetworkRules(db, netId) }));
@@ -1923,9 +1929,13 @@ return Bun.serve({
1923
1929
  username: resolved.user.username ?? null,
1924
1930
  });
1925
1931
  if (!result.ok) return withCors(req, Response.json({ ok: false, error: result.error }, { status: result.status }));
1926
- logAudit(resolved.user.user_id, resolved.user.username ?? null, "network_rules_updated", "network_rules", netId,
1927
- JSON.stringify({ revision: result.rules.revision, bytes: Buffer.byteLength(result.rules.content, "utf8") }), undefined, netId);
1928
- return withCors(req, Response.json({ ok: true, rules: result.rules }));
1932
+ // An idempotent save (same bytes) changes nothing, so it must not invent an
1933
+ // audit entry that says the rules were updated.
1934
+ if (result.changed) {
1935
+ logAudit(resolved.user.user_id, resolved.user.username ?? null, "network_rules_updated", "network_rules", netId,
1936
+ JSON.stringify({ revision: result.rules.revision, bytes: Buffer.byteLength(result.rules.content, "utf8") }), undefined, netId);
1937
+ }
1938
+ return withCors(req, Response.json({ ok: true, changed: result.changed, rules: result.rules }));
1929
1939
  }
1930
1940
  return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
1931
1941
  }