@sleep2agi/commhub-server 0.5.0-preview.25 → 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 +1 -1
- package/src/auth.ts +99 -1
- package/src/db-adapter.ts +46 -57
- package/src/db.ts +55 -0
- package/src/index.ts +69 -2
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.5.0-preview.
|
|
3
|
+
"version": "0.5.0-preview.26",
|
|
4
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",
|
package/src/auth.ts
CHANGED
|
@@ -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();
|
|
@@ -130,6 +134,10 @@ 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
|
|
|
@@ -183,3 +191,93 @@ export function changePassword(userId: string, oldPassword: string, newPassword:
|
|
|
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
|
@@ -110,79 +110,66 @@ export function sqliteToPostgres(sql: string): string {
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
/**
|
|
113
|
-
* PostgreSQL adapter using
|
|
113
|
+
* PostgreSQL adapter using a persistent worker subprocess.
|
|
114
114
|
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
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).
|
|
118
119
|
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
* but DB calls are sync within them.
|
|
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.
|
|
123
123
|
*/
|
|
124
124
|
export class PgAdapter implements DbAdapter {
|
|
125
125
|
readonly dialect = "postgres" as const;
|
|
126
|
-
private
|
|
127
|
-
private client: any; // dedicated sync client
|
|
126
|
+
private connString: string;
|
|
128
127
|
|
|
129
128
|
constructor(connectionString: string) {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
this.pool = new pg.Pool({ connectionString, max: 10 });
|
|
134
|
-
// Get a dedicated client for sync operations
|
|
135
|
-
// We use execSync pattern for blocking
|
|
136
|
-
} catch (e) {
|
|
129
|
+
this.connString = connectionString;
|
|
130
|
+
// Validate pg is available
|
|
131
|
+
try { require("pg"); } catch (e) {
|
|
137
132
|
throw new Error(
|
|
138
133
|
"PostgreSQL support requires 'pg' package. Install with: bun add pg\n" +
|
|
139
134
|
` Original error: ${(e as Error).message}`
|
|
140
135
|
);
|
|
141
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");
|
|
142
141
|
}
|
|
143
142
|
|
|
144
|
-
|
|
145
|
-
* Execute a blocking query against PG.
|
|
146
|
-
* Uses Bun's ability to block on promises in sync context.
|
|
147
|
-
*/
|
|
148
|
-
private querySync(sql: string, params?: any[]): any {
|
|
143
|
+
private querySync(sql: string, params?: any[]): { rows: any[]; rowCount: number } {
|
|
149
144
|
const pgSql = sqliteToPostgres(sql);
|
|
150
|
-
//
|
|
151
|
-
// For sync bridge, we use a worker or subprocess
|
|
152
|
-
// Simplest approach: use pg's synchronous query via dedicated connection
|
|
153
|
-
const result = this._blockingQuery(pgSql, params);
|
|
154
|
-
return result;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
private _blockingQuery(sql: string, params?: any[]): any {
|
|
158
|
-
// Use Bun.spawnSync to run a node script that executes the query
|
|
159
|
-
// This is a pragmatic sync bridge until we migrate to async interface
|
|
145
|
+
// Single-query subprocess with pg Pool (connection string from env)
|
|
160
146
|
const script = `
|
|
161
|
-
const
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
+
});
|
|
166
159
|
`;
|
|
167
|
-
const proc = Bun.spawnSync(["node", "-e", script], {
|
|
168
|
-
|
|
169
|
-
stdout: "pipe",
|
|
170
|
-
stderr: "pipe",
|
|
160
|
+
const proc = Bun.spawnSync(["node", "--no-warnings", "-e", script], {
|
|
161
|
+
stdout: "pipe", stderr: "pipe",
|
|
171
162
|
});
|
|
172
|
-
const out = proc.stdout.toString().trim();
|
|
173
163
|
if (proc.exitCode !== 0) {
|
|
174
|
-
|
|
175
|
-
throw new Error(`PG query failed: ${errOut || out}`);
|
|
164
|
+
throw new Error(`PG: ${proc.stderr.toString().trim() || "query failed"}`);
|
|
176
165
|
}
|
|
177
|
-
return JSON.parse(
|
|
166
|
+
return JSON.parse(proc.stdout.toString().trim());
|
|
178
167
|
}
|
|
179
168
|
|
|
180
169
|
run(sql: string, params?: any[]): QueryResult {
|
|
181
|
-
if (sql.trim().toUpperCase().startsWith("PRAGMA")) {
|
|
182
|
-
return { changes: 0 }; // Skip SQLite PRAGMAs
|
|
183
|
-
}
|
|
170
|
+
if (sql.trim().toUpperCase().startsWith("PRAGMA")) return { changes: 0 };
|
|
184
171
|
const result = this.querySync(sql, params);
|
|
185
|
-
return { changes: result.rowCount
|
|
172
|
+
return { changes: result.rowCount };
|
|
186
173
|
}
|
|
187
174
|
|
|
188
175
|
get<T = any>(sql: string, ...params: any[]): T | null {
|
|
@@ -196,11 +183,15 @@ export class PgAdapter implements DbAdapter {
|
|
|
196
183
|
}
|
|
197
184
|
|
|
198
185
|
exec(sql: string): void {
|
|
199
|
-
if (sql.trim().toUpperCase().startsWith("PRAGMA")) return;
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
+
}
|
|
204
195
|
}
|
|
205
196
|
}
|
|
206
197
|
|
|
@@ -216,9 +207,7 @@ export class PgAdapter implements DbAdapter {
|
|
|
216
207
|
}
|
|
217
208
|
}
|
|
218
209
|
|
|
219
|
-
close(): void {
|
|
220
|
-
try { this.pool?.end(); } catch {}
|
|
221
|
-
}
|
|
210
|
+
close(): void {}
|
|
222
211
|
}
|
|
223
212
|
|
|
224
213
|
// ════════════════════════════════════════════
|
package/src/db.ts
CHANGED
|
@@ -240,6 +240,61 @@ if (!existingLicense) {
|
|
|
240
240
|
console.log("[commhub] 🎉 14-day free trial started!");
|
|
241
241
|
}
|
|
242
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
|
+
|
|
243
298
|
// ── V3: add network_id to existing tables ──
|
|
244
299
|
for (const table of ["sessions", "nodes", "tasks", "inbox", "task_events"]) {
|
|
245
300
|
try { db.exec(`ALTER TABLE ${table} ADD COLUMN network_id TEXT`); } catch {}
|
package/src/index.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { z } from "zod/v4";
|
|
|
4
4
|
import { registerTools } from "./tools.js";
|
|
5
5
|
import { db, logTaskEvent, logAudit } from "./db.js";
|
|
6
6
|
import { createSSEStream, pushEvent, pushBroadcast, getSSEStats } from "./push.js";
|
|
7
|
-
import { register, login, resolveToken, getUserNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, listTokens, createToken, revokeToken, type AuthUser } from "./auth.js";
|
|
7
|
+
import { register, login, resolveToken, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, listTokens, createToken, revokeToken, getNetworkMembers, getUserNetworkRole, addNetworkMember, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, type AuthUser } from "./auth.js";
|
|
8
8
|
|
|
9
9
|
const PORT = Number(process.env.PORT) || 9200;
|
|
10
10
|
const AUTH_TOKEN = process.env.COMMHUB_AUTH_TOKEN;
|
|
@@ -346,7 +346,8 @@ Bun.serve({
|
|
|
346
346
|
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
347
347
|
const resolved = resolveToken(token);
|
|
348
348
|
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
349
|
-
|
|
349
|
+
// V3.13: return all networks user is a member of (not just owner)
|
|
350
|
+
const networks = getUserAllNetworks(resolved.user.user_id);
|
|
350
351
|
return withCors(req, Response.json({ ok: true, networks }));
|
|
351
352
|
}
|
|
352
353
|
|
|
@@ -364,6 +365,72 @@ Bun.serve({
|
|
|
364
365
|
}
|
|
365
366
|
}
|
|
366
367
|
|
|
368
|
+
// ── V3.13: Network members + invites ──
|
|
369
|
+
const membersMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/members(?:\/([^/]+))?$/);
|
|
370
|
+
if (membersMatch) {
|
|
371
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
372
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
373
|
+
const resolved = resolveToken(token);
|
|
374
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
375
|
+
const netId = membersMatch[1];
|
|
376
|
+
const targetUid = membersMatch[2];
|
|
377
|
+
const callerRole = getUserNetworkRole(resolved.user.user_id, netId);
|
|
378
|
+
if (!callerRole) return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
|
|
379
|
+
|
|
380
|
+
if (req.method === "GET") {
|
|
381
|
+
if (!["owner", "admin"].includes(callerRole)) return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
|
|
382
|
+
const members = getNetworkMembers(netId);
|
|
383
|
+
return withCors(req, Response.json({ ok: true, members }));
|
|
384
|
+
}
|
|
385
|
+
if (req.method === "POST") {
|
|
386
|
+
if (!["owner", "admin"].includes(callerRole)) return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
|
|
387
|
+
const body = await req.json() as any;
|
|
388
|
+
const result = addNetworkMember(netId, body.user_id, body.role || "member", resolved.user.user_id);
|
|
389
|
+
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "member_added", "network", netId, `${body.user_id} as ${body.role || "member"}`);
|
|
390
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
391
|
+
}
|
|
392
|
+
if (req.method === "PUT" && targetUid) {
|
|
393
|
+
if (callerRole !== "owner") return withCors(req, Response.json({ ok: false, error: "owner required" }, { status: 403 }));
|
|
394
|
+
const body = await req.json() as any;
|
|
395
|
+
const result = updateMemberRole(netId, targetUid, body.role);
|
|
396
|
+
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "member_role_changed", "network", netId, `${targetUid} → ${body.role}`);
|
|
397
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
398
|
+
}
|
|
399
|
+
if (req.method === "DELETE" && targetUid) {
|
|
400
|
+
if (!["owner", "admin"].includes(callerRole)) return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
|
|
401
|
+
const result = removeNetworkMember(netId, targetUid);
|
|
402
|
+
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "member_removed", "network", netId, targetUid);
|
|
403
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (url.pathname.match(/^\/api\/networks\/([^/]+)\/invite$/) && req.method === "POST") {
|
|
408
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
409
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
410
|
+
const resolved = resolveToken(token);
|
|
411
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
412
|
+
const netId = url.pathname.split("/")[3];
|
|
413
|
+
const callerRole = getUserNetworkRole(resolved.user.user_id, netId);
|
|
414
|
+
if (!callerRole || !["owner", "admin"].includes(callerRole)) {
|
|
415
|
+
return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
|
|
416
|
+
}
|
|
417
|
+
const body = await req.json() as any;
|
|
418
|
+
const result = createInvite(netId, resolved.user.user_id, body.role || "member", body.max_uses || 1, body.expires_days);
|
|
419
|
+
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "invite_created", "network", netId, result.invite_code);
|
|
420
|
+
return withCors(req, Response.json(result));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (url.pathname === "/api/networks/join" && req.method === "POST") {
|
|
424
|
+
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
425
|
+
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
426
|
+
const resolved = resolveToken(token);
|
|
427
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
428
|
+
const body = await req.json() as any;
|
|
429
|
+
const result = joinByInvite(body.invite_code, resolved.user.user_id);
|
|
430
|
+
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "network_joined", "network", result.network_id, `via invite, role=${result.role}`);
|
|
431
|
+
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
432
|
+
}
|
|
433
|
+
|
|
367
434
|
// ── V3: Admin APIs (require auth) ──
|
|
368
435
|
if (url.pathname === "/api/users" && req.method === "GET") {
|
|
369
436
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|