@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 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.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 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 {}
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
- const networks = getUserNetworks(resolved.user.user_id);
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 ", "");