@sleep2agi/commhub-server 0.9.0-preview.3 → 0.9.0-preview.4

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.3",
3
+ "version": "0.9.0-preview.4",
4
4
  "description": "CommHub Server \u2014 AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 17 MCP tools.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,94 @@
1
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { db } from "./db.js";
3
+
4
+ // Regression test for SELECT * fragility on GET /api/nodes.
5
+ //
6
+ // Prior behavior: `SELECT * FROM nodes` returned whatever columns the
7
+ // table happened to have. When RFC-024 added config_revision +
8
+ // config_snapshot columns (read by the per-node GET /api/nodes/:id/config
9
+ // endpoint, NOT meant for the list endpoint), they silently leaked into
10
+ // the list response and broke V3 Networks tests that asserted on the
11
+ // nodes-list row shape.
12
+ //
13
+ // Current behavior: explicit column list — the response contract is:
14
+ // node_id, node_name, alias, runtime, model, config_path, channels,
15
+ // server, hostname, network_id, created_at, updated_at
16
+ // This test runs the same SQL the handler uses, on a row that DOES have
17
+ // config_revision + config_snapshot populated, and asserts those fields
18
+ // do NOT appear in the returned row. If anyone reverts the handler to
19
+ // SELECT *, or adds an internal column to the SELECT, this test breaks.
20
+
21
+ const TEST_NETWORK = "net_test_shape";
22
+ const TEST_NODE = "node_test_shape_abc";
23
+
24
+ beforeEach(() => {
25
+ // Best-effort cleanup (test runs in shared test DB; rows are isolated by id)
26
+ try { db.run("DELETE FROM nodes WHERE node_id = ?1", [TEST_NODE]); } catch {}
27
+ });
28
+ afterAll(() => {
29
+ try { db.run("DELETE FROM nodes WHERE node_id = ?1", [TEST_NODE]); } catch {}
30
+ });
31
+
32
+ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", () => {
33
+ test("explicit columns: internal config_revision/config_snapshot NOT in response", () => {
34
+ // Insert a row with the leak-prone columns populated. Use INSERT OR
35
+ // REPLACE so concurrent test runs don't trip on PRIMARY KEY.
36
+ db.run(
37
+ `INSERT OR REPLACE INTO nodes (
38
+ node_id, node_name, alias, runtime, model, config_path,
39
+ channels, server, hostname, network_id,
40
+ config_revision, config_snapshot
41
+ ) VALUES (
42
+ ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12
43
+ )`,
44
+ [TEST_NODE, "test-shape", "test-shape", "claude-agent-sdk", "claude-opus",
45
+ "/tmp/cfg.json", "[]", "localhost", "test-host", TEST_NETWORK,
46
+ 7, JSON.stringify({ model: "claude-opus", flags: { x: true } })],
47
+ );
48
+
49
+ // Run the same SQL the handler runs (mirror; if handler changes, this
50
+ // assertion still locks the shape).
51
+ const rows = db.all<Record<string, unknown>>(
52
+ `SELECT node_id, node_name, alias, runtime, model,
53
+ config_path, channels, server, hostname,
54
+ network_id, created_at, updated_at
55
+ FROM nodes WHERE node_id = ?1`,
56
+ TEST_NODE,
57
+ );
58
+
59
+ expect(rows).toHaveLength(1);
60
+ const row = rows[0]!;
61
+ // Expected fields present
62
+ for (const k of ["node_id", "node_name", "alias", "runtime", "model",
63
+ "config_path", "channels", "server", "hostname",
64
+ "network_id", "created_at", "updated_at"]) {
65
+ expect(row).toHaveProperty(k);
66
+ }
67
+ // Internal fields NOT present (these are the leak the test guards against)
68
+ expect(row).not.toHaveProperty("config_revision");
69
+ expect(row).not.toHaveProperty("config_snapshot");
70
+ // Belt: full key set is exactly the 12 contract fields
71
+ expect(Object.keys(row).sort()).toEqual([
72
+ "alias", "channels", "config_path", "created_at", "hostname",
73
+ "model", "network_id", "node_id", "node_name", "runtime",
74
+ "server", "updated_at",
75
+ ]);
76
+ });
77
+
78
+ test("internal columns are still readable via dedicated query (config snapshot endpoint path)", () => {
79
+ db.run(
80
+ `INSERT OR REPLACE INTO nodes (node_id, node_name, alias, network_id,
81
+ config_revision, config_snapshot)
82
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6)`,
83
+ [TEST_NODE, "test-shape", "test-shape", TEST_NETWORK,
84
+ 42, JSON.stringify({ model: "claude-opus" })],
85
+ );
86
+ // Mirrors the SELECT in GET /api/nodes/:id/config (server/src/index.ts):
87
+ const cfgRow = db.get<{ config_revision: number; config_snapshot: string }>(
88
+ "SELECT config_revision, config_snapshot FROM nodes WHERE node_id = ?1",
89
+ TEST_NODE,
90
+ );
91
+ expect(cfgRow?.config_revision).toBe(42);
92
+ expect(cfgRow?.config_snapshot).toContain("claude-opus");
93
+ });
94
+ });
package/src/index.ts CHANGED
@@ -1950,10 +1950,25 @@ Bun.serve({
1950
1950
  }
1951
1951
 
1952
1952
  // ── REST: nodes table (V2 Sprint 2) ──
1953
+ // Explicit column list — NOT `SELECT *`. Two reasons:
1954
+ // 1. `SELECT *` silently broadcasts any column added later to the
1955
+ // response (RFC-024 added config_revision/config_snapshot which
1956
+ // the dashboard's GET /api/nodes/:id/config endpoint owns;
1957
+ // RFC-028 will add more vault-flavored columns). Tests asserting
1958
+ // response shape break on every ALTER; integrations that key
1959
+ // off Object.keys() can pick up internals.
1960
+ // 2. The list endpoint and the per-node config snapshot endpoint
1961
+ // have different consumer contracts; this list should be the
1962
+ // stable, dashboard-facing fields only.
1963
+ // Add a new column to this list explicitly when a real client needs
1964
+ // it; do NOT switch back to SELECT *.
1953
1965
  if (url.pathname === "/api/nodes") {
1954
1966
  const nodeId = url.searchParams.get("node_id");
1955
1967
  const alias = url.searchParams.get("alias");
1956
- let sql = "SELECT * FROM nodes WHERE 1=1";
1968
+ let sql = `SELECT node_id, node_name, alias, runtime, model,
1969
+ config_path, channels, server, hostname,
1970
+ network_id, created_at, updated_at
1971
+ FROM nodes WHERE 1=1`;
1957
1972
  const params: any[] = [];
1958
1973
  sql = addNetworkScope(sql, params, restScope);
1959
1974
  if (nodeId) { sql += ` AND node_id = ?${params.length + 1}`; params.push(nodeId); }