@sleep2agi/commhub-server 0.5.0-preview.9 → 0.5.1

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
@@ -1,98 +1,157 @@
1
1
  # @sleep2agi/commhub-server
2
2
 
3
- AI Agent 通信中枢 MCP Server + SSE Push + REST API
3
+ CommHub: MCP Streamable HTTP + SSE push + REST API for an AI agent network. Single-process Bun server, SQLite-backed, zero config.
4
4
 
5
- ## 快速启动
5
+ **v0.5.0 stable.** The supported path is to install the `anet` CLI (`@sleep2agi/agent-network` 2.0.0) and run `anet hub start`, which wires up port, default account, and config for you.
6
+
7
+ ## Quick start (verified)
6
8
 
7
9
  ```bash
8
- # 需要 Bun
10
+ # Recommended — through the anet CLI
11
+ npm install -g @sleep2agi/agent-network
12
+ anet hub start
13
+ # • http://127.0.0.1:9200 (also bound to LAN)
14
+ # • SQLite at ~/.commhub/commhub.db
15
+ # • Default admin account auto-created: admin / anethub
16
+ # • Reset hint printed in the launch banner
17
+
18
+ # Or directly via bunx (Bun required)
9
19
  bunx @sleep2agi/commhub-server
10
20
 
11
- # 或指定端口 + auth
21
+ # With custom port / auth token
12
22
  PORT=9200 COMMHUB_AUTH_TOKEN=your-secret bunx @sleep2agi/commhub-server
13
23
  ```
14
24
 
15
- 启动后:
16
- - MCP: `http://0.0.0.0:9200/mcp` (Claude Code / Codex 连接)
17
- - SSE: `http://0.0.0.0:9200/events/:alias` (Agent 实时推送)
18
- - REST: `http://0.0.0.0:9200/api/*` (Dashboard / 监控)
19
- - Health: `http://0.0.0.0:9200/health`
20
-
21
- ## MCP 工具 (17 个)
22
-
23
- ### Agent 端 (从 Agent 调用)
24
- | 工具 | 说明 |
25
- |------|------|
26
- | `report_status` | 心跳 + 状态更新 (idle/working/blocked/error/offline) |
27
- | `report_completion` | 任务完成汇报 |
28
- | `get_inbox` | 拉取待办任务 |
29
- | `ack_inbox` | 确认收到任务 |
30
-
31
- ### Hub 端 (从指挥室 / Dashboard 调用)
32
- | 工具 | 说明 |
33
- |------|------|
34
- | `send_task` | 下发任务 (+ 可选 ttl_seconds) |
35
- | `send_message` | 发消息 (不触发 Agent 处理) |
36
- | `send_reply` | 回复任务 (replied/failed/cancelled + in_reply_to) |
37
- | `send_ack` | 确认任务 (不入 inbox) |
38
- | `retry_task` | 重试失败/过期/取消的任务 |
39
- | `cancel_task` | 取消待处理任务 |
40
- | `reassign_task` | 转移任务到另一个 Agent |
41
- | `get_task` | 查询任务详情 |
42
- | `get_all_status` | 全局状态面板 |
43
- | `get_session_status` | session 详情 |
44
- | `broadcast` | 群发消息 |
25
+ Once running:
26
+
27
+ | Surface | URL |
28
+ |---|---|
29
+ | Health | `GET /health` |
30
+ | MCP (Streamable HTTP) | `POST /mcp` |
31
+ | SSE per-agent push | `GET /events/:alias` |
32
+ | REST | `/api/*` |
33
+
34
+ ## Pairs with
35
+
36
+ | Package | Version |
37
+ |---|---|
38
+ | [`@sleep2agi/agent-network`](https://www.npmjs.com/package/@sleep2agi/agent-network) | 2.0.0 |
39
+ | [`@sleep2agi/agent-network-dashboard`](https://www.npmjs.com/package/@sleep2agi/agent-network-dashboard) | 0.1.0 |
40
+ | [`@sleep2agi/agent-node`](https://www.npmjs.com/package/@sleep2agi/agent-node) | 2.1.1 |
41
+
42
+ ## MCP tools (18)
43
+
44
+ ### Agent-side
45
+ | Tool | Description |
46
+ |---|---|
47
+ | `report_status` | Heartbeat + status (idle / working / blocked / error / offline) |
48
+ | `report_completion` | Final completion payload |
49
+ | `get_inbox` | Pull pending tasks |
50
+ | `ack_inbox` | Acknowledge receipt |
51
+
52
+ ### Hub-side (used by Dashboard, Claude Code, peer agents)
53
+ | Tool | Description |
54
+ |---|---|
55
+ | `send_task` | Dispatch a task (supports `ttl_seconds`) |
56
+ | `send_message` | Send a chat message (no task lifecycle) |
57
+ | `send_reply` | Reply to a task (`replied` / `failed` / `cancelled`, plus `in_reply_to`) |
58
+ | `send_ack` | Acknowledge without inbox |
59
+ | `retry_task` | Retry failed / expired / cancelled tasks |
60
+ | `cancel_task` | Cancel a pending task |
61
+ | `reassign_task` | Move a task to a different agent |
62
+ | `get_task` | Fetch task details (used by peer-coordination polling) |
63
+ | `get_all_status` | Global presence panel |
64
+ | `get_session_status` | Per-session detail |
65
+ | `broadcast` | Group send |
66
+ | `list_tasks` | Task list, filterable by `network_id` |
67
+ | `get_completions` | Completion history |
45
68
 
46
69
  ## REST API
47
70
 
48
- | 端点 | 方法 | 说明 |
49
- |------|------|------|
50
- | `/health` | GET | 健康检查 (无需 auth) |
51
- | `/api/status` | GET | 所有 session |
52
- | `/api/tasks` | GET | 任务列表 (支持 status/from_name/to_name/task_id/limit 过滤) |
53
- | `/api/nodes` | GET | 节点持久化信息 |
54
- | `/api/task_events` | GET | 任务审计日志 |
55
- | `/api/messages` | GET | 消息列表 |
56
- | `/api/completions` | GET | 完成记录 |
57
- | `/mcp` | POST | MCP Streamable HTTP |
58
-
59
- ## 数据库 (6 表)
60
-
61
- SQLite WAL 模式, 自动创建在 `~/.commhub/commhub.db`
62
-
63
- | | 说明 |
64
- |---|------|
65
- | `sessions` | 运行时 session (21 列, 含 node_id/session_id/channels) |
66
- | `inbox` | 消息队列 (13 列, 含 in_reply_to/scope) |
67
- | `tasks` | 任务生命周期 (17 列, 完整状态机) |
68
- | `nodes` | 持久化节点身份 (11 列, 独立于 session) |
69
- | `completions` | 完成记录 (7 列) |
70
- | `task_events` | 审计日志 (7 列, 每次状态变化记录) |
71
-
72
- 任务状态机:
71
+ The server exposes ~33 endpoints across health, auth, networks, and observability surfaces. The endpoints in use today by the verified flow are:
72
+
73
+ | Method | Endpoint | Notes |
74
+ |---|---|---|
75
+ | GET | `/health` | No auth |
76
+ | POST | `/mcp` | MCP entry |
77
+ | POST | `/api/auth/register` | Bootstrap admin |
78
+ | POST | `/api/auth/login` | Returns user token |
79
+ | GET | `/api/auth/me` | Current user |
80
+ | PUT | `/api/auth/me` | Edit profile |
81
+ | POST | `/api/auth/password` | Change password |
82
+ | GET / POST / DELETE | `/api/auth/tokens[…]` | Manage API tokens |
83
+ | GET | `/api/status` | Sessions snapshot |
84
+ | GET | `/api/tasks` | Task list (Dashboard) |
85
+ | GET | `/api/messages` | Message list (Dashboard) |
86
+ | GET | `/api/nodes` | Node directory |
87
+ | GET | `/api/stats` | Aggregate stats |
88
+ | GET | `/api/audit-log` | Audit trail |
89
+
90
+ Network-management endpoints (`/api/networks…`) and `/api/license[…]` are present but are **not** part of the v2.0.0 verified flow — see *Not verified* below.
91
+
92
+ Auth: `Authorization: Bearer <token>` header, or `?token=<token>` query.
93
+
94
+ ## SQLite schema (13 tables)
95
+
96
+ Auto-created on first run.
97
+
98
+ | Table | Purpose |
99
+ |---|---|
100
+ | `sessions` | Live agent sessions |
101
+ | `inbox` | Pending messages and tasks |
102
+ | `tasks` | Task state machine |
103
+ | `nodes` | Persistent node identity |
104
+ | `completions` | Final completion records |
105
+ | `task_events` | Per-state audit |
106
+ | `users` | Accounts |
107
+ | `networks` | Workspaces |
108
+ | `api_tokens` | `utok_` / `ntok_` / `atok_` tokens |
109
+ | `audit_log` | Operation audit |
110
+ | `licenses` | License placeholder |
111
+ | `network_members` | Workspace membership |
112
+ | `network_invites` | Invite codes |
113
+
114
+ Task state machine:
115
+
73
116
  ```
74
117
  created → delivered → acked → running → replied
75
118
  → failed → retry → delivered
76
119
  → cancelled
77
- delivered → expired (5min patrol)
78
- delivered/acked/running → reassign → delivered (agent)
120
+ delivered → expired (5min watchdog)
121
+ delivered/acked/running → reassign → delivered (new agent)
122
+ ```
123
+
124
+ ## PostgreSQL (experimental)
125
+
126
+ Set `DATABASE_URL` to switch to PostgreSQL — the SQL layer auto-translates SQLite-isms (datetime, parameter placeholders) so application code is unchanged. Requires `bun add pg`. **Not in the v2.0.0 verified path.**
127
+
128
+ ```bash
129
+ DATABASE_URL=postgres://user:pass@host:5432/commhub bunx @sleep2agi/commhub-server
79
130
  ```
80
131
 
81
- ## 环境变量
132
+ ## Environment
133
+
134
+ | Variable | Default | Notes |
135
+ |---|---|---|
136
+ | `PORT` | `9200` | listen port |
137
+ | `HOST` | `0.0.0.0` | listen address |
138
+ | `COMMHUB_AUTH_TOKEN` | (none) | Bearer token gate (legacy) |
139
+ | `COMMHUB_DB` | `~/.commhub/commhub.db` | SQLite path |
140
+ | `DATABASE_URL` | (none) | switches to PostgreSQL when set (unverified) |
141
+
142
+ ## Auth modes
143
+
144
+ 1. **V3 user system (default)** — `POST /api/auth/register` and `/api/auth/login` issue `utok_…` tokens; nodes get `ntok_…`.
145
+ 2. **Legacy global token** — set `COMMHUB_AUTH_TOKEN` and pass it as Bearer / query.
82
146
 
83
- | 变量 | 默认 | 说明 |
84
- |------|------|------|
85
- | `PORT` | 9200 | 监听端口 |
86
- | `HOST` | 0.0.0.0 | 监听地址 |
87
- | `COMMHUB_AUTH_TOKEN` | (无) | Bearer token 鉴权 |
88
- | `COMMHUB_DB` | ~/.commhub/commhub.db | 数据库路径 |
147
+ `/health` is always public.
89
148
 
90
- ## 鉴权
149
+ ## Not verified
91
150
 
92
- 设置 `COMMHUB_AUTH_TOKEN` 后, 所有端点需要 Bearer token:
93
- - Header: `Authorization: Bearer <token>`
94
- - Query: `?token=<token>`
95
- - `/health` 不需要 auth
151
+ - `/api/networks*` (multi-network create / invite / member management) — code present, not E2E regressed.
152
+ - `/api/license*` placeholder for a future paid tier.
153
+ - PostgreSQL backend — translation layer exists, no E2E run.
154
+ - Telegram / WeChat / Feishu channel endpoints — out of scope for v2.0.0 verification.
96
155
 
97
156
  ## License
98
157
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.5.0-preview.9",
4
- "description": "CommHub MCP Server AI Agent communication hub with SSE push, MCP protocol, and REST API",
3
+ "version": "0.5.1",
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,16 +21,18 @@
21
21
  "agent",
22
22
  "ai",
23
23
  "sse",
24
- "claude",
24
+ "server",
25
25
  "orchestration",
26
- "communication",
27
- "hub"
26
+ "multi-network",
27
+ "auth",
28
+ "license"
28
29
  ],
29
30
  "author": "sleep2agi",
30
31
  "license": "MIT",
32
+ "homepage": "https://anet.vansin.me",
31
33
  "repository": {
32
34
  "type": "git",
33
- "url": "https://github.com/sleep2agi/agent-comm-hub",
35
+ "url": "https://github.com/sleep2agi/agent-network",
34
36
  "directory": "server"
35
37
  },
36
38
  "engines": {
@@ -39,4 +41,4 @@
39
41
  "dependencies": {
40
42
  "@modelcontextprotocol/sdk": "^1.12.0"
41
43
  }
42
- }
44
+ }
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,92 +15,125 @@ 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 {
22
24
  if (!username || username.length < 2) return { ok: false, error: "username must be at least 2 characters" };
25
+ if (username.length > 50) return { ok: false, error: "username too long (max 50)" };
23
26
  if (!password || password.length < 6) return { ok: false, error: "password must be at least 6 characters" };
24
27
  if (!/^[a-zA-Z0-9_\-\u4e00-\u9fff]+$/.test(username)) return { ok: false, error: "username contains invalid characters" };
25
28
 
26
- const existing = db.query<any, [string]>("SELECT user_id FROM users WHERE username = ?1").get(username);
29
+ const existing = db.get<any>("SELECT user_id FROM users WHERE username = ?1", username);
27
30
  if (existing) return { ok: false, error: "username already taken" };
28
31
 
32
+ // First user → auto admin
33
+ const userCount = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM users");
34
+ const isFirstUser = !userCount || userCount.cnt === 0;
35
+
29
36
  const userId = generateId("u");
30
37
  const pwHash = hashPassword(password);
31
38
 
32
39
  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]
40
+ "INSERT INTO users (user_id, username, password_hash, email, display_name, role) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
41
+ [userId, username, pwHash, email || null, displayName || username, isFirstUser ? "admin" : "user"]
35
42
  );
36
43
 
37
- // Auto-create default network
44
+ // Auto-create default network + add as owner member
38
45
  const networkId = generateId("net");
39
46
  db.run(
40
47
  "INSERT INTO networks (network_id, network_name, owner_id, description) VALUES (?1, ?2, ?3, ?4)",
41
48
  [networkId, "default", userId, "Auto-created default network"]
42
49
  );
50
+ db.run(
51
+ "INSERT INTO network_members (network_id, user_id, role) VALUES (?1, ?2, 'owner')",
52
+ [networkId, userId]
53
+ );
43
54
 
44
- // Auto-create API token
45
- const token = generateToken();
46
- const tokenId = generateId("tok");
55
+ // User token (utok_) — not bound to network, for CLI/Dashboard login
56
+ const userToken = generateUserToken();
57
+ const userTokenId = generateId("tok");
58
+ db.run(
59
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
60
+ [userTokenId, hashToken(userToken), userId, null, "user-login", "user"]
61
+ );
62
+
63
+ // Network token (ntok_) — bound to default network, for agent-node
64
+ const networkToken = generateNetworkToken();
65
+ const networkTokenId = generateId("tok");
47
66
  db.run(
48
67
  "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"]
68
+ [networkTokenId, hashToken(networkToken), userId, networkId, "default-network", "network"]
50
69
  );
51
70
 
52
71
  return {
53
72
  ok: true,
54
- user: { user_id: userId, username, display_name: displayName || username, email: email || null, role: "user" },
55
- token,
73
+ user: { user_id: userId, username, display_name: displayName || username, email: email || null, role: isFirstUser ? "admin" : "user" },
74
+ token: userToken,
75
+ network_token: networkToken,
76
+ network_id: networkId,
56
77
  };
57
78
  }
58
79
 
59
80
  export function login(username: string, password: string): AuthResult {
60
- const user = db.query<any, [string]>(
61
- "SELECT user_id, username, password_hash, display_name, email, role FROM users WHERE username = ?1"
62
- ).get(username);
81
+ const user = db.get<any>(
82
+ "SELECT user_id, username, password_hash, display_name, email, role FROM users WHERE username = ?1",
83
+ username);
63
84
 
64
85
  if (!user) return { ok: false, error: "invalid username or password" };
65
86
  if (user.password_hash !== hashPassword(password)) return { ok: false, error: "invalid username or password" };
66
87
 
67
- // Find or create token
68
- let tokenRow = db.query<any, [string]>(
69
- "SELECT token_id FROM api_tokens WHERE user_id = ?1 ORDER BY created_at DESC LIMIT 1"
70
- ).get(user.user_id);
71
-
72
- let token: string;
73
- if (tokenRow) {
74
- // Generate new token (rotate)
75
- token = generateToken();
76
- db.run("UPDATE api_tokens SET token_hash = ?1, last_used_at = datetime('now') WHERE token_id = ?2",
77
- [hashToken(token), tokenRow.token_id]);
78
- } else {
79
- token = generateToken();
80
- const tokenId = generateId("tok");
81
- const networkId = db.query<any, [string]>(
82
- "SELECT network_id FROM networks WHERE owner_id = ?1 LIMIT 1"
83
- ).get(user.user_id)?.network_id;
84
- 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"]
87
- );
88
- }
88
+ // Issue a NEW user token — do NOT rotate/invalidate existing ones. Each
89
+ // login (cli, dashboard, second machine) gets its own row so they don't
90
+ // kick each other out of session. Tokens can be revoked via /api/auth/tokens.
91
+ const userToken = generateUserToken();
92
+ const tokenId = generateId("tok");
93
+ db.run(
94
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
95
+ [tokenId, hashToken(userToken), user.user_id, null, "user-login", "user"]
96
+ );
97
+
98
+ // Find default network
99
+ const defaultNet = db.get<any>(
100
+ "SELECT network_id FROM network_members WHERE user_id = ?1 ORDER BY role = 'owner' DESC LIMIT 1",
101
+ user.user_id);
102
+ const networkId = defaultNet?.network_id || null;
103
+
104
+ // Backward compat: also try old atok_ tokens
105
+ const token = userToken;
89
106
 
90
107
  return {
91
108
  ok: true,
92
109
  user: { user_id: user.user_id, username: user.username, display_name: user.display_name, email: user.email, role: user.role },
93
110
  token,
111
+ network_id: networkId,
94
112
  };
95
113
  }
96
114
 
97
- export function resolveToken(token: string): { user: AuthUser; networkId: string | null } | null {
115
+ /** Create a network-scoped token (ntok_) for a specific node */
116
+ export function createNetworkTokenForNode(userId: string, networkId: string, nodeName: string): { ok: boolean; token?: string; error?: string } {
117
+ // Verify user is a member of this network with write access
118
+ const role = getUserNetworkRole(userId, networkId);
119
+ if (!role || role === "viewer") return { ok: false, error: "no write access to this network" };
120
+ const token = generateNetworkToken();
121
+ const tokenId = generateId("tok");
122
+ db.run(
123
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
124
+ [tokenId, hashToken(token), userId, networkId, `node:${nodeName}`, "network"]
125
+ );
126
+ return { ok: true, token };
127
+ }
128
+
129
+ export function resolveToken(token: string): { user: AuthUser; networkId: string | null; tokenName: string | null } | null {
98
130
  const tHash = hashToken(token);
99
- const row = db.query<any, [string]>(
100
- `SELECT t.user_id, t.network_id, t.scope, u.username, u.display_name, u.email, u.role
131
+ const row = db.get<any>(
132
+ `SELECT t.user_id, t.network_id, t.scope, t.name AS token_name,
133
+ u.username, u.display_name, u.email, u.role
101
134
  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
- ).get(tHash);
135
+ WHERE t.token_hash = ?1 AND (t.expires_at IS NULL OR t.expires_at > datetime('now'))`,
136
+ tHash);
104
137
 
105
138
  if (!row) return null;
106
139
 
@@ -110,19 +143,40 @@ export function resolveToken(token: string): { user: AuthUser; networkId: string
110
143
  return {
111
144
  user: { user_id: row.user_id, username: row.username, display_name: row.display_name, email: row.email, role: row.role },
112
145
  networkId: row.network_id,
146
+ // tokenName carries the binding identity. For node-scoped ntok_, it's
147
+ // 'node:<alias>'; we strip the prefix and use it as the default
148
+ // from_session for any MCP send_task / send_message / etc, so peer
149
+ // agents see who actually called them (not 'hub').
150
+ tokenName: row.token_name || null,
113
151
  };
114
152
  }
115
153
 
116
154
  export function getUserNetworks(userId: string) {
117
- return db.query<any, [string]>(
118
- "SELECT * FROM networks WHERE owner_id = ?1 ORDER BY created_at"
119
- ).all(userId);
155
+ return db.all<any>(
156
+ "SELECT * FROM networks WHERE owner_id = ?1 ORDER BY created_at",
157
+ userId);
120
158
  }
121
159
 
160
+ // Quota limits by plan
161
+ const QUOTAS: Record<string, { max_networks_owned: number; max_networks_joined: number }> = {
162
+ free: { max_networks_owned: 2, max_networks_joined: 3 },
163
+ pro: { max_networks_owned: 10, max_networks_joined: 20 },
164
+ admin: { max_networks_owned: Infinity, max_networks_joined: Infinity },
165
+ };
166
+
122
167
  export function createNetwork(userId: string, name: string, description?: string) {
123
- const existing = db.query<any, [string, string]>(
124
- "SELECT network_id FROM networks WHERE owner_id = ?1 AND network_name = ?2"
125
- ).get(userId, name);
168
+ // Quota check
169
+ const user = db.get<any>("SELECT plan, role FROM users WHERE user_id = ?1", userId);
170
+ const plan = user?.role === "admin" ? "admin" : (user?.plan || "free");
171
+ const quota = QUOTAS[plan] || QUOTAS.free;
172
+ const ownedCount = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM networks WHERE owner_id = ?1", userId);
173
+ if ((ownedCount?.cnt || 0) >= quota.max_networks_owned) {
174
+ return { ok: false, error: `quota exceeded: max ${quota.max_networks_owned} networks for ${plan} plan` };
175
+ }
176
+
177
+ const existing = db.get<any>(
178
+ "SELECT network_id FROM networks WHERE owner_id = ?1 AND network_name = ?2",
179
+ userId, name);
126
180
  if (existing) return { ok: false, error: "network name already exists" };
127
181
 
128
182
  const networkId = generateId("net");
@@ -130,5 +184,156 @@ export function createNetwork(userId: string, name: string, description?: string
130
184
  "INSERT INTO networks (network_id, network_name, owner_id, description) VALUES (?1, ?2, ?3, ?4)",
131
185
  [networkId, name, userId, description || null]
132
186
  );
187
+ db.run(
188
+ "INSERT INTO network_members (network_id, user_id, role) VALUES (?1, ?2, 'owner')",
189
+ [networkId, userId]
190
+ );
133
191
  return { ok: true, network_id: networkId, network_name: name };
134
192
  }
193
+
194
+ export function listTokens(userId: string) {
195
+ return db.all<any>(
196
+ "SELECT token_id, name, scope, network_id, last_used_at, created_at FROM api_tokens WHERE user_id = ?1 ORDER BY created_at DESC",
197
+ userId);
198
+ }
199
+
200
+ export function renameNetwork(userId: string, networkId: string, newName: string): { ok: boolean; error?: string } {
201
+ const net = db.get<any>("SELECT * FROM networks WHERE network_id = ?1", networkId);
202
+ if (!net) return { ok: false, error: "network not found" };
203
+ if (net.owner_id !== userId) return { ok: false, error: "not your network" };
204
+ const dup = db.get<any>("SELECT network_id FROM networks WHERE owner_id = ?1 AND network_name = ?2", userId, newName);
205
+ if (dup) return { ok: false, error: "name already taken" };
206
+ db.run("UPDATE networks SET network_name = ?1, updated_at = datetime('now') WHERE network_id = ?2", [newName, networkId]);
207
+ return { ok: true };
208
+ }
209
+
210
+ export function deleteNetwork(userId: string, networkId: string): { ok: boolean; error?: string } {
211
+ const net = db.get<any>("SELECT * FROM networks WHERE network_id = ?1", networkId);
212
+ if (!net) return { ok: false, error: "network not found" };
213
+ if (net.owner_id !== userId) return { ok: false, error: "not your network" };
214
+ // Check if any sessions/tasks still reference this network
215
+ const sessions = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM sessions WHERE network_id = ?1", networkId);
216
+ if (sessions && sessions.cnt > 0) return { ok: false, error: `network has ${sessions.cnt} active session(s) — stop them first` };
217
+ db.run("DELETE FROM networks WHERE network_id = ?1 AND owner_id = ?2", [networkId, userId]);
218
+ return { ok: true };
219
+ }
220
+
221
+ export function createToken(userId: string, name: string, networkId?: string): { ok: boolean; token?: string; token_id?: string; error?: string } {
222
+ // Security: verify user is a member of the target network
223
+ if (networkId) {
224
+ const role = getUserNetworkRole(userId, networkId);
225
+ if (!role) return { ok: false, error: "not a member of this network" };
226
+ if (role === "viewer") return { ok: false, error: "viewer cannot create full-access network tokens" };
227
+ }
228
+ const token = generateToken();
229
+ const tokenId = generateId("tok");
230
+ db.run(
231
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
232
+ [tokenId, hashToken(token), userId, networkId || null, name, "full"]
233
+ );
234
+ return { ok: true, token, token_id: tokenId };
235
+ }
236
+
237
+ export function revokeToken(userId: string, tokenId: string): { ok: boolean; error?: string } {
238
+ const result = db.run("DELETE FROM api_tokens WHERE token_id = ?1 AND user_id = ?2", [tokenId, userId]);
239
+ return result.changes > 0 ? { ok: true } : { ok: false, error: "token not found" };
240
+ }
241
+
242
+ export function changePassword(userId: string, oldPassword: string, newPassword: string): { ok: boolean; error?: string } {
243
+ if (!newPassword || newPassword.length < 6) return { ok: false, error: "new password must be at least 6 characters" };
244
+ const user = db.get<any>("SELECT password_hash FROM users WHERE user_id = ?1", userId);
245
+ if (!user) return { ok: false, error: "user not found" };
246
+ if (user.password_hash !== hashPassword(oldPassword)) return { ok: false, error: "incorrect current password" };
247
+ db.run("UPDATE users SET password_hash = ?1, updated_at = datetime('now') WHERE user_id = ?2", [hashPassword(newPassword), userId]);
248
+ return { ok: true };
249
+ }
250
+
251
+ // ══════════════════════════════════════
252
+ // V3.13: Network Members
253
+ // ══════════════════════════════════════
254
+
255
+ export function getNetworkMembers(networkId: string) {
256
+ return db.all<any>(
257
+ `SELECT nm.user_id, nm.role, nm.joined_at, nm.invited_by, u.username, u.display_name
258
+ FROM network_members nm JOIN users u ON nm.user_id = u.user_id
259
+ WHERE nm.network_id = ?1 ORDER BY nm.joined_at`,
260
+ networkId);
261
+ }
262
+
263
+ export function getUserNetworkRole(userId: string, networkId: string): string | null {
264
+ const row = db.get<any>("SELECT role FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId);
265
+ return row?.role || null;
266
+ }
267
+
268
+ export function addNetworkMember(networkId: string, userId: string, role: string, invitedBy?: string): { ok: boolean; error?: string } {
269
+ const existing = db.get<any>("SELECT 1 FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId);
270
+ if (existing) return { ok: false, error: "user already a member" };
271
+ db.run("INSERT INTO network_members (network_id, user_id, role, invited_by) VALUES (?1, ?2, ?3, ?4)",
272
+ [networkId, userId, role, invitedBy || null]);
273
+ return { ok: true };
274
+ }
275
+
276
+ export function updateMemberRole(networkId: string, userId: string, newRole: string): { ok: boolean; error?: string } {
277
+ if (newRole === "owner") return { ok: false, error: "cannot assign owner role" };
278
+ const result = db.run("UPDATE network_members SET role = ?1 WHERE network_id = ?2 AND user_id = ?3 AND role != 'owner'",
279
+ [newRole, networkId, userId]);
280
+ return result.changes > 0 ? { ok: true } : { ok: false, error: "member not found or is owner" };
281
+ }
282
+
283
+ export function removeNetworkMember(networkId: string, userId: string): { ok: boolean; error?: string } {
284
+ const member = db.get<any>("SELECT role FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId);
285
+ if (!member) return { ok: false, error: "not a member" };
286
+ if (member.role === "owner") return { ok: false, error: "cannot remove owner" };
287
+ db.run("DELETE FROM network_members WHERE network_id = ?1 AND user_id = ?2", [networkId, userId]);
288
+ return { ok: true };
289
+ }
290
+
291
+ // ══════════════════════════════════════
292
+ // V3.13: Invite Codes
293
+ // ══════════════════════════════════════
294
+
295
+ export function createInvite(networkId: string, createdBy: string, role: string = "member", maxUses: number = 1, expiresInDays?: number): { ok: boolean; invite_code?: string; error?: string } {
296
+ if (!["admin", "member", "viewer"].includes(role)) return { ok: false, error: "invalid role" };
297
+ const code = `inv_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
298
+ const expiresAt = expiresInDays ? `datetime('now', '+${expiresInDays} days')` : null;
299
+ if (expiresAt) {
300
+ 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))",
301
+ [code, networkId, role, createdBy, maxUses, `+${expiresInDays} days`]);
302
+ } else {
303
+ db.run("INSERT INTO network_invites (invite_code, network_id, role, created_by, max_uses) VALUES (?1, ?2, ?3, ?4, ?5)",
304
+ [code, networkId, role, createdBy, maxUses]);
305
+ }
306
+ return { ok: true, invite_code: code };
307
+ }
308
+
309
+ export function joinByInvite(inviteCode: string, userId: string): { ok: boolean; network_id?: string; role?: string; error?: string } {
310
+ const invite = db.get<any>("SELECT * FROM network_invites WHERE invite_code = ?1", inviteCode);
311
+ if (!invite) return { ok: false, error: "invalid invite code" };
312
+ if (invite.max_uses > 0 && invite.used_count >= invite.max_uses) return { ok: false, error: "invite code fully used" };
313
+ if (invite.expires_at) {
314
+ const now = new Date().toISOString().replace("T", " ").slice(0, 19);
315
+ if (invite.expires_at < now) return { ok: false, error: "invite code expired" };
316
+ }
317
+ // Check not already member
318
+ const existing = db.get<any>("SELECT 1 FROM network_members WHERE network_id = ?1 AND user_id = ?2", invite.network_id, userId);
319
+ if (existing) return { ok: false, error: "already a member of this network" };
320
+ // Add member + increment used count
321
+ db.run("INSERT INTO network_members (network_id, user_id, role, invited_by) VALUES (?1, ?2, ?3, ?4)",
322
+ [invite.network_id, userId, invite.role, invite.created_by]);
323
+ db.run("UPDATE network_invites SET used_count = used_count + 1 WHERE invite_code = ?1", [inviteCode]);
324
+ // Auto-create a token for this network
325
+ const token = generateToken();
326
+ const tokenId = generateId("tok");
327
+ db.run("INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
328
+ [tokenId, hashToken(token), userId, invite.network_id, "auto-join", "full"]);
329
+ return { ok: true, network_id: invite.network_id, role: invite.role };
330
+ }
331
+
332
+ /** Get all networks a user is a member of (replaces owner-only query) */
333
+ export function getUserAllNetworks(userId: string) {
334
+ return db.all<any>(
335
+ `SELECT n.*, nm.role as member_role
336
+ FROM networks n JOIN network_members nm ON n.network_id = nm.network_id
337
+ WHERE nm.user_id = ?1 ORDER BY nm.role = 'owner' DESC, n.created_at`,
338
+ userId);
339
+ }