@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.
- package/bin/commhub.ts +4 -0
- package/package.json +4 -4
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +5 -5
- package/src/admin-networks-http.test.ts +84 -0
- package/src/api-host-supervisors-fallback.test.ts +1 -6
- package/src/auth.ts +88 -14
- package/src/dashboard-slash-routing-http.test.ts +103 -0
- package/src/db-adapter-guard.test.ts +85 -0
- package/src/db-adapter.ts +64 -23
- package/src/db.ts +279 -32
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +10 -17
- package/src/external-schedule-edits-http.test.ts +316 -0
- package/src/external-schedule-edits.ts +314 -0
- package/src/file-download-authz-dev-open.test.ts +1 -1
- package/src/file-download-authz.test.ts +21 -1
- package/src/file-download-date-bucket.test.ts +1 -1
- package/src/file-network-scope-dev-open.test.ts +1 -1
- package/src/file-network-scope.test.ts +1 -1
- package/src/health-redaction.test.ts +12 -0
- package/src/index.ts +6 -2
- package/src/network-name-http.test.ts +71 -0
- package/src/network-scope.ts +20 -0
- package/src/node-attrs.test.ts +1 -1
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/rest-explicit-columns-http.test.ts +149 -0
- package/src/rest-identity.test.ts +120 -0
- package/src/rest-identity.ts +93 -0
- package/src/rest-projections.ts +67 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-tasks-http.test.ts +460 -0
- package/src/scheduled-tasks.ts +538 -0
- package/src/send-reply-agent-warning.test.ts +38 -38
- package/src/server.ts +315 -60
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/skillhub-http.test.ts +88 -0
- package/src/task-auth-origin-http.test.ts +87 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +329 -0
- package/src/task-diagnostic-http.test.ts +145 -0
- package/src/task-diagnostic.test.ts +82 -0
- package/src/task-diagnostic.ts +89 -0
- package/src/task-idempotency-mcp.test.ts +137 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +160 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +137 -0
- package/src/tasks-pagination-http.test.ts +137 -0
- package/src/tools.ts +635 -107
- package/src/uploads-http.test.ts +1 -3
- 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.
|
|
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": "
|
|
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
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
/**
|
|
195
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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>(
|
|
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
|
|
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
|
+
});
|