@sleep2agi/commhub-server 0.9.0-preview.27 → 0.9.0-preview.29

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.
Files changed (56) hide show
  1. package/bin/commhub.ts +4 -0
  2. package/package.json +4 -4
  3. package/src/ack-create-request-transport.test.ts +159 -0
  4. package/src/ack-create-request.test.ts +5 -5
  5. package/src/admin-networks-http.test.ts +84 -0
  6. package/src/api-host-supervisors-fallback.test.ts +1 -6
  7. package/src/auth.ts +88 -14
  8. package/src/dashboard-slash-routing-http.test.ts +103 -0
  9. package/src/db-adapter-guard.test.ts +85 -0
  10. package/src/db-adapter.ts +64 -23
  11. package/src/db.ts +279 -32
  12. package/src/ext-token-shared.test.ts +60 -0
  13. package/src/ext-validation-regression.test.ts +10 -17
  14. package/src/external-schedule-edits-http.test.ts +316 -0
  15. package/src/external-schedule-edits.ts +314 -0
  16. package/src/file-download-authz-dev-open.test.ts +1 -1
  17. package/src/file-download-authz.test.ts +21 -1
  18. package/src/file-download-date-bucket.test.ts +1 -1
  19. package/src/file-network-scope-dev-open.test.ts +1 -1
  20. package/src/file-network-scope.test.ts +1 -1
  21. package/src/health-redaction.test.ts +12 -0
  22. package/src/index.ts +6 -2
  23. package/src/network-name-http.test.ts +71 -0
  24. package/src/network-scope.ts +20 -0
  25. package/src/node-attrs.test.ts +1 -1
  26. package/src/peer-reply-atomic.test.ts +455 -0
  27. package/src/peer-reply-capability.test.ts +70 -0
  28. package/src/require-explicit-test-db.ts +9 -0
  29. package/src/rest-explicit-columns-http.test.ts +149 -0
  30. package/src/rest-identity.test.ts +120 -0
  31. package/src/rest-identity.ts +93 -0
  32. package/src/rest-projections.ts +67 -0
  33. package/src/scheduled-run-terminal.test.ts +268 -0
  34. package/src/scheduled-tasks-http.test.ts +460 -0
  35. package/src/scheduled-tasks.ts +538 -0
  36. package/src/send-reply-agent-warning.test.ts +38 -38
  37. package/src/server.ts +315 -60
  38. package/src/shared/external-schedule-contract.ts +60 -0
  39. package/src/skillhub-http.test.ts +88 -0
  40. package/src/task-auth-origin-http.test.ts +87 -0
  41. package/src/task-auth-origin.test.ts +24 -0
  42. package/src/task-auth-origin.ts +7 -0
  43. package/src/task-consumption.test.ts +329 -0
  44. package/src/task-diagnostic-http.test.ts +145 -0
  45. package/src/task-diagnostic.test.ts +82 -0
  46. package/src/task-diagnostic.ts +89 -0
  47. package/src/task-idempotency-mcp.test.ts +137 -0
  48. package/src/task-idempotency.test.ts +60 -0
  49. package/src/task-idempotency.ts +75 -0
  50. package/src/task-lifecycle-watcher.test.ts +160 -0
  51. package/src/task-lifecycle-watcher.ts +49 -0
  52. package/src/task-network-resolution-http.test.ts +137 -0
  53. package/src/tasks-pagination-http.test.ts +137 -0
  54. package/src/tools.ts +635 -107
  55. package/src/uploads-http.test.ts +1 -3
  56. package/src/uploads.ts +14 -3
package/bin/commhub.ts CHANGED
@@ -54,6 +54,10 @@ Examples:
54
54
  }
55
55
  }
56
56
 
57
+ // Opt into the default production DB path before loading the server graph.
58
+ // Ordinary imports / bun -e probes do not receive this capability.
59
+ process.env.COMMHUB_SERVER = "1";
60
+
57
61
  // Load the server module (side-effect-free since the #438 corrective:
58
62
  // importing binds nothing) and start the hub EXPLICITLY. Never rely on
59
63
  // import side effects or import.meta.main here — this bin is a dynamic
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.27",
3
+ "version": "0.9.0-preview.29",
4
4
  "description": "CommHub Server — 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",
@@ -12,9 +12,9 @@
12
12
  "bin"
13
13
  ],
14
14
  "scripts": {
15
- "start": "bun run src/index.ts",
16
- "dev": "bun --watch run src/index.ts",
17
- "test": "COMMHUB_DB=/tmp/test-commhub-$$.db bun test src/"
15
+ "start": "COMMHUB_SERVER=1 bun run src/index.ts",
16
+ "dev": "COMMHUB_SERVER=1 bun --watch run src/index.ts",
17
+ "test": "bun run scripts/test-aggregate.ts"
18
18
  },
19
19
  "keywords": [
20
20
  "commhub",
@@ -0,0 +1,159 @@
1
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { db } from "./db.js";
6
+ import { registerTools } from "./tools.js";
7
+
8
+ const NET = "net_ack_transport";
9
+ const DAEMON_NODE_ID = "node_ack_transport_daemon";
10
+ const DAEMON_ALIAS = "ack-transport-daemon";
11
+ const DAEMON_USER = "u_ack_transport_owner";
12
+ const DAEMON_TOKEN_ID = "tok_ack_transport_daemon";
13
+
14
+ function cleanup() {
15
+ try { db.run("DELETE FROM audit_log WHERE network_id = ?1", [NET]); } catch {}
16
+ try { db.run("DELETE FROM node_create_requests WHERE network_id = ?1", [NET]); } catch {}
17
+ try { db.run("DELETE FROM nodes WHERE network_id = ?1", [NET]); } catch {}
18
+ try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [NET]); } catch {}
19
+ try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET]); } catch {}
20
+ try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET]); } catch {}
21
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [DAEMON_USER]); } catch {}
22
+ }
23
+
24
+ function seedWorld() {
25
+ db.run(
26
+ `INSERT INTO users (user_id, username, password_hash, role, created_at)
27
+ VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
28
+ [DAEMON_USER, DAEMON_USER],
29
+ );
30
+ db.run(
31
+ `INSERT INTO networks (network_id, network_name, owner_id, created_at)
32
+ VALUES (?1, ?2, ?3, datetime('now'))`,
33
+ [NET, NET, DAEMON_USER],
34
+ );
35
+ db.run(
36
+ `INSERT INTO network_members (user_id, network_id, role, joined_at)
37
+ VALUES (?1, ?2, 'owner', datetime('now'))`,
38
+ [DAEMON_USER, NET],
39
+ );
40
+ db.run(
41
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at)
42
+ VALUES (?1, ?2, ?3, ?4, datetime('now'), datetime('now'))`,
43
+ [DAEMON_NODE_ID, DAEMON_ALIAS, DAEMON_ALIAS, NET],
44
+ );
45
+ db.run(
46
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
47
+ VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
48
+ [DAEMON_TOKEN_ID, DAEMON_USER, NET, `node:${DAEMON_ALIAS}`, "hash_ack_transport_daemon"],
49
+ );
50
+ }
51
+
52
+ function seedRequest(requestId: string) {
53
+ db.run(
54
+ `INSERT INTO node_create_requests
55
+ (request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, created_at, created_by_token)
56
+ VALUES (?1, ?2, ?3, ?4, 'codex-sdk', 'x', '{}', '[]', 'delivered', ?5, ?6)`,
57
+ [requestId, DAEMON_NODE_ID, `child_${requestId}`, NET, Date.now(), DAEMON_TOKEN_ID],
58
+ );
59
+ }
60
+
61
+ async function connectClient() {
62
+ const server = new McpServer({ name: "ack-transport-test", version: "1" });
63
+ registerTools(
64
+ server,
65
+ undefined,
66
+ NET,
67
+ DAEMON_USER,
68
+ DAEMON_ALIAS,
69
+ true,
70
+ DAEMON_TOKEN_ID,
71
+ );
72
+ const client = new Client({ name: "ack-transport-client", version: "1" });
73
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
74
+ await server.connect(serverTransport);
75
+ await client.connect(clientTransport);
76
+ return { client, server };
77
+ }
78
+
79
+ type ToolResult = { content: Array<{ type: string; text?: string }>; isError?: boolean };
80
+
81
+ function responseJson(result: ToolResult) {
82
+ const first = result.content[0];
83
+ if (!first || first.type !== "text" || typeof first.text !== "string") throw new Error("missing text tool result");
84
+ return JSON.parse(first.text) as { ok?: boolean; status?: string };
85
+ }
86
+
87
+ function expectTransportValidationError(result: ToolResult) {
88
+ expect(result.isError).toBe(true);
89
+ const first = result.content[0];
90
+ expect(first?.type).toBe("text");
91
+ expect(first?.text).toMatch(/Input validation error.*ack_create_request/i);
92
+ }
93
+
94
+ beforeEach(() => {
95
+ cleanup();
96
+ seedWorld();
97
+ });
98
+ afterAll(cleanup);
99
+
100
+ describe("#344 ack_create_request real in-process MCP transport", () => {
101
+ test("accepts runtime_capability_check_failed plus string runtime through the SDK zod gate", async () => {
102
+ const requestId = "cr_transport_accept";
103
+ seedRequest(requestId);
104
+ const { client, server } = await connectClient();
105
+ try {
106
+ const result = await client.callTool({
107
+ name: "ack_create_request",
108
+ arguments: {
109
+ request_id: requestId,
110
+ status: "runtime_capability_check_failed",
111
+ error: "child died within 5000ms post-spawn",
112
+ runtime: "codex-sdk",
113
+ },
114
+ });
115
+ expect(responseJson(result)).toMatchObject({ ok: true, status: "runtime_capability_check_failed" });
116
+ expect(db.get<{ status: string }>("SELECT status FROM node_create_requests WHERE request_id = ?1", requestId)?.status)
117
+ .toBe("runtime_capability_check_failed");
118
+ } finally {
119
+ await client.close();
120
+ await server.close();
121
+ }
122
+ });
123
+
124
+ test("rejects unknown status at the transport gate before the handler mutates the row", async () => {
125
+ const requestId = "cr_transport_bad_status";
126
+ seedRequest(requestId);
127
+ const { client, server } = await connectClient();
128
+ try {
129
+ const result = await client.callTool({
130
+ name: "ack_create_request",
131
+ arguments: { request_id: requestId, status: "capability_failed_typo", runtime: "codex-sdk" },
132
+ });
133
+ expectTransportValidationError(result);
134
+ expect(db.get<{ status: string }>("SELECT status FROM node_create_requests WHERE request_id = ?1", requestId)?.status)
135
+ .toBe("delivered");
136
+ } finally {
137
+ await client.close();
138
+ await server.close();
139
+ }
140
+ });
141
+
142
+ test("rejects non-string runtime at the transport gate before the handler mutates the row", async () => {
143
+ const requestId = "cr_transport_bad_runtime";
144
+ seedRequest(requestId);
145
+ const { client, server } = await connectClient();
146
+ try {
147
+ const result = await client.callTool({
148
+ name: "ack_create_request",
149
+ arguments: { request_id: requestId, status: "runtime_capability_check_failed", runtime: 42 },
150
+ });
151
+ expectTransportValidationError(result);
152
+ expect(db.get<{ status: string }>("SELECT status FROM node_create_requests WHERE request_id = ?1", requestId)?.status)
153
+ .toBe("delivered");
154
+ } finally {
155
+ await client.close();
156
+ await server.close();
157
+ }
158
+ });
159
+ });
@@ -13,11 +13,11 @@ import { registerTools } from "./tools.js";
13
13
  // `daemon_capability_lied` audit was written when in fact the action wasn't
14
14
  // in the audit enum and no insert ran.
15
15
  //
16
- // This handler-driven suite drives the registered MCP tool through
17
- // registerTools() so a daemon's exact ack shape (status + runtime field)
18
- // flows through the real zod schema + handler branch + audit insert. It
19
- // would have caught the lane-recurring "single component passes but cross-
20
- // component path stays broken" failure mode immediately.
16
+ // This handler-driven suite captures and invokes the registered callback
17
+ // directly. It validates handler branches and DB effects, but deliberately
18
+ // does NOT claim to exercise the MCP SDK's zod transport gate. The
19
+ // complementary in-process transport coverage lives in
20
+ // ack-create-request-transport.test.ts (#344).
21
21
 
22
22
  const NET = "net_ack_t";
23
23
  const DAEMON_NODE_ID = "node_ack_daemon";
@@ -0,0 +1,84 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { NETWORK_REST_COLUMNS } from "./rest-projections.js";
6
+
7
+ const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-admin-networks-"));
8
+ process.env.COMMHUB_DB ||= join(PRIVATE_DB_DIR, "hub.db");
9
+
10
+ let server: ReturnType<typeof Bun.serve>;
11
+ let base = "";
12
+ let adminToken = "";
13
+ let memberToken = "";
14
+ let nodeToken = "";
15
+ let adminNetworkId = "";
16
+ let memberNetworkId = "";
17
+
18
+ async function listNetworks(token: string): Promise<any[]> {
19
+ const response = await fetch(`${base}/api/networks`, {
20
+ headers: { Authorization: `Bearer ${token}` },
21
+ });
22
+ expect(response.status).toBe(200);
23
+ const body = await response.json() as any;
24
+ expect(body.ok).toBe(true);
25
+ return body.networks;
26
+ }
27
+
28
+ beforeAll(async () => {
29
+ const { db } = await import("./db.js");
30
+ const { createNetworkTokenForNode, register } = await import("./auth.js");
31
+
32
+ // The first registered user is the global Hub admin. The second user owns a
33
+ // separate network and is intentionally not a member of the admin's network.
34
+ const admin = register(`admin_networks_${Date.now()}`, "AdminNetworks-Strong-1!", undefined, "admin");
35
+ expect(admin.ok).toBe(true);
36
+ adminToken = admin.token!;
37
+ adminNetworkId = admin.network_id!;
38
+
39
+ const member = register(`member_networks_${Date.now()}`, "MemberNetworks-Strong-1!", undefined, "member");
40
+ expect(member.ok).toBe(true);
41
+ memberToken = member.token!;
42
+ memberNetworkId = member.network_id!;
43
+
44
+ const memberUserId = db.get<{ user_id: string }>(
45
+ "SELECT user_id FROM users WHERE username LIKE 'member_networks_%' ORDER BY created_at DESC LIMIT 1",
46
+ )!.user_id;
47
+ const minted = createNetworkTokenForNode(memberUserId, memberNetworkId, "admin-networks-node");
48
+ expect(minted.ok).toBe(true);
49
+ nodeToken = minted.token!;
50
+
51
+ const mod = await import("./server.js");
52
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
53
+ base = `http://127.0.0.1:${server.port}`;
54
+ });
55
+
56
+ afterAll(() => {
57
+ try { server?.stop(true); } catch {}
58
+ try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
59
+ });
60
+
61
+ describe("GET /api/networks global-admin visibility (#94)", () => {
62
+ test("global admin utok sees every network without losing public shape", async () => {
63
+ const rows = await listNetworks(adminToken);
64
+ expect(new Set(rows.map((row) => row.network_id))).toEqual(new Set([adminNetworkId, memberNetworkId]));
65
+
66
+ const own = rows.find((row) => row.network_id === adminNetworkId);
67
+ const foreign = rows.find((row) => row.network_id === memberNetworkId);
68
+ expect(own.member_role).toBe("owner");
69
+ expect(foreign.member_role).toBe("admin");
70
+ expect(Object.keys(foreign).sort()).toEqual([...NETWORK_REST_COLUMNS, "member_role", "name"].sort());
71
+ });
72
+
73
+ test("ordinary utok remains limited to member networks", async () => {
74
+ const rows = await listNetworks(memberToken);
75
+ expect(rows.map((row) => row.network_id)).toEqual([memberNetworkId]);
76
+ expect(rows.some((row) => row.network_id === adminNetworkId)).toBe(false);
77
+ });
78
+
79
+ test("network token remains limited to its bound network", async () => {
80
+ const rows = await listNetworks(nodeToken);
81
+ expect(rows.map((row) => row.network_id)).toEqual([memberNetworkId]);
82
+ expect(rows.some((row) => row.network_id === adminNetworkId)).toBe(false);
83
+ });
84
+ });
@@ -20,13 +20,10 @@
20
20
  // server on an ephemeral port with a temp DB, hit it with fetch().
21
21
 
22
22
  import { describe, expect, test, beforeAll, afterAll } from "bun:test";
23
- import { mkdtempSync, rmSync } from "fs";
24
- import { join } from "path";
25
- import { tmpdir } from "os";
23
+ import "./require-explicit-test-db.js";
26
24
  import { register, login, createNetwork } from "./auth.js";
27
25
  import { db } from "./db.js";
28
26
 
29
- const SERVER_DB = mkdtempSync(join(tmpdir(), "anet-hs-fallback-db-")) + "/commhub.db";
30
27
  // #438 corrective: private bootServer({ port: 0 }) instance (see
31
28
  // uploads-http.test.ts for rationale).
32
29
  let BASE = "";
@@ -42,7 +39,6 @@ let multiNetworkA = "", multiNetworkB = "";
42
39
  let soloDaemonAlias = "";
43
40
 
44
41
  beforeAll(async () => {
45
- process.env.COMMHUB_DB = SERVER_DB;
46
42
  process.env.HOST = "127.0.0.1";
47
43
 
48
44
  const suffix = `${Date.now()}_${Math.floor(Math.random() * 1000)}`;
@@ -155,7 +151,6 @@ function seedHostSupervisorDaemon(opts: {
155
151
 
156
152
  afterAll(() => {
157
153
  try { server?.stop?.(true); } catch {}
158
- try { rmSync(SERVER_DB, { recursive: true, force: true }); } catch {}
159
154
  });
160
155
 
161
156
  async function get(path: string, token: string): Promise<{ status: number; body: any }> {
package/src/auth.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import { db, generateId, hashPassword, verifyPassword, hashToken, generateToken, generateUserToken, generateNetworkToken, uuidv4 } from "./db.js";
5
5
  import { WEAK_PASSWORDS } from "./password-dict.js";
6
+ import { NETWORK_REST_COLUMNS, NETWORK_REST_SELECT, sqlColumns } from "./rest-projections.js";
6
7
 
7
8
  // Round-6 A1 hardening — dummy hash for username-enumeration timing
8
9
  // close. We compute ONE scrypt hash of a throwaway password and reuse
@@ -87,11 +88,32 @@ export function register(username: string, password: string, email?: string, dis
87
88
  [userId, username, pwHash, email || null, displayName || username, isFirstUser ? "admin" : "user"]
88
89
  );
89
90
 
90
- // Auto-create default network + add as owner member
91
+ // Auto-create the user's own network + add as owner member.
92
+ //
93
+ // Name it after the user rather than the literal "default". Every
94
+ // registration used to mint a network called "default", so a hub with N
95
+ // users showed N identical "default" rows in the Dashboard sidebar with
96
+ // no way to tell them apart (23 of them on the live hub, 22 empty).
97
+ // Three CLI call sites (bin/cli.ts debate / demo-social / pr-review) resolve
98
+ // a network as:
99
+ // me.user.default_network_id
100
+ // || networks.find(n => n.network_name === "default")
101
+ // || networks[0]
102
+ // Checked against the live hub: GET /api/auth/me returns
103
+ // {user_id, username, display_name, email, role} — there is no
104
+ // default_network_id field, and nothing in server/ ever writes one. So the
105
+ // first term is always undefined and the name lookup is the operative path,
106
+ // not a fallback.
107
+ //
108
+ // After this change that lookup misses for new users and they land on
109
+ // networks[0]. That still resolves to this network because listNetworks()
110
+ // is `ORDER BY created_at` ascending (auth.ts:296) and this row is the
111
+ // user's oldest. Reordering that query would silently point those three
112
+ // commands at the wrong network.
91
113
  const networkId = generateId("net");
92
114
  db.run(
93
115
  "INSERT INTO networks (network_id, network_name, owner_id, description) VALUES (?1, ?2, ?3, ?4)",
94
- [networkId, "default", userId, "Auto-created default network"]
116
+ [networkId, username, userId, `Auto-created network for ${username}`]
95
117
  );
96
118
  db.run(
97
119
  "INSERT INTO network_members (network_id, user_id, role) VALUES (?1, ?2, 'owner')",
@@ -191,18 +213,67 @@ export function login(username: string, password: string): AuthResult {
191
213
  };
192
214
  }
193
215
 
194
- /** Create a network-scoped token (ntok_) for a specific node */
195
- export function createNetworkTokenForNode(userId: string, networkId: string, nodeName: string): { ok: boolean; token?: string; error?: string } {
216
+ /**
217
+ * Create a network-scoped token (ntok_) for a specific node.
218
+ *
219
+ * RFC-036: callers that provide nodeId atomically establish the immutable
220
+ * node-owner binding before the plaintext token is returned. The 3-argument
221
+ * legacy form remains available for old clients, but its token has no bound
222
+ * node id and therefore cannot consume external-schedule edit intents.
223
+ */
224
+ export function createNetworkTokenForNode(userId: string, networkId: string, nodeName: string, nodeId?: string): { ok: boolean; token?: string; token_id?: string; node_id?: string; error?: string } {
196
225
  // Verify user is a member of this network with write access
197
226
  const role = getUserNetworkRole(userId, networkId);
198
227
  if (!role || role === "viewer") return { ok: false, error: "no write access to this network" };
228
+ if (!nodeName || nodeName.length > 200) return { ok: false, error: "invalid_node_name" };
229
+ if (nodeId !== undefined && (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(nodeId))) {
230
+ return { ok: false, error: "invalid_node_id" };
231
+ }
199
232
  const token = generateNetworkToken();
200
233
  const tokenId = generateId("tok");
201
- db.run(
202
- "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
203
- [tokenId, hashToken(token), userId, networkId, `node:${nodeName}`, "network"]
204
- );
205
- return { ok: true, token };
234
+ try {
235
+ db.transaction(() => {
236
+ if (nodeId) {
237
+ const existing = db.get<{ network_id: string | null; owner_user_id: string | null }>(
238
+ "SELECT network_id, owner_user_id FROM nodes WHERE node_id = ?1",
239
+ nodeId,
240
+ );
241
+ if (existing?.network_id && existing.network_id !== networkId) throw new Error("cross_network_node");
242
+ // Existing pre-RFC-036 rows have no trustworthy owner anchor. A token
243
+ // refresh must not turn knowledge of a legacy node_id into ownership.
244
+ // They remain read-only until a separately authorized migration.
245
+ if (existing && !existing.owner_user_id) throw new Error("node_owner_unclaimed");
246
+ if (existing?.owner_user_id && existing.owner_user_id !== userId) throw new Error("node_owner_mismatch");
247
+ db.run(
248
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, updated_at)
249
+ VALUES (?1, ?2, ?2, ?3, ?4, datetime('now'))
250
+ ON CONFLICT(node_id) DO UPDATE SET
251
+ node_name = COALESCE(nodes.node_name, ?2),
252
+ alias = COALESCE(nodes.alias, ?2),
253
+ network_id = COALESCE(nodes.network_id, ?3),
254
+ owner_user_id = COALESCE(nodes.owner_user_id, ?4),
255
+ updated_at = datetime('now')`,
256
+ [nodeId, nodeName, networkId, userId],
257
+ );
258
+ if (!existing) {
259
+ db.run(
260
+ `INSERT INTO audit_log (user_id, action, target_type, target_id, detail, network_id)
261
+ VALUES (?1, 'external_schedule.owner_claimed', 'node', ?2, ?3, ?4)`,
262
+ [userId, nodeId, JSON.stringify({ node_id: nodeId, network_id: networkId }), networkId],
263
+ );
264
+ }
265
+ }
266
+ db.run(
267
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, bound_node_id) VALUES (?1, ?2, ?3, ?4, ?5, 'network', ?6)",
268
+ [tokenId, hashToken(token), userId, networkId, `node:${nodeName}`, nodeId ?? null],
269
+ );
270
+ });
271
+ } catch (error: any) {
272
+ const code = String(error?.message || "node_token_create_failed");
273
+ if (code === "cross_network_node" || code === "node_owner_unclaimed" || code === "node_owner_mismatch") return { ok: false, error: code };
274
+ throw error;
275
+ }
276
+ return { ok: true, token, token_id: tokenId, ...(nodeId ? { node_id: nodeId } : {}) };
206
277
  }
207
278
 
208
279
  export function resolveToken(token: string): { user: AuthUser; networkId: string | null; tokenName: string | null; tokenId: string | null } | null {
@@ -235,7 +306,7 @@ export function resolveToken(token: string): { user: AuthUser; networkId: string
235
306
 
236
307
  export function getUserNetworks(userId: string) {
237
308
  return db.all<any>(
238
- "SELECT * FROM networks WHERE owner_id = ?1 ORDER BY created_at",
309
+ `SELECT ${NETWORK_REST_SELECT} FROM networks WHERE owner_id = ?1 ORDER BY created_at`,
239
310
  userId);
240
311
  }
241
312
 
@@ -280,7 +351,7 @@ export function listTokens(userId: string) {
280
351
  }
281
352
 
282
353
  export function renameNetwork(userId: string, networkId: string, newName: string): { ok: boolean; error?: string } {
283
- const net = db.get<any>("SELECT * FROM networks WHERE network_id = ?1", networkId);
354
+ const net = db.get<any>("SELECT owner_id FROM networks WHERE network_id = ?1", networkId);
284
355
  if (!net) return { ok: false, error: "network not found" };
285
356
  if (net.owner_id !== userId) return { ok: false, error: "not your network" };
286
357
  const dup = db.get<any>("SELECT network_id FROM networks WHERE owner_id = ?1 AND network_name = ?2", userId, newName);
@@ -290,7 +361,7 @@ export function renameNetwork(userId: string, networkId: string, newName: string
290
361
  }
291
362
 
292
363
  export function deleteNetwork(userId: string, networkId: string): { ok: boolean; error?: string } {
293
- const net = db.get<any>("SELECT * FROM networks WHERE network_id = ?1", networkId);
364
+ const net = db.get<any>("SELECT owner_id FROM networks WHERE network_id = ?1", networkId);
294
365
  if (!net) return { ok: false, error: "network not found" };
295
366
  if (net.owner_id !== userId) return { ok: false, error: "not your network" };
296
367
  // Check if any sessions/tasks still reference this network
@@ -449,7 +520,10 @@ export function createInvite(networkId: string, createdBy: string, role: string
449
520
  }
450
521
 
451
522
  export function joinByInvite(inviteCode: string, userId: string): { ok: boolean; network_id?: string; role?: string; error?: string } {
452
- const invite = db.get<any>("SELECT * FROM network_invites WHERE invite_code = ?1", inviteCode);
523
+ const invite = db.get<any>(
524
+ "SELECT invite_code, network_id, role, created_by, max_uses, used_count, expires_at, created_at FROM network_invites WHERE invite_code = ?1",
525
+ inviteCode,
526
+ );
453
527
  if (!invite) return { ok: false, error: "invalid invite code" };
454
528
  if (invite.max_uses > 0 && invite.used_count >= invite.max_uses) return { ok: false, error: "invite code fully used" };
455
529
  if (invite.expires_at) {
@@ -474,7 +548,7 @@ export function joinByInvite(inviteCode: string, userId: string): { ok: boolean;
474
548
  /** Get all networks a user is a member of (replaces owner-only query) */
475
549
  export function getUserAllNetworks(userId: string) {
476
550
  return db.all<any>(
477
- `SELECT n.*, nm.role as member_role
551
+ `SELECT ${sqlColumns(NETWORK_REST_COLUMNS, "n")}, nm.role as member_role
478
552
  FROM networks n JOIN network_members nm ON n.network_id = nm.network_id
479
553
  WHERE nm.user_id = ?1 ORDER BY nm.role = 'owner' DESC, n.created_at`,
480
554
  userId);
@@ -0,0 +1,103 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "fs";
3
+ import { join } from "path";
4
+ import { tmpdir } from "os";
5
+ import { isInteractiveDashboardTask } from "../../agent-node/src/inbox-dispatch";
6
+ import { shouldCreateScheduledGoal } from "../../agent-node/src/goals/routing";
7
+ import { createNetworkTokenForNode, register } from "./auth.js";
8
+ import { db } from "./db.js";
9
+
10
+ const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-dashboard-slash-http-"));
11
+ const TARGET = `dashboard-slash-target-${process.pid}`;
12
+ const TARGET_NODE_ID = `node_dashboard_slash_target_${process.pid}`;
13
+ const TARGET_RESUME_ID = `resume_dashboard_slash_target_${process.pid}`;
14
+ let server: any;
15
+ let base = "";
16
+ let userToken = "";
17
+ let nodeToken = "";
18
+ let networkId = "";
19
+ let requestSequence = 0;
20
+
21
+ beforeAll(async () => {
22
+ process.env.COMMHUB_DB = process.env.COMMHUB_DB || join(PRIVATE_DB_DIR, "hub.db");
23
+ const username = `dashboard_slash_${Date.now()}`;
24
+ const registered = register(username, "DashboardSlash123!", undefined, "seed");
25
+ expect(registered.ok).toBe(true);
26
+ userToken = registered.token!;
27
+ networkId = registered.network_id!;
28
+ const userId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", [username])!.user_id;
29
+ const minted = createNetworkTokenForNode(userId, networkId, "dashboard-slash-node");
30
+ expect(minted.ok).toBe(true);
31
+ nodeToken = minted.token!;
32
+ db.run(
33
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
34
+ VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
35
+ [TARGET_RESUME_ID, TARGET, TARGET_NODE_ID, networkId],
36
+ );
37
+ db.run(
38
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at, lifecycle_state)
39
+ VALUES (?1, ?2, ?2, ?3, datetime('now'), datetime('now'), 'active')`,
40
+ [TARGET_NODE_ID, TARGET, networkId],
41
+ );
42
+ const mod: any = await import("./server.js");
43
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
44
+ base = `http://127.0.0.1:${server.port}`;
45
+ });
46
+
47
+ afterAll(() => {
48
+ try { server?.stop?.(true); } catch {}
49
+ try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
50
+ });
51
+
52
+ async function postAndRead(token: string, from: string, task: string) {
53
+ requestSequence += 1;
54
+ const clientRequestId = `dreq_${requestSequence.toString(16).padStart(32, "0")}`;
55
+ const response = await fetch(`${base}/api/task`, {
56
+ method: "POST",
57
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
58
+ body: JSON.stringify({
59
+ alias: TARGET,
60
+ from,
61
+ network_id: networkId,
62
+ task,
63
+ meta: { source: "dashboard-chat", client_request_id: clientRequestId, auth_origin: "user" },
64
+ }),
65
+ });
66
+ const body = await response.json() as any;
67
+ expect([200, 202]).toContain(response.status);
68
+ const taskId = body.task_id ?? body.message_id;
69
+ const row = db.get<{ content: string; meta_json: string }>(
70
+ "SELECT content, meta_json FROM inbox WHERE id = ?1",
71
+ [taskId],
72
+ );
73
+ expect(row).toBeTruthy();
74
+ return { type: "task", content: row!.content, meta_json: row!.meta_json };
75
+ }
76
+
77
+ describe("real Hub → authenticated Dashboard slash routing", () => {
78
+ test("Dashboard /goal and /loop remain exact native payloads for every runtime", async () => {
79
+ for (const command of ["/goal 更新一下文档", "/loop 5m native loop"] as const) {
80
+ const row = await postAndRead(userToken, "dashboard-user", command);
81
+ expect(row.content).toBe(command);
82
+ expect(isInteractiveDashboardTask(row)).toBe(true);
83
+ for (const runtime of ["codex-app-server", "codex", "claude", "grok", "opencode"] as const) {
84
+ expect(shouldCreateScheduledGoal(row.content, runtime, true)).toBe(false);
85
+ }
86
+ }
87
+ });
88
+
89
+ test("Dashboard /agoal and /aloop use the Agent Network scheduler", async () => {
90
+ for (const command of ["/agoal 5m update docs", "/aloop 5m update docs"] as const) {
91
+ const row = await postAndRead(userToken, "dashboard-user", command);
92
+ expect(isInteractiveDashboardTask(row)).toBe(true);
93
+ expect(shouldCreateScheduledGoal(row.content, "codex-app-server", true)).toBe(true);
94
+ }
95
+ });
96
+
97
+ test("node token cannot forge Dashboard pass-through and keeps legacy compatibility", async () => {
98
+ const row = await postAndRead(nodeToken, "dashboard-slash-node", "/loop 5m legacy automation");
99
+ expect(JSON.parse(row.meta_json).auth_origin).toBe("node");
100
+ expect(isInteractiveDashboardTask(row)).toBe(false);
101
+ expect(shouldCreateScheduledGoal(row.content, "codex-app-server", false)).toBe(true);
102
+ });
103
+ });