@sleep2agi/commhub-server 0.9.0-preview.150 → 0.9.0-preview.151
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/network-rules-http.test.ts +117 -0
- package/src/network-rules.ts +119 -0
- package/src/server.ts +41 -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.151",
|
|
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,117 @@
|
|
|
1
|
+
// #993 first slice — network-scoped rules: GET / PUT /api/networks/:id/rules
|
|
2
|
+
//
|
|
3
|
+
// The contract this fixes:
|
|
4
|
+
// * any member may READ (the rules are what nodes obey);
|
|
5
|
+
// * only owner/admin may WRITE, and only with a matching `expected_revision`
|
|
6
|
+
// (blind writes would let two people overwrite the document every node inherits);
|
|
7
|
+
// * node tokens cannot write (user_token_required), and non-members cannot read;
|
|
8
|
+
// * reads before any write report revision 0 with empty content, so a client can
|
|
9
|
+
// create with `expected_revision: 0` and no special "create" path;
|
|
10
|
+
// * size is capped in UTF-8 bytes.
|
|
11
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
12
|
+
import "./require-explicit-test-db.js";
|
|
13
|
+
import { register } from "./auth.js";
|
|
14
|
+
import { db } from "./db.js";
|
|
15
|
+
|
|
16
|
+
let BASE = "";
|
|
17
|
+
let server: any = null;
|
|
18
|
+
let adminToken = "";
|
|
19
|
+
let memberToken = "";
|
|
20
|
+
let netId = "";
|
|
21
|
+
let nodeToken = "";
|
|
22
|
+
|
|
23
|
+
async function send(token: string, method: string, path: string, payload?: unknown) {
|
|
24
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
25
|
+
method,
|
|
26
|
+
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), "Content-Type": "application/json" },
|
|
27
|
+
body: payload === undefined ? undefined : JSON.stringify(payload),
|
|
28
|
+
});
|
|
29
|
+
const text = await res.text();
|
|
30
|
+
let body: any = null;
|
|
31
|
+
try { body = JSON.parse(text); } catch {}
|
|
32
|
+
return { status: res.status, body };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
beforeAll(async () => {
|
|
36
|
+
process.env.HOST = "127.0.0.1";
|
|
37
|
+
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
|
38
|
+
const pw = "NetworkRulesPw123Aa!";
|
|
39
|
+
const a = register(`nr_admin_${suffix}`, pw, undefined, "admin");
|
|
40
|
+
if (!a.ok) throw new Error("admin register failed");
|
|
41
|
+
adminToken = a.token!;
|
|
42
|
+
netId = a.network_id!;
|
|
43
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [a.user!.user_id]);
|
|
44
|
+
|
|
45
|
+
// The server must be listening before any HTTP call below.
|
|
46
|
+
const { bootServer } = await import("./server.js");
|
|
47
|
+
server = bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
48
|
+
BASE = `http://127.0.0.1:${server.port}`;
|
|
49
|
+
|
|
50
|
+
const m = await send(adminToken, "POST", "/api/admin/users", { username: `nr_member_${suffix}`, password: pw, network_id: netId, role: "member" });
|
|
51
|
+
expect(m.status).toBe(200);
|
|
52
|
+
const login = await send("", "POST", "/api/auth/login", { username: `nr_member_${suffix}`, password: pw });
|
|
53
|
+
expect(login.status).toBe(200);
|
|
54
|
+
memberToken = login.body.token;
|
|
55
|
+
|
|
56
|
+
const { createNetworkTokenForNode } = await import("./auth.js");
|
|
57
|
+
const nt = createNetworkTokenForNode(a.user!.user_id, netId, "rules-node");
|
|
58
|
+
expect(nt.ok).toBe(true);
|
|
59
|
+
nodeToken = nt.token!;
|
|
60
|
+
}, 30_000);
|
|
61
|
+
|
|
62
|
+
afterAll(() => { try { server?.stop?.(true); } catch {} });
|
|
63
|
+
|
|
64
|
+
describe("#993 — network rules storage + CAS write", () => {
|
|
65
|
+
test("a member reads an empty document as revision 0", async () => {
|
|
66
|
+
const r = await send(memberToken, "GET", `/api/networks/${netId}/rules`);
|
|
67
|
+
expect(r.status).toBe(200);
|
|
68
|
+
expect(r.body.rules).toMatchObject({ network_id: netId, content: "", revision: 0, updated_at: null, updated_by: null });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("a plain member cannot write", async () => {
|
|
72
|
+
const r = await send(memberToken, "PUT", `/api/networks/${netId}/rules`, { content: "nope", expected_revision: 0 });
|
|
73
|
+
expect(r.status).toBe(403);
|
|
74
|
+
expect(r.body.error).toBe("owner/admin required");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("admin writes with expected_revision 0 and reads it back", async () => {
|
|
78
|
+
const w = await send(adminToken, "PUT", `/api/networks/${netId}/rules`, {
|
|
79
|
+
content: "# Team rules\n\n- be precise\n", expected_revision: 0,
|
|
80
|
+
});
|
|
81
|
+
expect(w.status).toBe(200);
|
|
82
|
+
expect(w.body.rules.revision).toBe(1);
|
|
83
|
+
expect(w.body.rules.updated_by.username).toBeTruthy();
|
|
84
|
+
const r = await send(memberToken, "GET", `/api/networks/${netId}/rules`);
|
|
85
|
+
expect(r.body.rules.content).toContain("be precise");
|
|
86
|
+
expect(r.body.rules.revision).toBe(1);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("a stale expected_revision is a 409, and nothing is overwritten", async () => {
|
|
90
|
+
const w = await send(adminToken, "PUT", `/api/networks/${netId}/rules`, { content: "clobber", expected_revision: 0 });
|
|
91
|
+
expect(w.status).toBe(409);
|
|
92
|
+
expect(w.body.error).toBe("rules_revision_conflict");
|
|
93
|
+
const r = await send(adminToken, "GET", `/api/networks/${netId}/rules`);
|
|
94
|
+
expect(r.body.rules.content).toContain("be precise");
|
|
95
|
+
expect(r.body.rules.revision).toBe(1);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("a node token cannot write (user_token_required)", async () => {
|
|
99
|
+
const r = await send(nodeToken, "PUT", `/api/networks/${netId}/rules`, { content: "x", expected_revision: 1 });
|
|
100
|
+
expect(r.status).toBe(403);
|
|
101
|
+
expect(r.body.error).toBe("user_token_required");
|
|
102
|
+
});
|
|
103
|
+
|
|
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 });
|
|
106
|
+
expect(r.status).toBe(413);
|
|
107
|
+
expect(r.body.error).toBe("rules_too_large");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("a non-member cannot read", async () => {
|
|
111
|
+
const other = register(`nr_other_${Date.now()}`, "OtherPw123Aa!", undefined, "other");
|
|
112
|
+
expect(other.ok).toBe(true);
|
|
113
|
+
const r = await send(other.token!, "GET", `/api/networks/${netId}/rules`);
|
|
114
|
+
expect(r.status).toBe(403);
|
|
115
|
+
expect(r.body.error).toBe("not_a_member");
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// #993 — network-scoped rules text.
|
|
2
|
+
//
|
|
3
|
+
// First slice: storage + a read/write API with a compare-and-swap revision and an
|
|
4
|
+
// audit entry. Inheritance (Hub → Daemon → node) is NOT implemented here: it needs
|
|
5
|
+
// a precedence and conflict story of its own, and shipping the storage first is
|
|
6
|
+
// what lets that be reviewed against real content.
|
|
7
|
+
//
|
|
8
|
+
// Deliberate choices:
|
|
9
|
+
// * one row per network, text is the whole document (no chunking) — a team rule
|
|
10
|
+
// file is a few kilobytes, and partial reads would make "which revision did I
|
|
11
|
+
// read" ambiguous;
|
|
12
|
+
// * `revision` starts at 1 for the first write; reads before any write report
|
|
13
|
+
// revision 0 and empty content, so a client can PUT with expected_revision 0
|
|
14
|
+
// without a special "create" path;
|
|
15
|
+
// * the size cap is measured in UTF-8 bytes, not UTF-16 units, because the
|
|
16
|
+
// content is a file the node will write to disk.
|
|
17
|
+
import type { DbAdapter } from "./db-adapter.js";
|
|
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;
|
|
21
|
+
|
|
22
|
+
export type NetworkRules = {
|
|
23
|
+
network_id: string;
|
|
24
|
+
content: string;
|
|
25
|
+
revision: number;
|
|
26
|
+
updated_at: string | null;
|
|
27
|
+
updated_by: { user_id: string; username: string | null } | null;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type Row = { content: string; revision: number; updated_at: string; updated_by_json: string | null };
|
|
31
|
+
|
|
32
|
+
export function ensureNetworkRules(database: DbAdapter): void {
|
|
33
|
+
database.exec(`
|
|
34
|
+
CREATE TABLE IF NOT EXISTS network_rules (
|
|
35
|
+
network_id TEXT NOT NULL PRIMARY KEY,
|
|
36
|
+
content TEXT NOT NULL,
|
|
37
|
+
revision INTEGER NOT NULL,
|
|
38
|
+
updated_at TEXT NOT NULL,
|
|
39
|
+
updated_by_json TEXT
|
|
40
|
+
);
|
|
41
|
+
`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let ensured = false;
|
|
45
|
+
/** Idempotent: the table is created on first use, not on every request. */
|
|
46
|
+
export function ensureNetworkRulesOnce(database: DbAdapter): void {
|
|
47
|
+
if (ensured) return;
|
|
48
|
+
ensureNetworkRules(database);
|
|
49
|
+
ensured = true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function current(database: DbAdapter, networkId: string): Row | null {
|
|
53
|
+
return database.get<Row>(
|
|
54
|
+
"SELECT content, revision, updated_at, updated_by_json FROM network_rules WHERE network_id = ?1",
|
|
55
|
+
networkId,
|
|
56
|
+
) ?? null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function readNetworkRules(database: DbAdapter, networkId: string): NetworkRules {
|
|
60
|
+
const row = current(database, networkId);
|
|
61
|
+
if (!row) return { network_id: networkId, content: "", revision: 0, updated_at: null, updated_by: null };
|
|
62
|
+
let updatedBy: NetworkRules["updated_by"] = null;
|
|
63
|
+
try {
|
|
64
|
+
const parsed = JSON.parse(row.updated_by_json ?? "null");
|
|
65
|
+
if (parsed && typeof parsed === "object" && typeof parsed.user_id === "string") {
|
|
66
|
+
updatedBy = { user_id: parsed.user_id, username: typeof parsed.username === "string" ? parsed.username : null };
|
|
67
|
+
}
|
|
68
|
+
} catch { /* a corrupt actor blob must not make the rules unreadable */ }
|
|
69
|
+
return {
|
|
70
|
+
network_id: networkId,
|
|
71
|
+
content: row.content,
|
|
72
|
+
revision: Number(row.revision) || 0,
|
|
73
|
+
updated_at: row.updated_at,
|
|
74
|
+
updated_by: updatedBy,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type WriteResult =
|
|
79
|
+
| { ok: true; rules: NetworkRules }
|
|
80
|
+
| { ok: false; status: number; error: "invalid_rules_content" | "rules_too_large" | "rules_revision_conflict" };
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Compare-and-swap write. `expectedRevision` is required: a blind PUT would let two
|
|
84
|
+
* clients overwrite each other, and this is the same document every node in the
|
|
85
|
+
* network would inherit.
|
|
86
|
+
*/
|
|
87
|
+
export function writeNetworkRules(
|
|
88
|
+
database: DbAdapter,
|
|
89
|
+
networkId: string,
|
|
90
|
+
content: unknown,
|
|
91
|
+
expectedRevision: unknown,
|
|
92
|
+
actor: { user_id: string; username: string | null },
|
|
93
|
+
now = new Date().toISOString(),
|
|
94
|
+
): WriteResult {
|
|
95
|
+
if (typeof content !== "string") return { ok: false, status: 400, error: "invalid_rules_content" };
|
|
96
|
+
if (typeof expectedRevision !== "number" || !Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
|
|
97
|
+
return { ok: false, status: 400, error: "invalid_rules_content" };
|
|
98
|
+
}
|
|
99
|
+
if (Buffer.byteLength(content, "utf8") > NETWORK_RULES_MAX_BYTES) {
|
|
100
|
+
return { ok: false, status: 413, error: "rules_too_large" };
|
|
101
|
+
}
|
|
102
|
+
const row = current(database, networkId);
|
|
103
|
+
const revision = row ? Number(row.revision) || 0 : 0;
|
|
104
|
+
if (revision !== expectedRevision) return { ok: false, status: 409, error: "rules_revision_conflict" };
|
|
105
|
+
const next = revision + 1;
|
|
106
|
+
const actorJson = JSON.stringify({ user_id: actor.user_id, username: actor.username });
|
|
107
|
+
if (row) {
|
|
108
|
+
database.run(
|
|
109
|
+
"UPDATE network_rules SET content = ?1, revision = ?2, updated_at = ?3, updated_by_json = ?4 WHERE network_id = ?5",
|
|
110
|
+
[content, next, now, actorJson, networkId],
|
|
111
|
+
);
|
|
112
|
+
} else {
|
|
113
|
+
database.run(
|
|
114
|
+
"INSERT INTO network_rules (network_id, content, revision, updated_at, updated_by_json) VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
115
|
+
[networkId, content, next, now, actorJson],
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return { ok: true, rules: readNetworkRules(database, networkId) };
|
|
119
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/
|
|
|
24
24
|
import { z } from "zod/v4";
|
|
25
25
|
import { registerTools } from "./tools.js";
|
|
26
26
|
import { db, logTaskEvent, logAudit, syncScheduledRunForTask } from "./db.js";
|
|
27
|
+
import { ensureNetworkRulesOnce, readNetworkRules, writeNetworkRules } from "./network-rules.js";
|
|
27
28
|
import { recentNodeIdentityConflicts } from "./node-identity-conflict.js";
|
|
28
29
|
import { createSSEStream, createNetworkObserverStream, createUserEventStream, pushEvent, pushNetworkObserverEvent, getSSEStats, PRINTABLE_OBSERVER_KEY_PREFIX, closeUserStreamsInNetwork, getUserPresence, onUserPresenceChange, pushUserEventToNetwork } from "./push.js";
|
|
29
30
|
import { assertNodeActive } from "./lifecycle-guard.js";
|
|
@@ -1889,6 +1890,46 @@ return Bun.serve({
|
|
|
1889
1890
|
|
|
1890
1891
|
// ── Agent 分组(RFC-038 §8):owner/admin/Hub 管理员管理;组授权动态生效 ──
|
|
1891
1892
|
// GET/POST /api/networks/:id/agent-groups · PATCH/DELETE …/:gid · PUT …/:gid/members {node_ids}
|
|
1893
|
+
// #993 — network-scoped rules text (first slice: storage + CAS write + audit).
|
|
1894
|
+
// Inheritance (Hub → Daemon → node) is deliberately NOT here: it needs its own
|
|
1895
|
+
// precedence/conflict decision, and shipping storage first keeps that reviewable.
|
|
1896
|
+
const networkRulesMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/rules$/);
|
|
1897
|
+
if (networkRulesMatch) {
|
|
1898
|
+
const token = requestToken(req, { allowQueryToken: false });
|
|
1899
|
+
const resolved = token ? resolveToken(token) : null;
|
|
1900
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1901
|
+
const netId = decodeURIComponent(networkRulesMatch[1]);
|
|
1902
|
+
const hubAdmin = resolved.user.role === "admin";
|
|
1903
|
+
const role = getUserNetworkRole(resolved.user.user_id, netId);
|
|
1904
|
+
ensureNetworkRulesOnce(db);
|
|
1905
|
+
if (req.method === "GET") {
|
|
1906
|
+
// Reading is for anyone in the network: the rules are what the nodes obey.
|
|
1907
|
+
if (!hubAdmin && !role) return withCors(req, Response.json({ ok: false, error: "not_a_member" }, { status: 403 }));
|
|
1908
|
+
return withCors(req, Response.json({ ok: true, rules: readNetworkRules(db, netId) }));
|
|
1909
|
+
}
|
|
1910
|
+
if (req.method === "PUT") {
|
|
1911
|
+
// Writing changes what every node in the network would inherit.
|
|
1912
|
+
if (resolved.networkId) return withCors(req, Response.json({ ok: false, error: "user_token_required" }, { status: 403 }));
|
|
1913
|
+
if (!hubAdmin && role !== "owner" && role !== "admin") {
|
|
1914
|
+
return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
|
|
1915
|
+
}
|
|
1916
|
+
let body: any = null;
|
|
1917
|
+
try { body = await req.json(); } catch { body = null; }
|
|
1918
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
1919
|
+
return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 }));
|
|
1920
|
+
}
|
|
1921
|
+
const result = writeNetworkRules(db, netId, body.content, body.expected_revision, {
|
|
1922
|
+
user_id: resolved.user.user_id,
|
|
1923
|
+
username: resolved.user.username ?? null,
|
|
1924
|
+
});
|
|
1925
|
+
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 }));
|
|
1929
|
+
}
|
|
1930
|
+
return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1892
1933
|
const groupsMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/agent-groups(?:\/([^/]+)(\/members)?)?$/);
|
|
1893
1934
|
if (groupsMatch) {
|
|
1894
1935
|
const token = requestToken(req, { allowQueryToken: false });
|