@sleep2agi/commhub-server 0.9.0-preview.152 → 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 +1 -1
- package/src/auth.ts +5 -0
- package/src/network-rules-http.test.ts +43 -1
- package/src/network-rules.ts +14 -4
- package/src/server.ts +7 -3
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.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,53 @@ 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(
|
|
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
|
+
|
|
110
152
|
test("a node token cannot read another network's rules", async () => {
|
|
111
153
|
// The node token belongs to a hub admin (that is how it was minted), so the
|
|
112
154
|
// hubAdmin branch alone would have allowed it. A node stays inside its network.
|
package/src/network-rules.ts
CHANGED
|
@@ -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
|
-
/**
|
|
20
|
-
|
|
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
|
@@ -1929,9 +1929,13 @@ return Bun.serve({
|
|
|
1929
1929
|
username: resolved.user.username ?? null,
|
|
1930
1930
|
});
|
|
1931
1931
|
if (!result.ok) return withCors(req, Response.json({ ok: false, error: result.error }, { status: result.status }));
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
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 }));
|
|
1935
1939
|
}
|
|
1936
1940
|
return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
|
|
1937
1941
|
}
|