@sleep2agi/commhub-server 0.5.0-preview.25 → 0.5.0-preview.27

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 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
- ## 数据库 (6 表)
59
+ ## 数据表 (11 表)
60
60
 
61
- SQLite WAL 模式, 自动创建在 `~/.commhub/commhub.db`
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.25",
3
+ "version": "0.5.0-preview.27",
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
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * V3 Auth module — user registration, login, token management
3
3
  */
4
- import { db, generateId, hashPassword, hashToken, generateToken, uuidv4 } from "./db.js";
4
+ import { db, generateId, hashPassword, hashToken, generateToken, generateUserToken, generateNetworkToken, uuidv4 } from "./db.js";
5
5
 
6
6
  export interface AuthUser {
7
7
  user_id: string;
@@ -15,7 +15,9 @@ export interface AuthResult {
15
15
  ok: boolean;
16
16
  error?: string;
17
17
  user?: AuthUser;
18
- token?: string;
18
+ token?: string; // user token (utok_)
19
+ network_token?: string; // network token (ntok_) for default network
20
+ network_id?: string;
19
21
  }
20
22
 
21
23
  export function register(username: string, password: string, email?: string, displayName?: string): AuthResult {
@@ -26,33 +28,51 @@ export function register(username: string, password: string, email?: string, dis
26
28
  const existing = db.get<any>("SELECT user_id FROM users WHERE username = ?1", username);
27
29
  if (existing) return { ok: false, error: "username already taken" };
28
30
 
31
+ // First user → auto admin
32
+ const userCount = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM users");
33
+ const isFirstUser = !userCount || userCount.cnt === 0;
34
+
29
35
  const userId = generateId("u");
30
36
  const pwHash = hashPassword(password);
31
37
 
32
38
  db.run(
33
- "INSERT INTO users (user_id, username, password_hash, email, display_name) VALUES (?1, ?2, ?3, ?4, ?5)",
34
- [userId, username, pwHash, email || null, displayName || username]
39
+ "INSERT INTO users (user_id, username, password_hash, email, display_name, role) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
40
+ [userId, username, pwHash, email || null, displayName || username, isFirstUser ? "admin" : "user"]
35
41
  );
36
42
 
37
- // Auto-create default network
43
+ // Auto-create default network + add as owner member
38
44
  const networkId = generateId("net");
39
45
  db.run(
40
46
  "INSERT INTO networks (network_id, network_name, owner_id, description) VALUES (?1, ?2, ?3, ?4)",
41
47
  [networkId, "default", userId, "Auto-created default network"]
42
48
  );
49
+ db.run(
50
+ "INSERT INTO network_members (network_id, user_id, role) VALUES (?1, ?2, 'owner')",
51
+ [networkId, userId]
52
+ );
43
53
 
44
- // Auto-create API token
45
- const token = generateToken();
46
- const tokenId = generateId("tok");
54
+ // User token (utok_) — not bound to network, for CLI/Dashboard login
55
+ const userToken = generateUserToken();
56
+ const userTokenId = generateId("tok");
47
57
  db.run(
48
58
  "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
49
- [tokenId, hashToken(token), userId, networkId, "default", "full"]
59
+ [userTokenId, hashToken(userToken), userId, null, "user-login", "user"]
60
+ );
61
+
62
+ // Network token (ntok_) — bound to default network, for agent-node
63
+ const networkToken = generateNetworkToken();
64
+ const networkTokenId = generateId("tok");
65
+ db.run(
66
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
67
+ [networkTokenId, hashToken(networkToken), userId, networkId, "default-network", "network"]
50
68
  );
51
69
 
52
70
  return {
53
71
  ok: true,
54
- user: { user_id: userId, username, display_name: displayName || username, email: email || null, role: "user" },
55
- token,
72
+ user: { user_id: userId, username, display_name: displayName || username, email: email || null, role: isFirstUser ? "admin" : "user" },
73
+ token: userToken,
74
+ network_token: networkToken,
75
+ network_id: networkId,
56
76
  };
57
77
  }
58
78
 
@@ -64,36 +84,54 @@ export function login(username: string, password: string): AuthResult {
64
84
  if (!user) return { ok: false, error: "invalid username or password" };
65
85
  if (user.password_hash !== hashPassword(password)) return { ok: false, error: "invalid username or password" };
66
86
 
67
- // Find or create token
68
- let tokenRow = db.get<any>(
69
- "SELECT token_id FROM api_tokens WHERE user_id = ?1 ORDER BY created_at DESC LIMIT 1",
87
+ // Generate/rotate user token (utok_, not bound to network)
88
+ let userTokenRow = db.get<any>(
89
+ "SELECT token_id FROM api_tokens WHERE user_id = ?1 AND scope = 'user' ORDER BY created_at DESC LIMIT 1",
70
90
  user.user_id);
71
91
 
72
- let token: string;
73
- if (tokenRow) {
74
- // Generate new token (rotate)
75
- token = generateToken();
92
+ const userToken = generateUserToken();
93
+ if (userTokenRow) {
76
94
  db.run("UPDATE api_tokens SET token_hash = ?1, last_used_at = datetime('now') WHERE token_id = ?2",
77
- [hashToken(token), tokenRow.token_id]);
95
+ [hashToken(userToken), userTokenRow.token_id]);
78
96
  } else {
79
- token = generateToken();
80
97
  const tokenId = generateId("tok");
81
- const networkId = db.get<any>(
82
- "SELECT network_id FROM networks WHERE owner_id = ?1 LIMIT 1",
83
- user.user_id)?.network_id;
84
98
  db.run(
85
- "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name) VALUES (?1, ?2, ?3, ?4, ?5)",
86
- [tokenId, hashToken(token), user.user_id, networkId || null, "login"]
99
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
100
+ [tokenId, hashToken(userToken), user.user_id, null, "user-login", "user"]
87
101
  );
88
102
  }
89
103
 
104
+ // Find default network
105
+ const defaultNet = db.get<any>(
106
+ "SELECT network_id FROM network_members WHERE user_id = ?1 ORDER BY role = 'owner' DESC LIMIT 1",
107
+ user.user_id);
108
+ const networkId = defaultNet?.network_id || null;
109
+
110
+ // Backward compat: also try old atok_ tokens
111
+ const token = userToken;
112
+
90
113
  return {
91
114
  ok: true,
92
115
  user: { user_id: user.user_id, username: user.username, display_name: user.display_name, email: user.email, role: user.role },
93
116
  token,
117
+ network_id: networkId,
94
118
  };
95
119
  }
96
120
 
121
+ /** Create a network-scoped token (ntok_) for a specific node */
122
+ export function createNetworkTokenForNode(userId: string, networkId: string, nodeName: string): { ok: boolean; token?: string; error?: string } {
123
+ // Verify user is a member of this network with write access
124
+ const role = getUserNetworkRole(userId, networkId);
125
+ if (!role || role === "viewer") return { ok: false, error: "no write access to this network" };
126
+ const token = generateNetworkToken();
127
+ const tokenId = generateId("tok");
128
+ db.run(
129
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
130
+ [tokenId, hashToken(token), userId, networkId, `node:${nodeName}`, "network"]
131
+ );
132
+ return { ok: true, token };
133
+ }
134
+
97
135
  export function resolveToken(token: string): { user: AuthUser; networkId: string | null } | null {
98
136
  const tHash = hashToken(token);
99
137
  const row = db.get<any>(
@@ -130,6 +168,10 @@ export function createNetwork(userId: string, name: string, description?: string
130
168
  "INSERT INTO networks (network_id, network_name, owner_id, description) VALUES (?1, ?2, ?3, ?4)",
131
169
  [networkId, name, userId, description || null]
132
170
  );
171
+ db.run(
172
+ "INSERT INTO network_members (network_id, user_id, role) VALUES (?1, ?2, 'owner')",
173
+ [networkId, userId]
174
+ );
133
175
  return { ok: true, network_id: networkId, network_name: name };
134
176
  }
135
177
 
@@ -183,3 +225,93 @@ export function changePassword(userId: string, oldPassword: string, newPassword:
183
225
  db.run("UPDATE users SET password_hash = ?1, updated_at = datetime('now') WHERE user_id = ?2", [hashPassword(newPassword), userId]);
184
226
  return { ok: true };
185
227
  }
228
+
229
+ // ══════════════════════════════════════
230
+ // V3.13: Network Members
231
+ // ══════════════════════════════════════
232
+
233
+ export function getNetworkMembers(networkId: string) {
234
+ return db.all<any>(
235
+ `SELECT nm.user_id, nm.role, nm.joined_at, nm.invited_by, u.username, u.display_name
236
+ FROM network_members nm JOIN users u ON nm.user_id = u.user_id
237
+ WHERE nm.network_id = ?1 ORDER BY nm.joined_at`,
238
+ networkId);
239
+ }
240
+
241
+ export function getUserNetworkRole(userId: string, networkId: string): string | null {
242
+ const row = db.get<any>("SELECT role FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId);
243
+ return row?.role || null;
244
+ }
245
+
246
+ export function addNetworkMember(networkId: string, userId: string, role: string, invitedBy?: string): { ok: boolean; error?: string } {
247
+ const existing = db.get<any>("SELECT 1 FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId);
248
+ if (existing) return { ok: false, error: "user already a member" };
249
+ db.run("INSERT INTO network_members (network_id, user_id, role, invited_by) VALUES (?1, ?2, ?3, ?4)",
250
+ [networkId, userId, role, invitedBy || null]);
251
+ return { ok: true };
252
+ }
253
+
254
+ export function updateMemberRole(networkId: string, userId: string, newRole: string): { ok: boolean; error?: string } {
255
+ if (newRole === "owner") return { ok: false, error: "cannot assign owner role" };
256
+ const result = db.run("UPDATE network_members SET role = ?1 WHERE network_id = ?2 AND user_id = ?3 AND role != 'owner'",
257
+ [newRole, networkId, userId]);
258
+ return result.changes > 0 ? { ok: true } : { ok: false, error: "member not found or is owner" };
259
+ }
260
+
261
+ export function removeNetworkMember(networkId: string, userId: string): { ok: boolean; error?: string } {
262
+ const member = db.get<any>("SELECT role FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId);
263
+ if (!member) return { ok: false, error: "not a member" };
264
+ if (member.role === "owner") return { ok: false, error: "cannot remove owner" };
265
+ db.run("DELETE FROM network_members WHERE network_id = ?1 AND user_id = ?2", [networkId, userId]);
266
+ return { ok: true };
267
+ }
268
+
269
+ // ══════════════════════════════════════
270
+ // V3.13: Invite Codes
271
+ // ══════════════════════════════════════
272
+
273
+ export function createInvite(networkId: string, createdBy: string, role: string = "member", maxUses: number = 1, expiresInDays?: number): { ok: boolean; invite_code?: string; error?: string } {
274
+ if (!["admin", "member", "viewer"].includes(role)) return { ok: false, error: "invalid role" };
275
+ const code = `inv_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
276
+ const expiresAt = expiresInDays ? `datetime('now', '+${expiresInDays} days')` : null;
277
+ if (expiresAt) {
278
+ 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))",
279
+ [code, networkId, role, createdBy, maxUses, `+${expiresInDays} days`]);
280
+ } else {
281
+ db.run("INSERT INTO network_invites (invite_code, network_id, role, created_by, max_uses) VALUES (?1, ?2, ?3, ?4, ?5)",
282
+ [code, networkId, role, createdBy, maxUses]);
283
+ }
284
+ return { ok: true, invite_code: code };
285
+ }
286
+
287
+ export function joinByInvite(inviteCode: string, userId: string): { ok: boolean; network_id?: string; role?: string; error?: string } {
288
+ const invite = db.get<any>("SELECT * FROM network_invites WHERE invite_code = ?1", inviteCode);
289
+ if (!invite) return { ok: false, error: "invalid invite code" };
290
+ if (invite.max_uses > 0 && invite.used_count >= invite.max_uses) return { ok: false, error: "invite code fully used" };
291
+ if (invite.expires_at) {
292
+ const now = new Date().toISOString().replace("T", " ").slice(0, 19);
293
+ if (invite.expires_at < now) return { ok: false, error: "invite code expired" };
294
+ }
295
+ // Check not already member
296
+ const existing = db.get<any>("SELECT 1 FROM network_members WHERE network_id = ?1 AND user_id = ?2", invite.network_id, userId);
297
+ if (existing) return { ok: false, error: "already a member of this network" };
298
+ // Add member + increment used count
299
+ db.run("INSERT INTO network_members (network_id, user_id, role, invited_by) VALUES (?1, ?2, ?3, ?4)",
300
+ [invite.network_id, userId, invite.role, invite.created_by]);
301
+ db.run("UPDATE network_invites SET used_count = used_count + 1 WHERE invite_code = ?1", [inviteCode]);
302
+ // Auto-create a token for this network
303
+ const token = generateToken();
304
+ const tokenId = generateId("tok");
305
+ db.run("INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
306
+ [tokenId, hashToken(token), userId, invite.network_id, "auto-join", "full"]);
307
+ return { ok: true, network_id: invite.network_id, role: invite.role };
308
+ }
309
+
310
+ /** Get all networks a user is a member of (replaces owner-only query) */
311
+ export function getUserAllNetworks(userId: string) {
312
+ return db.all<any>(
313
+ `SELECT n.*, nm.role as member_role
314
+ FROM networks n JOIN network_members nm ON n.network_id = nm.network_id
315
+ WHERE nm.user_id = ?1 ORDER BY nm.role = 'owner' DESC, n.created_at`,
316
+ userId);
317
+ }
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 pg Pool.
113
+ * PostgreSQL adapter using a persistent worker subprocess.
114
114
  *
115
- * Design: uses synchronous blocking via Bun's subprocess or
116
- * deasync-style approach. Since Bun MCP handlers are async,
117
- * we store a pool and use blocking queries via pg's synchronous mode.
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
- * NOTE: This adapter uses `pg` npm package in synchronous mode.
120
- * For production, the interface should be async. This sync bridge
121
- * works for the current codebase where MCP handlers are async
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 pool: any; // pg.Pool
127
- private client: any; // dedicated sync client
126
+ private connString: string;
128
127
 
129
128
  constructor(connectionString: string) {
130
- // Dynamic import of pg — only loaded when DATABASE_URL is set
131
- try {
132
- const pg = require("pg");
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
- // Bun supports top-level await and can block use a shared promise pattern
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 { Pool } = require('pg');
162
- const pool = new Pool({ connectionString: process.env.DATABASE_URL });
163
- pool.query(${JSON.stringify(sql)}, ${JSON.stringify(params || [])})
164
- .then(r => { process.stdout.write(JSON.stringify({ rows: r.rows, rowCount: r.rowCount })); pool.end(); })
165
- .catch(e => { process.stdout.write(JSON.stringify({ error: e.message })); pool.end(); process.exit(1); });
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
- env: { ...process.env },
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
- const errOut = proc.stderr.toString().trim();
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(out);
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 ?? 0 };
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; // Skip
200
- // Split multiple statements and execute each
201
- const statements = sql.split(";").map(s => s.trim()).filter(s => s.length > 0);
202
- for (const stmt of statements) {
203
- this.querySync(stmt);
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 {}
@@ -269,6 +324,14 @@ export function generateToken(): string {
269
324
  return `atok_${crypto.randomUUID().replace(/-/g, "")}`;
270
325
  }
271
326
 
327
+ export function generateUserToken(): string {
328
+ return `utok_${crypto.randomUUID().replace(/-/g, "")}`;
329
+ }
330
+
331
+ export function generateNetworkToken(): string {
332
+ return `ntok_${crypto.randomUUID().replace(/-/g, "")}`;
333
+ }
334
+
272
335
  export function logAudit(userId: string | null, username: string | null, action: string, targetType?: string, targetId?: string, detail?: string, ip?: string, networkId?: string) {
273
336
  try {
274
337
  db.run(
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, createNetworkTokenForNode, 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;
@@ -304,6 +304,23 @@ Bun.serve({
304
304
  }
305
305
  }
306
306
 
307
+ // ── V3.13: Create network token for a node ──
308
+ if (url.pathname === "/api/auth/node-token" && req.method === "POST") {
309
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "");
310
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
311
+ const resolved = resolveToken(token);
312
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
313
+ try {
314
+ const body = await req.json() as any;
315
+ if (!body.network_id || !body.node_name) return withCors(req, Response.json({ ok: false, error: "network_id and node_name required" }, { status: 400 }));
316
+ const result = createNetworkTokenForNode(resolved.user.user_id, body.network_id, body.node_name);
317
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "node_token_created", "network", body.network_id, body.node_name);
318
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
319
+ } catch (e: any) {
320
+ return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
321
+ }
322
+ }
323
+
307
324
  // ── V3: Token management ──
308
325
  if (url.pathname === "/api/auth/tokens" && req.method === "GET") {
309
326
  const token = req.headers.get("Authorization")?.replace("Bearer ", "");
@@ -346,7 +363,8 @@ Bun.serve({
346
363
  if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
347
364
  const resolved = resolveToken(token);
348
365
  if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
349
- const networks = getUserNetworks(resolved.user.user_id);
366
+ // V3.13: return all networks user is a member of (not just owner)
367
+ const networks = getUserAllNetworks(resolved.user.user_id);
350
368
  return withCors(req, Response.json({ ok: true, networks }));
351
369
  }
352
370
 
@@ -364,6 +382,72 @@ Bun.serve({
364
382
  }
365
383
  }
366
384
 
385
+ // ── V3.13: Network members + invites ──
386
+ const membersMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/members(?:\/([^/]+))?$/);
387
+ if (membersMatch) {
388
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
389
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
390
+ const resolved = resolveToken(token);
391
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
392
+ const netId = membersMatch[1];
393
+ const targetUid = membersMatch[2];
394
+ const callerRole = getUserNetworkRole(resolved.user.user_id, netId);
395
+ if (!callerRole) return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
396
+
397
+ if (req.method === "GET") {
398
+ if (!["owner", "admin"].includes(callerRole)) return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
399
+ const members = getNetworkMembers(netId);
400
+ return withCors(req, Response.json({ ok: true, members }));
401
+ }
402
+ if (req.method === "POST") {
403
+ if (!["owner", "admin"].includes(callerRole)) return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
404
+ const body = await req.json() as any;
405
+ const result = addNetworkMember(netId, body.user_id, body.role || "member", resolved.user.user_id);
406
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "member_added", "network", netId, `${body.user_id} as ${body.role || "member"}`);
407
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
408
+ }
409
+ if (req.method === "PUT" && targetUid) {
410
+ if (callerRole !== "owner") return withCors(req, Response.json({ ok: false, error: "owner required" }, { status: 403 }));
411
+ const body = await req.json() as any;
412
+ const result = updateMemberRole(netId, targetUid, body.role);
413
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "member_role_changed", "network", netId, `${targetUid} → ${body.role}`);
414
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
415
+ }
416
+ if (req.method === "DELETE" && targetUid) {
417
+ if (!["owner", "admin"].includes(callerRole)) return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
418
+ const result = removeNetworkMember(netId, targetUid);
419
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "member_removed", "network", netId, targetUid);
420
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
421
+ }
422
+ }
423
+
424
+ if (url.pathname.match(/^\/api\/networks\/([^/]+)\/invite$/) && req.method === "POST") {
425
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
426
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
427
+ const resolved = resolveToken(token);
428
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
429
+ const netId = url.pathname.split("/")[3];
430
+ const callerRole = getUserNetworkRole(resolved.user.user_id, netId);
431
+ if (!callerRole || !["owner", "admin"].includes(callerRole)) {
432
+ return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
433
+ }
434
+ const body = await req.json() as any;
435
+ const result = createInvite(netId, resolved.user.user_id, body.role || "member", body.max_uses || 1, body.expires_days);
436
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "invite_created", "network", netId, result.invite_code);
437
+ return withCors(req, Response.json(result));
438
+ }
439
+
440
+ if (url.pathname === "/api/networks/join" && req.method === "POST") {
441
+ const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
442
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
443
+ const resolved = resolveToken(token);
444
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
445
+ const body = await req.json() as any;
446
+ const result = joinByInvite(body.invite_code, resolved.user.user_id);
447
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "network_joined", "network", result.network_id, `via invite, role=${result.role}`);
448
+ return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
449
+ }
450
+
367
451
  // ── V3: Admin APIs (require auth) ──
368
452
  if (url.pathname === "/api/users" && req.method === "GET") {
369
453
  const token = req.headers.get("Authorization")?.replace("Bearer ", "");