@sleep2agi/commhub-server 0.9.0-preview.129 → 0.9.0-preview.130

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.129",
3
+ "version": "0.9.0-preview.130",
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",
@@ -27,6 +27,21 @@ export function checkNodeTokenClaim(user: string, network: string, alias: string
27
27
  if (row && (row.owner_user_id ? row.owner_user_id !== user : !nodeId && !legacyNodeHolder(user, network, alias, row.node_id))) {
28
28
  throw Error("node_owner_mismatch");
29
29
  }
30
+ // Old clients can register a session without ever creating a nodes row.
31
+ // That name is already in use. Sessions have no owner column, so only
32
+ // an administrator or the unique active node-token holder may refresh it.
33
+ // A general token renamed by a heartbeat is not holder evidence (epoch 1).
34
+ if (!row && db.get("SELECT resume_id FROM sessions WHERE network_id=?1 AND alias=?2 LIMIT 1", network, alias)) {
35
+ const actor = db.get<{ role: string; hub_role: string }>(
36
+ `SELECT nm.role, u.role AS hub_role FROM network_members nm JOIN users u ON u.user_id=nm.user_id
37
+ WHERE nm.network_id=?1 AND nm.user_id=?2`, network, user);
38
+ const admin = actor && (actor.role === "owner" || actor.role === "admin" || actor.hub_role === "admin");
39
+ const holders = db.all<{ user_id: string }>(
40
+ `SELECT DISTINCT user_id FROM api_tokens WHERE network_id=?1 AND name=?2 AND scope='network'
41
+ AND bound_node_id IS NULL AND node_identity_epoch IN (0,2) AND revoked_at IS NULL
42
+ AND (expires_at IS NULL OR expires_at > datetime('now'))`, network, `node:${alias}`);
43
+ if (!admin && (holders.length !== 1 || holders[0].user_id !== user)) throw Error("node_owner_mismatch");
44
+ }
30
45
  if (!row && db.get(`SELECT token_id FROM api_tokens WHERE network_id=?1 AND name=?2
31
46
  AND user_id<>?3 AND node_identity_epoch=2 AND revoked_at IS NULL
32
47
  AND (expires_at IS NULL OR expires_at > datetime('now')) LIMIT 1`, network, `node:${alias}`, user)) {
@@ -0,0 +1,122 @@
1
+ import { beforeAll, afterAll, expect, test } from "bun:test";
2
+ import { mkdtempSync } from "node:fs";
3
+
4
+ process.env.COMMHUB_DB ||= `${mkdtempSync("/tmp/session689-")}/hub.db`;
5
+ let db: any, hub: ReturnType<typeof Bun.serve>, base: string;
6
+ let owner: any, holder: any, member: any, admin: any, outsider: any;
7
+ let generateNetworkToken: () => string, hashToken: (token: string) => string;
8
+ async function mint(actor: any, alias: string, nodeId?: string, network = owner.network_id) {
9
+ const response = await fetch(`${base}/api/auth/node-token`, {
10
+ method: "POST", headers: { Authorization: `Bearer ${actor.token}`, "Content-Type": "application/json" },
11
+ body: JSON.stringify({ network_id: network, node_name: alias, ...(nodeId ? { node_id: nodeId } : {}) }),
12
+ });
13
+ return { status: response.status, body: await response.json() as any };
14
+ }
15
+ function session(alias: string, network = owner.network_id, nodeId: string | null = null) {
16
+ db.run("INSERT INTO sessions(resume_id,alias,network_id,node_id,status) VALUES(?1,?2,?3,?4,'offline')",
17
+ [crypto.randomUUID(), alias, network, nodeId]);
18
+ }
19
+ function credential(alias: string, actor = holder, options: { epoch?: number; revoked?: boolean; expired?: boolean; network?: string; bound?: string; scope?: string } = {}) {
20
+ const id = crypto.randomUUID();
21
+ db.run(`INSERT INTO api_tokens(token_id,token_hash,user_id,network_id,name,scope,node_identity_epoch,bound_node_id,revoked_at,expires_at)
22
+ VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)`,
23
+ [id, hashToken(generateNetworkToken()), actor.user.user_id, options.network ?? owner.network_id,
24
+ `node:${alias}`, options.scope ?? "network", options.epoch ?? 0, options.bound ?? null,
25
+ options.revoked ? "2000-01-01 00:00:00" : null, options.expired ? "2000-01-01 00:00:00" : null]);
26
+ }
27
+ function snapshot() {
28
+ return {
29
+ tokens: db.all("SELECT token_id,user_id,name,bound_node_id FROM api_tokens ORDER BY token_id"),
30
+ sessions: db.all("SELECT resume_id,alias,network_id,node_id,status FROM sessions ORDER BY resume_id"),
31
+ nodes: db.all("SELECT node_id,alias,network_id,owner_user_id FROM nodes ORDER BY node_id"),
32
+ audits: db.all("SELECT id,action,target_id FROM audit_log ORDER BY id"),
33
+ };
34
+ }
35
+ async function denied(actor: any, alias: string, nodeId?: string) {
36
+ const before = snapshot();
37
+ expect(await mint(actor, alias, nodeId)).toEqual({ status: 400, body: { ok: false, error: "node_owner_mismatch" } });
38
+ expect(snapshot()).toEqual(before);
39
+ }
40
+ beforeAll(async () => {
41
+ ({ db, generateNetworkToken, hashToken } = await import("./db.js"));
42
+ const { register } = await import("./auth.js");
43
+ owner = register("owner689", "Fixture-Strong1!");
44
+ holder = register("holder689", "Fixture-Strong1!");
45
+ member = register("member689", "Fixture-Strong1!");
46
+ admin = register("admin689", "Fixture-Strong1!");
47
+ outsider = register("outside689", "Fixture-Strong1!");
48
+ for (const actor of [holder, member, admin]) {
49
+ db.run("INSERT INTO network_members(network_id,user_id,role,agent_access) VALUES(?1,?2,?3,'all')",
50
+ [owner.network_id, actor.user.user_id, actor === admin ? "admin" : "member"]);
51
+ }
52
+ const { bootServer } = await import("./server.js");
53
+ hub = bootServer({ port: 0, hostname: "127.0.0.1" }); base = `http://127.0.0.1:${hub.port}`;
54
+ }, 60000);
55
+ afterAll(() => hub?.stop(true));
56
+
57
+ test("existing session requires authority for both request shapes without writes", async () => {
58
+ session("existing689");
59
+ await denied(member, "existing689");
60
+ await denied(member, "existing689", "n_existing689");
61
+ });
62
+ test("network owner and administrator can restore a legacy identity", async () => {
63
+ for (const [actor, alias] of [[owner, "restore-owner689"], [admin, "restore-admin689"]]) {
64
+ session(alias);
65
+ expect((await mint(actor, alias)).status).toBe(200);
66
+ const withId = await mint(actor, alias, `n_${alias}`);
67
+ expect(withId.status).toBe(200);
68
+ expect(db.get("SELECT owner_user_id FROM nodes WHERE node_id=?1", `n_${alias}`).owner_user_id).toBe(actor.user.user_id);
69
+ }
70
+ });
71
+ test("unique legacy holder can refresh without rewriting the session", async () => {
72
+ session("holder-session689"); credential("holder-session689");
73
+ await denied(member, "holder-session689");
74
+ const before = db.get("SELECT * FROM sessions WHERE alias='holder-session689'");
75
+ expect((await mint(holder, "holder-session689")).status).toBe(200);
76
+ expect((await mint(holder, "holder-session689", "n_holder-session689")).status).toBe(200);
77
+ expect(db.get("SELECT * FROM sessions WHERE alias='holder-session689'")).toEqual(before);
78
+ });
79
+ test("current name-only holder can refresh while registration is pending", async () => {
80
+ expect((await mint(holder, "pending689")).status).toBe(200);
81
+ session("pending689");
82
+ await denied(member, "pending689", "n_pending689");
83
+ expect((await mint(holder, "pending689")).status).toBe(200);
84
+ });
85
+ for (const [label, options] of [
86
+ ["revoked", { revoked: true }], ["expired", { expired: true }],
87
+ ["renamed-general", { epoch: 1 }], ["wrong-scope", { scope: "full" }],
88
+ ["missing-bound-row", { bound: "n_missing689" }],
89
+ ] as const) {
90
+ test(`${label} credential is not session holder evidence`, async () => {
91
+ const alias = `${label}689`; session(alias); credential(alias, holder, options);
92
+ await denied(holder, alias);
93
+ });
94
+ }
95
+ test("conflicting legacy holders need administrator recovery", async () => {
96
+ session("ambiguous689"); credential("ambiguous689"); credential("ambiguous689", member);
97
+ await denied(holder, "ambiguous689"); await denied(member, "ambiguous689", "n_ambiguous689");
98
+ expect((await mint(admin, "ambiguous689")).status).toBe(200);
99
+ });
100
+ test("session and holder evidence stay in the requested network", async () => {
101
+ session("foreign-session689", outsider.network_id);
102
+ expect((await mint(member, "foreign-session689")).status).toBe(200);
103
+ session("foreign-holder689"); credential("foreign-holder689", holder, { network: holder.network_id });
104
+ await denied(holder, "foreign-holder689");
105
+ expect((await mint(outsider, "foreign-holder689")).body.error).toBe("no write access to this network");
106
+ });
107
+ test("administrators do not bypass an existing node owner", async () => {
108
+ db.run("INSERT INTO nodes(node_id,node_name,alias,network_id,owner_user_id) VALUES('n_owned689','owned689','owned689',?1,?2)",
109
+ [owner.network_id, holder.user.user_id]);
110
+ session("owned689", owner.network_id, "n_owned689");
111
+ await denied(admin, "owned689"); await denied(owner, "owned689", "n_owned689");
112
+ expect((await mint(holder, "owned689")).body.node_id).toBe("n_owned689");
113
+ });
114
+ test("viewer and restricted member gates still precede session recovery", async () => {
115
+ session("restricted689"); credential("restricted689");
116
+ db.run("UPDATE network_members SET agent_access='granted' WHERE network_id=?1 AND user_id=?2", [owner.network_id, holder.user.user_id]);
117
+ try { expect((await mint(holder, "restricted689")).body.error).toBe("restricted members cannot create network tokens"); }
118
+ finally { db.run("UPDATE network_members SET agent_access='all' WHERE network_id=?1 AND user_id=?2", [owner.network_id, holder.user.user_id]); }
119
+ db.run("UPDATE network_members SET role='viewer' WHERE network_id=?1 AND user_id=?2", [owner.network_id, member.user.user_id]);
120
+ try { expect((await mint(member, "restricted689")).body.error).toBe("no write access to this network"); }
121
+ finally { db.run("UPDATE network_members SET role='member' WHERE network_id=?1 AND user_id=?2", [owner.network_id, member.user.user_id]); }
122
+ });