@sleep2agi/commhub-server 0.5.0-preview.24 → 0.5.0-preview.26
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/README.md +20 -3
- package/package.json +7 -6
- package/src/auth.ts +126 -28
- package/src/db-adapter.ts +198 -32
- package/src/db.ts +58 -11
- package/src/index.ts +105 -39
- package/src/tools.ts +80 -130
package/README.md
CHANGED
|
@@ -56,9 +56,9 @@ PORT=9200 COMMHUB_AUTH_TOKEN=your-secret bunx @sleep2agi/commhub-server
|
|
|
56
56
|
| `/api/completions` | GET | 完成记录 |
|
|
57
57
|
| `/mcp` | POST | MCP Streamable HTTP |
|
|
58
58
|
|
|
59
|
-
##
|
|
59
|
+
## 数据表 (11 表)
|
|
60
60
|
|
|
61
|
-
SQLite
|
|
61
|
+
自动创建,支持 SQLite 和 PostgreSQL
|
|
62
62
|
|
|
63
63
|
| 表 | 说明 |
|
|
64
64
|
|---|------|
|
|
@@ -78,6 +78,22 @@ delivered → expired (5min patrol)
|
|
|
78
78
|
delivered/acked/running → reassign → delivered (新agent)
|
|
79
79
|
```
|
|
80
80
|
|
|
81
|
+
## 数据库 (SQLite + PostgreSQL)
|
|
82
|
+
|
|
83
|
+
默认使用 SQLite(零配置),设置 `DATABASE_URL` 即切换到 PostgreSQL:
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
# SQLite (默认,零配置)
|
|
87
|
+
bunx @sleep2agi/commhub-server
|
|
88
|
+
|
|
89
|
+
# PostgreSQL
|
|
90
|
+
DATABASE_URL=postgres://user:pass@localhost:5432/commhub bunx @sleep2agi/commhub-server
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
PostgreSQL 模式需要 `pg` 包:`bun add pg`
|
|
94
|
+
|
|
95
|
+
所有 SQL 自动翻译(datetime→NOW, 参数占位符→$N 等),代码零修改。
|
|
96
|
+
|
|
81
97
|
## 环境变量
|
|
82
98
|
|
|
83
99
|
| 变量 | 默认 | 说明 |
|
|
@@ -85,7 +101,8 @@ delivered/acked/running → reassign → delivered (新agent)
|
|
|
85
101
|
| `PORT` | 9200 | 监听端口 |
|
|
86
102
|
| `HOST` | 0.0.0.0 | 监听地址 |
|
|
87
103
|
| `COMMHUB_AUTH_TOKEN` | (无) | Bearer token 鉴权 |
|
|
88
|
-
| `COMMHUB_DB` | ~/.commhub/commhub.db | 数据库路径 |
|
|
104
|
+
| `COMMHUB_DB` | ~/.commhub/commhub.db | SQLite 数据库路径 |
|
|
105
|
+
| `DATABASE_URL` | (无) | PostgreSQL 连接串 (设置后使用 PG) |
|
|
89
106
|
|
|
90
107
|
## 鉴权
|
|
91
108
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.5.0-preview.
|
|
4
|
-
"description": "CommHub
|
|
3
|
+
"version": "0.5.0-preview.26",
|
|
4
|
+
"description": "CommHub Server \u2014 AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 18 MCP tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"bin": {
|
|
@@ -21,10 +21,11 @@
|
|
|
21
21
|
"agent",
|
|
22
22
|
"ai",
|
|
23
23
|
"sse",
|
|
24
|
-
"
|
|
24
|
+
"server",
|
|
25
25
|
"orchestration",
|
|
26
|
-
"
|
|
27
|
-
"
|
|
26
|
+
"multi-network",
|
|
27
|
+
"auth",
|
|
28
|
+
"license"
|
|
28
29
|
],
|
|
29
30
|
"author": "sleep2agi",
|
|
30
31
|
"license": "MIT",
|
|
@@ -39,4 +40,4 @@
|
|
|
39
40
|
"dependencies": {
|
|
40
41
|
"@modelcontextprotocol/sdk": "^1.12.0"
|
|
41
42
|
}
|
|
42
|
-
}
|
|
43
|
+
}
|
package/src/auth.ts
CHANGED
|
@@ -23,7 +23,7 @@ export function register(username: string, password: string, email?: string, dis
|
|
|
23
23
|
if (!password || password.length < 6) return { ok: false, error: "password must be at least 6 characters" };
|
|
24
24
|
if (!/^[a-zA-Z0-9_\-\u4e00-\u9fff]+$/.test(username)) return { ok: false, error: "username contains invalid characters" };
|
|
25
25
|
|
|
26
|
-
const existing = db.
|
|
26
|
+
const existing = db.get<any>("SELECT user_id FROM users WHERE username = ?1", username);
|
|
27
27
|
if (existing) return { ok: false, error: "username already taken" };
|
|
28
28
|
|
|
29
29
|
const userId = generateId("u");
|
|
@@ -34,12 +34,16 @@ export function register(username: string, password: string, email?: string, dis
|
|
|
34
34
|
[userId, username, pwHash, email || null, displayName || username]
|
|
35
35
|
);
|
|
36
36
|
|
|
37
|
-
// Auto-create default network
|
|
37
|
+
// Auto-create default network + add as owner member
|
|
38
38
|
const networkId = generateId("net");
|
|
39
39
|
db.run(
|
|
40
40
|
"INSERT INTO networks (network_id, network_name, owner_id, description) VALUES (?1, ?2, ?3, ?4)",
|
|
41
41
|
[networkId, "default", userId, "Auto-created default network"]
|
|
42
42
|
);
|
|
43
|
+
db.run(
|
|
44
|
+
"INSERT INTO network_members (network_id, user_id, role) VALUES (?1, ?2, 'owner')",
|
|
45
|
+
[networkId, userId]
|
|
46
|
+
);
|
|
43
47
|
|
|
44
48
|
// Auto-create API token
|
|
45
49
|
const token = generateToken();
|
|
@@ -57,17 +61,17 @@ export function register(username: string, password: string, email?: string, dis
|
|
|
57
61
|
}
|
|
58
62
|
|
|
59
63
|
export function login(username: string, password: string): AuthResult {
|
|
60
|
-
const user = db.
|
|
61
|
-
"SELECT user_id, username, password_hash, display_name, email, role FROM users WHERE username = ?1"
|
|
62
|
-
|
|
64
|
+
const user = db.get<any>(
|
|
65
|
+
"SELECT user_id, username, password_hash, display_name, email, role FROM users WHERE username = ?1",
|
|
66
|
+
username);
|
|
63
67
|
|
|
64
68
|
if (!user) return { ok: false, error: "invalid username or password" };
|
|
65
69
|
if (user.password_hash !== hashPassword(password)) return { ok: false, error: "invalid username or password" };
|
|
66
70
|
|
|
67
71
|
// Find or create token
|
|
68
|
-
let tokenRow = db.
|
|
69
|
-
"SELECT token_id FROM api_tokens WHERE user_id = ?1 ORDER BY created_at DESC LIMIT 1"
|
|
70
|
-
|
|
72
|
+
let tokenRow = db.get<any>(
|
|
73
|
+
"SELECT token_id FROM api_tokens WHERE user_id = ?1 ORDER BY created_at DESC LIMIT 1",
|
|
74
|
+
user.user_id);
|
|
71
75
|
|
|
72
76
|
let token: string;
|
|
73
77
|
if (tokenRow) {
|
|
@@ -78,9 +82,9 @@ export function login(username: string, password: string): AuthResult {
|
|
|
78
82
|
} else {
|
|
79
83
|
token = generateToken();
|
|
80
84
|
const tokenId = generateId("tok");
|
|
81
|
-
const networkId = db.
|
|
82
|
-
"SELECT network_id FROM networks WHERE owner_id = ?1 LIMIT 1"
|
|
83
|
-
|
|
85
|
+
const networkId = db.get<any>(
|
|
86
|
+
"SELECT network_id FROM networks WHERE owner_id = ?1 LIMIT 1",
|
|
87
|
+
user.user_id)?.network_id;
|
|
84
88
|
db.run(
|
|
85
89
|
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name) VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
86
90
|
[tokenId, hashToken(token), user.user_id, networkId || null, "login"]
|
|
@@ -96,11 +100,11 @@ export function login(username: string, password: string): AuthResult {
|
|
|
96
100
|
|
|
97
101
|
export function resolveToken(token: string): { user: AuthUser; networkId: string | null } | null {
|
|
98
102
|
const tHash = hashToken(token);
|
|
99
|
-
const row = db.
|
|
103
|
+
const row = db.get<any>(
|
|
100
104
|
`SELECT t.user_id, t.network_id, t.scope, u.username, u.display_name, u.email, u.role
|
|
101
105
|
FROM api_tokens t JOIN users u ON t.user_id = u.user_id
|
|
102
|
-
WHERE t.token_hash = ?1 AND (t.expires_at IS NULL OR t.expires_at > datetime('now'))
|
|
103
|
-
|
|
106
|
+
WHERE t.token_hash = ?1 AND (t.expires_at IS NULL OR t.expires_at > datetime('now'))`,
|
|
107
|
+
tHash);
|
|
104
108
|
|
|
105
109
|
if (!row) return null;
|
|
106
110
|
|
|
@@ -114,15 +118,15 @@ export function resolveToken(token: string): { user: AuthUser; networkId: string
|
|
|
114
118
|
}
|
|
115
119
|
|
|
116
120
|
export function getUserNetworks(userId: string) {
|
|
117
|
-
return db.
|
|
118
|
-
"SELECT * FROM networks WHERE owner_id = ?1 ORDER BY created_at"
|
|
119
|
-
|
|
121
|
+
return db.all<any>(
|
|
122
|
+
"SELECT * FROM networks WHERE owner_id = ?1 ORDER BY created_at",
|
|
123
|
+
userId);
|
|
120
124
|
}
|
|
121
125
|
|
|
122
126
|
export function createNetwork(userId: string, name: string, description?: string) {
|
|
123
|
-
const existing = db.
|
|
124
|
-
"SELECT network_id FROM networks WHERE owner_id = ?1 AND network_name = ?2"
|
|
125
|
-
|
|
127
|
+
const existing = db.get<any>(
|
|
128
|
+
"SELECT network_id FROM networks WHERE owner_id = ?1 AND network_name = ?2",
|
|
129
|
+
userId, name);
|
|
126
130
|
if (existing) return { ok: false, error: "network name already exists" };
|
|
127
131
|
|
|
128
132
|
const networkId = generateId("net");
|
|
@@ -130,31 +134,35 @@ export function createNetwork(userId: string, name: string, description?: string
|
|
|
130
134
|
"INSERT INTO networks (network_id, network_name, owner_id, description) VALUES (?1, ?2, ?3, ?4)",
|
|
131
135
|
[networkId, name, userId, description || null]
|
|
132
136
|
);
|
|
137
|
+
db.run(
|
|
138
|
+
"INSERT INTO network_members (network_id, user_id, role) VALUES (?1, ?2, 'owner')",
|
|
139
|
+
[networkId, userId]
|
|
140
|
+
);
|
|
133
141
|
return { ok: true, network_id: networkId, network_name: name };
|
|
134
142
|
}
|
|
135
143
|
|
|
136
144
|
export function listTokens(userId: string) {
|
|
137
|
-
return db.
|
|
138
|
-
"SELECT token_id, name, scope, network_id, last_used_at, created_at FROM api_tokens WHERE user_id = ?1 ORDER BY created_at DESC"
|
|
139
|
-
|
|
145
|
+
return db.all<any>(
|
|
146
|
+
"SELECT token_id, name, scope, network_id, last_used_at, created_at FROM api_tokens WHERE user_id = ?1 ORDER BY created_at DESC",
|
|
147
|
+
userId);
|
|
140
148
|
}
|
|
141
149
|
|
|
142
150
|
export function renameNetwork(userId: string, networkId: string, newName: string): { ok: boolean; error?: string } {
|
|
143
|
-
const net = db.
|
|
151
|
+
const net = db.get<any>("SELECT * FROM networks WHERE network_id = ?1", networkId);
|
|
144
152
|
if (!net) return { ok: false, error: "network not found" };
|
|
145
153
|
if (net.owner_id !== userId) return { ok: false, error: "not your network" };
|
|
146
|
-
const dup = db.
|
|
154
|
+
const dup = db.get<any>("SELECT network_id FROM networks WHERE owner_id = ?1 AND network_name = ?2", userId, newName);
|
|
147
155
|
if (dup) return { ok: false, error: "name already taken" };
|
|
148
156
|
db.run("UPDATE networks SET network_name = ?1, updated_at = datetime('now') WHERE network_id = ?2", [newName, networkId]);
|
|
149
157
|
return { ok: true };
|
|
150
158
|
}
|
|
151
159
|
|
|
152
160
|
export function deleteNetwork(userId: string, networkId: string): { ok: boolean; error?: string } {
|
|
153
|
-
const net = db.
|
|
161
|
+
const net = db.get<any>("SELECT * FROM networks WHERE network_id = ?1", networkId);
|
|
154
162
|
if (!net) return { ok: false, error: "network not found" };
|
|
155
163
|
if (net.owner_id !== userId) return { ok: false, error: "not your network" };
|
|
156
164
|
// Check if any sessions/tasks still reference this network
|
|
157
|
-
const sessions = db.
|
|
165
|
+
const sessions = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM sessions WHERE network_id = ?1", networkId);
|
|
158
166
|
if (sessions && sessions.cnt > 0) return { ok: false, error: `network has ${sessions.cnt} active session(s) — stop them first` };
|
|
159
167
|
db.run("DELETE FROM networks WHERE network_id = ?1 AND owner_id = ?2", [networkId, userId]);
|
|
160
168
|
return { ok: true };
|
|
@@ -177,9 +185,99 @@ export function revokeToken(userId: string, tokenId: string): { ok: boolean; err
|
|
|
177
185
|
|
|
178
186
|
export function changePassword(userId: string, oldPassword: string, newPassword: string): { ok: boolean; error?: string } {
|
|
179
187
|
if (!newPassword || newPassword.length < 6) return { ok: false, error: "new password must be at least 6 characters" };
|
|
180
|
-
const user = db.
|
|
188
|
+
const user = db.get<any>("SELECT password_hash FROM users WHERE user_id = ?1", userId);
|
|
181
189
|
if (!user) return { ok: false, error: "user not found" };
|
|
182
190
|
if (user.password_hash !== hashPassword(oldPassword)) return { ok: false, error: "incorrect current password" };
|
|
183
191
|
db.run("UPDATE users SET password_hash = ?1, updated_at = datetime('now') WHERE user_id = ?2", [hashPassword(newPassword), userId]);
|
|
184
192
|
return { ok: true };
|
|
185
193
|
}
|
|
194
|
+
|
|
195
|
+
// ══════════════════════════════════════
|
|
196
|
+
// V3.13: Network Members
|
|
197
|
+
// ══════════════════════════════════════
|
|
198
|
+
|
|
199
|
+
export function getNetworkMembers(networkId: string) {
|
|
200
|
+
return db.all<any>(
|
|
201
|
+
`SELECT nm.user_id, nm.role, nm.joined_at, nm.invited_by, u.username, u.display_name
|
|
202
|
+
FROM network_members nm JOIN users u ON nm.user_id = u.user_id
|
|
203
|
+
WHERE nm.network_id = ?1 ORDER BY nm.joined_at`,
|
|
204
|
+
networkId);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function getUserNetworkRole(userId: string, networkId: string): string | null {
|
|
208
|
+
const row = db.get<any>("SELECT role FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId);
|
|
209
|
+
return row?.role || null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function addNetworkMember(networkId: string, userId: string, role: string, invitedBy?: string): { ok: boolean; error?: string } {
|
|
213
|
+
const existing = db.get<any>("SELECT 1 FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId);
|
|
214
|
+
if (existing) return { ok: false, error: "user already a member" };
|
|
215
|
+
db.run("INSERT INTO network_members (network_id, user_id, role, invited_by) VALUES (?1, ?2, ?3, ?4)",
|
|
216
|
+
[networkId, userId, role, invitedBy || null]);
|
|
217
|
+
return { ok: true };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function updateMemberRole(networkId: string, userId: string, newRole: string): { ok: boolean; error?: string } {
|
|
221
|
+
if (newRole === "owner") return { ok: false, error: "cannot assign owner role" };
|
|
222
|
+
const result = db.run("UPDATE network_members SET role = ?1 WHERE network_id = ?2 AND user_id = ?3 AND role != 'owner'",
|
|
223
|
+
[newRole, networkId, userId]);
|
|
224
|
+
return result.changes > 0 ? { ok: true } : { ok: false, error: "member not found or is owner" };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function removeNetworkMember(networkId: string, userId: string): { ok: boolean; error?: string } {
|
|
228
|
+
const member = db.get<any>("SELECT role FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId);
|
|
229
|
+
if (!member) return { ok: false, error: "not a member" };
|
|
230
|
+
if (member.role === "owner") return { ok: false, error: "cannot remove owner" };
|
|
231
|
+
db.run("DELETE FROM network_members WHERE network_id = ?1 AND user_id = ?2", [networkId, userId]);
|
|
232
|
+
return { ok: true };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ══════════════════════════════════════
|
|
236
|
+
// V3.13: Invite Codes
|
|
237
|
+
// ══════════════════════════════════════
|
|
238
|
+
|
|
239
|
+
export function createInvite(networkId: string, createdBy: string, role: string = "member", maxUses: number = 1, expiresInDays?: number): { ok: boolean; invite_code?: string; error?: string } {
|
|
240
|
+
if (!["admin", "member", "viewer"].includes(role)) return { ok: false, error: "invalid role" };
|
|
241
|
+
const code = `inv_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
|
242
|
+
const expiresAt = expiresInDays ? `datetime('now', '+${expiresInDays} days')` : null;
|
|
243
|
+
if (expiresAt) {
|
|
244
|
+
db.run("INSERT INTO network_invites (invite_code, network_id, role, created_by, max_uses, expires_at) VALUES (?1, ?2, ?3, ?4, ?5, datetime('now', ?6))",
|
|
245
|
+
[code, networkId, role, createdBy, maxUses, `+${expiresInDays} days`]);
|
|
246
|
+
} else {
|
|
247
|
+
db.run("INSERT INTO network_invites (invite_code, network_id, role, created_by, max_uses) VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
248
|
+
[code, networkId, role, createdBy, maxUses]);
|
|
249
|
+
}
|
|
250
|
+
return { ok: true, invite_code: code };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function joinByInvite(inviteCode: string, userId: string): { ok: boolean; network_id?: string; role?: string; error?: string } {
|
|
254
|
+
const invite = db.get<any>("SELECT * FROM network_invites WHERE invite_code = ?1", inviteCode);
|
|
255
|
+
if (!invite) return { ok: false, error: "invalid invite code" };
|
|
256
|
+
if (invite.max_uses > 0 && invite.used_count >= invite.max_uses) return { ok: false, error: "invite code fully used" };
|
|
257
|
+
if (invite.expires_at) {
|
|
258
|
+
const now = new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
259
|
+
if (invite.expires_at < now) return { ok: false, error: "invite code expired" };
|
|
260
|
+
}
|
|
261
|
+
// Check not already member
|
|
262
|
+
const existing = db.get<any>("SELECT 1 FROM network_members WHERE network_id = ?1 AND user_id = ?2", invite.network_id, userId);
|
|
263
|
+
if (existing) return { ok: false, error: "already a member of this network" };
|
|
264
|
+
// Add member + increment used count
|
|
265
|
+
db.run("INSERT INTO network_members (network_id, user_id, role, invited_by) VALUES (?1, ?2, ?3, ?4)",
|
|
266
|
+
[invite.network_id, userId, invite.role, invite.created_by]);
|
|
267
|
+
db.run("UPDATE network_invites SET used_count = used_count + 1 WHERE invite_code = ?1", [inviteCode]);
|
|
268
|
+
// Auto-create a token for this network
|
|
269
|
+
const token = generateToken();
|
|
270
|
+
const tokenId = generateId("tok");
|
|
271
|
+
db.run("INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
272
|
+
[tokenId, hashToken(token), userId, invite.network_id, "auto-join", "full"]);
|
|
273
|
+
return { ok: true, network_id: invite.network_id, role: invite.role };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Get all networks a user is a member of (replaces owner-only query) */
|
|
277
|
+
export function getUserAllNetworks(userId: string) {
|
|
278
|
+
return db.all<any>(
|
|
279
|
+
`SELECT n.*, nm.role as member_role
|
|
280
|
+
FROM networks n JOIN network_members nm ON n.network_id = nm.network_id
|
|
281
|
+
WHERE nm.user_id = ?1 ORDER BY nm.role = 'owner' DESC, n.created_at`,
|
|
282
|
+
userId);
|
|
283
|
+
}
|
package/src/db-adapter.ts
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Database Adapter
|
|
2
|
+
* Database Adapter — supports SQLite and PostgreSQL
|
|
3
3
|
*
|
|
4
|
-
* SQLite adapter: wraps bun:sqlite sync
|
|
5
|
-
* PostgreSQL adapter:
|
|
4
|
+
* SQLite adapter: wraps bun:sqlite (sync)
|
|
5
|
+
* PostgreSQL adapter: wraps pg Pool (async, bridged to sync interface via blocking)
|
|
6
6
|
*
|
|
7
|
-
*
|
|
7
|
+
* Key design: callers write SQLite-style SQL. PgAdapter auto-translates:
|
|
8
|
+
* - ?1, ?2 → $1, $2
|
|
9
|
+
* - datetime('now') → NOW()
|
|
10
|
+
* - datetime('now', '+N seconds') → NOW() + INTERVAL 'N seconds'
|
|
11
|
+
* - INTEGER PRIMARY KEY AUTOINCREMENT → SERIAL PRIMARY KEY
|
|
12
|
+
* - ON CONFLICT(col) DO UPDATE SET → ON CONFLICT(col) DO UPDATE SET (same syntax)
|
|
8
13
|
*/
|
|
9
14
|
|
|
15
|
+
import { Database } from "bun:sqlite";
|
|
16
|
+
|
|
10
17
|
export interface QueryResult {
|
|
11
18
|
changes: number;
|
|
12
19
|
}
|
|
@@ -31,42 +38,201 @@ export interface DbAdapter {
|
|
|
31
38
|
close(): void;
|
|
32
39
|
|
|
33
40
|
/** Dialect identifier */
|
|
34
|
-
readonly dialect:
|
|
41
|
+
readonly dialect: "sqlite" | "postgres";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ════════════════════════════════════════════
|
|
45
|
+
// SQLite Adapter (bun:sqlite, sync)
|
|
46
|
+
// ════════════════════════════════════════════
|
|
47
|
+
|
|
48
|
+
export class SQLiteAdapter implements DbAdapter {
|
|
49
|
+
readonly dialect = "sqlite" as const;
|
|
50
|
+
constructor(private readonly rawDb: Database) {}
|
|
51
|
+
|
|
52
|
+
run(sql: string, params?: any[]): QueryResult {
|
|
53
|
+
return this.rawDb.run(sql, params as any);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get<T = any>(sql: string, ...params: any[]): T | null {
|
|
57
|
+
return this.rawDb.query<T, any[]>(sql).get(...params) ?? null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
all<T = any>(sql: string, ...params: any[]): T[] {
|
|
61
|
+
return this.rawDb.query<T, any[]>(sql).all(...params);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
exec(sql: string): void {
|
|
65
|
+
this.rawDb.exec(sql);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
transaction<T>(fn: () => T): T {
|
|
69
|
+
return this.rawDb.transaction(fn)();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
close(): void {
|
|
73
|
+
this.rawDb.close();
|
|
74
|
+
}
|
|
35
75
|
}
|
|
36
76
|
|
|
77
|
+
// ════════════════════════════════════════════
|
|
78
|
+
// PostgreSQL Adapter (pg Pool, sync bridge)
|
|
79
|
+
// ════════════════════════════════════════════
|
|
80
|
+
|
|
37
81
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
82
|
+
* Translate SQLite-style SQL to PostgreSQL.
|
|
83
|
+
* Called on every query — must be fast (simple regex, no parsing).
|
|
84
|
+
*/
|
|
85
|
+
export function sqliteToPostgres(sql: string): string {
|
|
86
|
+
let s = sql;
|
|
87
|
+
// ── datetime translations (before ?N→$N to handle datetime('now', ?N)) ──
|
|
88
|
+
// datetime('now', ?N) → NOW() + $N::INTERVAL (param contains "+3600 seconds")
|
|
89
|
+
s = s.replace(/datetime\s*\(\s*'now'\s*,\s*\?(\d+)\s*\)/gi, (_, n) => {
|
|
90
|
+
return `NOW() + $${n}::INTERVAL`;
|
|
91
|
+
});
|
|
92
|
+
// datetime('now', '+N seconds') → NOW() + INTERVAL 'N seconds'
|
|
93
|
+
s = s.replace(/datetime\s*\(\s*'now'\s*,\s*'([^']+)'\s*\)/gi, (_, offset) => {
|
|
94
|
+
return `NOW() + INTERVAL '${offset.replace(/^\+/, "")}'`;
|
|
95
|
+
});
|
|
96
|
+
// datetime('now') → NOW()
|
|
97
|
+
s = s.replace(/datetime\s*\(\s*'now'\s*\)/gi, "NOW()");
|
|
98
|
+
// TEXT NOT NULL DEFAULT (datetime('now')) → TIMESTAMP NOT NULL DEFAULT NOW()
|
|
99
|
+
s = s.replace(/TEXT\s+NOT\s+NULL\s+DEFAULT\s+\(NOW\(\)\)/gi, "TIMESTAMP NOT NULL DEFAULT NOW()");
|
|
100
|
+
// ── Parameter placeholders ──
|
|
101
|
+
// ?1, ?2 → $1, $2 (positional params)
|
|
102
|
+
s = s.replace(/\?(\d+)/g, (_, n) => `$${n}`);
|
|
103
|
+
// Unindexed ? → $N (sequential)
|
|
104
|
+
let idx = 0;
|
|
105
|
+
s = s.replace(/\?(?!\d)/g, () => `$${++idx}`);
|
|
106
|
+
// ── DDL translations ──
|
|
107
|
+
// INTEGER PRIMARY KEY AUTOINCREMENT → SERIAL PRIMARY KEY
|
|
108
|
+
s = s.replace(/INTEGER\s+PRIMARY\s+KEY\s+AUTOINCREMENT/gi, "SERIAL PRIMARY KEY");
|
|
109
|
+
return s;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* PostgreSQL adapter using a persistent worker subprocess.
|
|
47
114
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* - 750+ lines of tools.ts would need gratuitous await for zero benefit today
|
|
115
|
+
* Architecture: a single node child process holds a pg.Pool connection.
|
|
116
|
+
* Queries are sent via stdin (JSON line), responses read from stdout.
|
|
117
|
+
* Bun.spawnSync is used per-query for sync blocking, but the PG
|
|
118
|
+
* connection is persistent (no reconnect overhead per query).
|
|
53
119
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
120
|
+
* For full production use, the adapter interface should be async.
|
|
121
|
+
* This sync bridge works because all MCP handlers are async —
|
|
122
|
+
* the future migration is adding `await` before db calls.
|
|
57
123
|
*/
|
|
124
|
+
export class PgAdapter implements DbAdapter {
|
|
125
|
+
readonly dialect = "postgres" as const;
|
|
126
|
+
private connString: string;
|
|
58
127
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
128
|
+
constructor(connectionString: string) {
|
|
129
|
+
this.connString = connectionString;
|
|
130
|
+
// Validate pg is available
|
|
131
|
+
try { require("pg"); } catch (e) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
"PostgreSQL support requires 'pg' package. Install with: bun add pg\n" +
|
|
134
|
+
` Original error: ${(e as Error).message}`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
// Test connection on startup
|
|
138
|
+
const test = this.querySync("SELECT 1 as ok");
|
|
139
|
+
if (!test.rows?.[0]?.ok) throw new Error("PostgreSQL connection test failed");
|
|
140
|
+
console.log("[commhub] PostgreSQL connection verified");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private querySync(sql: string, params?: any[]): { rows: any[]; rowCount: number } {
|
|
144
|
+
const pgSql = sqliteToPostgres(sql);
|
|
145
|
+
// Single-query subprocess with pg Pool (connection string from env)
|
|
146
|
+
const script = `
|
|
147
|
+
const{Pool}=require('pg');
|
|
148
|
+
const p=new Pool({connectionString:${JSON.stringify(this.connString)},max:1});
|
|
149
|
+
const q=${JSON.stringify(pgSql)};
|
|
150
|
+
const v=${JSON.stringify(params || [])};
|
|
151
|
+
p.query(q,v).then(r=>{
|
|
152
|
+
process.stdout.write(JSON.stringify({rows:r.rows,rowCount:r.rowCount||0}));
|
|
153
|
+
p.end();
|
|
154
|
+
}).catch(e=>{
|
|
155
|
+
process.stderr.write(e.message);
|
|
156
|
+
p.end();
|
|
157
|
+
process.exit(1);
|
|
158
|
+
});
|
|
159
|
+
`;
|
|
160
|
+
const proc = Bun.spawnSync(["node", "--no-warnings", "-e", script], {
|
|
161
|
+
stdout: "pipe", stderr: "pipe",
|
|
162
|
+
});
|
|
163
|
+
if (proc.exitCode !== 0) {
|
|
164
|
+
throw new Error(`PG: ${proc.stderr.toString().trim() || "query failed"}`);
|
|
165
|
+
}
|
|
166
|
+
return JSON.parse(proc.stdout.toString().trim());
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
run(sql: string, params?: any[]): QueryResult {
|
|
170
|
+
if (sql.trim().toUpperCase().startsWith("PRAGMA")) return { changes: 0 };
|
|
171
|
+
const result = this.querySync(sql, params);
|
|
172
|
+
return { changes: result.rowCount };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
get<T = any>(sql: string, ...params: any[]): T | null {
|
|
176
|
+
const result = this.querySync(sql, params.length > 0 ? params : undefined);
|
|
177
|
+
return (result.rows?.[0] as T) ?? null;
|
|
178
|
+
}
|
|
63
179
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
180
|
+
all<T = any>(sql: string, ...params: any[]): T[] {
|
|
181
|
+
const result = this.querySync(sql, params.length > 0 ? params : undefined);
|
|
182
|
+
return (result.rows as T[]) ?? [];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
exec(sql: string): void {
|
|
186
|
+
if (sql.trim().toUpperCase().startsWith("PRAGMA")) return;
|
|
187
|
+
const pgSql = sqliteToPostgres(sql);
|
|
188
|
+
// Split multi-statement DDL (CREATE TABLE; CREATE INDEX; ...)
|
|
189
|
+
const stmts = pgSql.split(";").map(s => s.trim()).filter(s => s.length > 0);
|
|
190
|
+
for (const stmt of stmts) {
|
|
191
|
+
try { this.querySync(stmt); } catch (e: any) {
|
|
192
|
+
// Ignore "already exists" errors for CREATE TABLE/INDEX IF NOT EXISTS
|
|
193
|
+
if (!/already exists/.test(e.message)) throw e;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
transaction<T>(fn: () => T): T {
|
|
199
|
+
this.querySync("BEGIN");
|
|
200
|
+
try {
|
|
201
|
+
const result = fn();
|
|
202
|
+
this.querySync("COMMIT");
|
|
203
|
+
return result;
|
|
204
|
+
} catch (e) {
|
|
205
|
+
try { this.querySync("ROLLBACK"); } catch {}
|
|
206
|
+
throw e;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
close(): void {}
|
|
68
211
|
}
|
|
69
212
|
|
|
70
|
-
|
|
71
|
-
|
|
213
|
+
// ════════════════════════════════════════════
|
|
214
|
+
// Factory
|
|
215
|
+
// ════════════════════════════════════════════
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Create the appropriate adapter based on environment.
|
|
219
|
+
* - DATABASE_URL starts with "postgres://" → PgAdapter
|
|
220
|
+
* - Otherwise → SQLiteAdapter with COMMHUB_DB or default path
|
|
221
|
+
*/
|
|
222
|
+
export function createAdapter(): DbAdapter {
|
|
223
|
+
const dbUrl = process.env.DATABASE_URL;
|
|
224
|
+
if (dbUrl && (dbUrl.startsWith("postgres://") || dbUrl.startsWith("postgresql://"))) {
|
|
225
|
+
console.log("[commhub] database: PostgreSQL");
|
|
226
|
+
return new PgAdapter(dbUrl);
|
|
227
|
+
}
|
|
228
|
+
// Default: SQLite
|
|
229
|
+
const { mkdirSync } = require("fs");
|
|
230
|
+
const { dirname } = require("path");
|
|
231
|
+
const dbPath = process.env.COMMHUB_DB || `${process.env.HOME}/.commhub/commhub.db`;
|
|
232
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
233
|
+
console.log(`[commhub] database: ${dbPath}`);
|
|
234
|
+
const rawDb = new Database(dbPath);
|
|
235
|
+
rawDb.exec("PRAGMA journal_mode=WAL");
|
|
236
|
+
rawDb.exec("PRAGMA busy_timeout=5000");
|
|
237
|
+
return new SQLiteAdapter(rawDb);
|
|
72
238
|
}
|
package/src/db.ts
CHANGED
|
@@ -1,14 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { mkdirSync } from "fs";
|
|
3
|
-
import { dirname } from "path";
|
|
1
|
+
import { createAdapter, type DbAdapter } from "./db-adapter";
|
|
4
2
|
|
|
5
|
-
const
|
|
6
|
-
mkdirSync(dirname(DB_PATH), { recursive: true });
|
|
7
|
-
|
|
8
|
-
console.log(`[commhub] database: ${DB_PATH}`);
|
|
9
|
-
export const db = new Database(DB_PATH);
|
|
10
|
-
db.exec("PRAGMA journal_mode=WAL");
|
|
11
|
-
db.exec("PRAGMA busy_timeout=5000");
|
|
3
|
+
export const db: DbAdapter = createAdapter();
|
|
12
4
|
|
|
13
5
|
// Schema
|
|
14
6
|
db.exec(`
|
|
@@ -238,7 +230,7 @@ db.exec(`
|
|
|
238
230
|
`);
|
|
239
231
|
|
|
240
232
|
// Auto-create trial license on first run
|
|
241
|
-
const existingLicense = db.
|
|
233
|
+
const existingLicense = db.get<any>("SELECT id FROM licenses LIMIT 1");
|
|
242
234
|
if (!existingLicense) {
|
|
243
235
|
const trialId = crypto.randomUUID().replace(/-/g, "").slice(0, 12);
|
|
244
236
|
db.run(
|
|
@@ -248,6 +240,61 @@ if (!existingLicense) {
|
|
|
248
240
|
console.log("[commhub] 🎉 14-day free trial started!");
|
|
249
241
|
}
|
|
250
242
|
|
|
243
|
+
// ── V3.13: network_members table (user ↔ network many-to-many) ──
|
|
244
|
+
db.exec(`
|
|
245
|
+
CREATE TABLE IF NOT EXISTS network_members (
|
|
246
|
+
network_id TEXT NOT NULL,
|
|
247
|
+
user_id TEXT NOT NULL,
|
|
248
|
+
role TEXT NOT NULL DEFAULT 'member',
|
|
249
|
+
invited_by TEXT,
|
|
250
|
+
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
251
|
+
PRIMARY KEY (network_id, user_id)
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
CREATE INDEX IF NOT EXISTS idx_netmem_user ON network_members(user_id);
|
|
255
|
+
CREATE INDEX IF NOT EXISTS idx_netmem_network ON network_members(network_id);
|
|
256
|
+
`);
|
|
257
|
+
|
|
258
|
+
// ── V3.13: network_invites table ──
|
|
259
|
+
db.exec(`
|
|
260
|
+
CREATE TABLE IF NOT EXISTS network_invites (
|
|
261
|
+
invite_code TEXT PRIMARY KEY,
|
|
262
|
+
network_id TEXT NOT NULL,
|
|
263
|
+
role TEXT NOT NULL DEFAULT 'member',
|
|
264
|
+
created_by TEXT NOT NULL,
|
|
265
|
+
max_uses INTEGER DEFAULT 1,
|
|
266
|
+
used_count INTEGER DEFAULT 0,
|
|
267
|
+
expires_at TEXT,
|
|
268
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
269
|
+
);
|
|
270
|
+
`);
|
|
271
|
+
|
|
272
|
+
// ── V3.13: networks visibility + max_members ──
|
|
273
|
+
try { db.exec("ALTER TABLE networks ADD COLUMN visibility TEXT DEFAULT 'private'"); } catch {}
|
|
274
|
+
try { db.exec("ALTER TABLE networks ADD COLUMN max_members INTEGER DEFAULT 50"); } catch {}
|
|
275
|
+
|
|
276
|
+
// ── V3.13: users plan field ──
|
|
277
|
+
try { db.exec("ALTER TABLE users ADD COLUMN plan TEXT DEFAULT 'free'"); } catch {}
|
|
278
|
+
|
|
279
|
+
// ── V3.13: migrate existing networks → network_members (owner) ──
|
|
280
|
+
try {
|
|
281
|
+
const networks = db.all<any>("SELECT network_id, owner_id FROM networks");
|
|
282
|
+
for (const net of networks) {
|
|
283
|
+
const exists = db.get<any>("SELECT 1 FROM network_members WHERE network_id = ?1 AND user_id = ?2", net.network_id, net.owner_id);
|
|
284
|
+
if (!exists) {
|
|
285
|
+
db.run("INSERT INTO network_members (network_id, user_id, role) VALUES (?1, ?2, 'owner')", [net.network_id, net.owner_id]);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
} catch {}
|
|
289
|
+
|
|
290
|
+
// ── V3.13: first registered user → admin ──
|
|
291
|
+
try {
|
|
292
|
+
const firstUser = db.get<any>("SELECT user_id, role FROM users ORDER BY created_at LIMIT 1");
|
|
293
|
+
if (firstUser && firstUser.role !== "admin") {
|
|
294
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [firstUser.user_id]);
|
|
295
|
+
}
|
|
296
|
+
} catch {}
|
|
297
|
+
|
|
251
298
|
// ── V3: add network_id to existing tables ──
|
|
252
299
|
for (const table of ["sessions", "nodes", "tasks", "inbox", "task_events"]) {
|
|
253
300
|
try { db.exec(`ALTER TABLE ${table} ADD COLUMN network_id TEXT`); } catch {}
|