@sleep2agi/commhub-server 0.5.0-preview.3 → 0.5.0-preview.31
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 +162 -0
- package/package.json +9 -7
- package/src/auth.ts +340 -0
- package/src/db-adapter.ts +238 -0
- package/src/db.ts +225 -10
- package/src/index.ts +634 -39
- package/src/tools.ts +448 -164
package/README.md
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# @sleep2agi/commhub-server
|
|
2
|
+
|
|
3
|
+
AI Agent 通信中枢 — MCP Server + SSE Push + REST API。
|
|
4
|
+
|
|
5
|
+
## 快速启动
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# 需要 Bun
|
|
9
|
+
bunx @sleep2agi/commhub-server
|
|
10
|
+
|
|
11
|
+
# 或指定端口 + auth
|
|
12
|
+
PORT=9200 COMMHUB_AUTH_TOKEN=your-secret bunx @sleep2agi/commhub-server
|
|
13
|
+
```
|
|
14
|
+
|
|
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 工具 (18 个)
|
|
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` | 群发消息 |
|
|
45
|
+
|
|
46
|
+
## REST API (33 端点)
|
|
47
|
+
|
|
48
|
+
| 端点 | 方法 | 说明 |
|
|
49
|
+
|------|------|------|
|
|
50
|
+
| `/health` | GET | 健康检查 (无需 auth) |
|
|
51
|
+
| `/mcp` | POST | MCP Streamable HTTP |
|
|
52
|
+
| **认证** | | |
|
|
53
|
+
| `/api/auth/register` | POST | 注册 → utok_ + ntok_ |
|
|
54
|
+
| `/api/auth/login` | POST | 登录 → utok_ |
|
|
55
|
+
| `/api/auth/me` | GET | 当前用户信息 |
|
|
56
|
+
| `/api/auth/me` | PUT | 修改资料 |
|
|
57
|
+
| `/api/auth/password` | POST | 修改密码 |
|
|
58
|
+
| `/api/auth/tokens` | GET | Token 列表 |
|
|
59
|
+
| `/api/auth/tokens` | POST | 创建 Token |
|
|
60
|
+
| `/api/auth/tokens/:id` | DELETE | 撤销 Token |
|
|
61
|
+
| `/api/auth/node-token` | POST | 创建节点网络 Token (ntok_) |
|
|
62
|
+
| **网络** | | |
|
|
63
|
+
| `/api/networks` | GET | 我的网络列表(成员网络) |
|
|
64
|
+
| `/api/networks` | POST | 创建网络 |
|
|
65
|
+
| `/api/networks/:id` | GET | 网络详情 + 统计 |
|
|
66
|
+
| `/api/networks/:id` | PUT | 重命名网络 |
|
|
67
|
+
| `/api/networks/:id` | DELETE | 删除网络 |
|
|
68
|
+
| `/api/networks/:id/members` | GET | 成员列表 |
|
|
69
|
+
| `/api/networks/:id/members` | POST | 添加成员 |
|
|
70
|
+
| `/api/networks/:id/members/:uid` | PUT | 修改成员角色 |
|
|
71
|
+
| `/api/networks/:id/members/:uid` | DELETE | 移除成员 |
|
|
72
|
+
| `/api/networks/:id/invite` | POST | 生成邀请码 |
|
|
73
|
+
| `/api/networks/join` | POST | 用邀请码加入 |
|
|
74
|
+
| **数据** | | |
|
|
75
|
+
| `/api/status` | GET | 所有 session |
|
|
76
|
+
| `/api/tasks` | GET | 任务列表 |
|
|
77
|
+
| `/api/nodes` | GET | 节点信息 |
|
|
78
|
+
| `/api/stats` | GET | 统计汇总 |
|
|
79
|
+
| `/api/messages` | GET | 消息列表 |
|
|
80
|
+
| `/api/completions` | GET | 完成记录 |
|
|
81
|
+
| `/api/task_events` | GET | 任务审计日志 |
|
|
82
|
+
| `/api/audit-log` | GET | 操作审计日志 |
|
|
83
|
+
| `/api/users` | GET | 用户列表 (admin) |
|
|
84
|
+
| `/api/license` | GET | License 状态 |
|
|
85
|
+
| `/api/license/activate` | POST | 激活授权码 |
|
|
86
|
+
|
|
87
|
+
## 数据表 (13 表)
|
|
88
|
+
|
|
89
|
+
自动创建,SQLite
|
|
90
|
+
|
|
91
|
+
| 表 | 说明 |
|
|
92
|
+
|---|------|
|
|
93
|
+
| `sessions` | 运行时 session (21 列, 含 node_id/session_id/channels) |
|
|
94
|
+
| `inbox` | 消息队列 (13 列, 含 in_reply_to/scope) |
|
|
95
|
+
| `tasks` | 任务生命周期 (17 列, 完整状态机) |
|
|
96
|
+
| `nodes` | 持久化节点身份 (11 列, 独立于 session) |
|
|
97
|
+
| `completions` | 完成记录 (7 列) |
|
|
98
|
+
| `task_events` | 审计日志 (7 列, 每次状态变化记录) |
|
|
99
|
+
| `users` | 用户 (username/password_hash/role/plan) |
|
|
100
|
+
| `networks` | 网络 (name/owner/visibility/max_members) |
|
|
101
|
+
| `api_tokens` | API Token (utok_/ntok_/atok_ + scope + network) |
|
|
102
|
+
| `audit_log` | 操作审计 (user/action/target/ip) |
|
|
103
|
+
| `licenses` | License (type/expires/limits) |
|
|
104
|
+
| `network_members` | 网络成员 (user ↔ network + role) |
|
|
105
|
+
| `network_invites` | 邀请码 (code/role/max_uses/expires) |
|
|
106
|
+
|
|
107
|
+
任务状态机:
|
|
108
|
+
```
|
|
109
|
+
created → delivered → acked → running → replied
|
|
110
|
+
→ failed → retry → delivered
|
|
111
|
+
→ cancelled
|
|
112
|
+
delivered → expired (5min patrol)
|
|
113
|
+
delivered/acked/running → reassign → delivered (新agent)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## 数据库 (SQLite + PostgreSQL)
|
|
117
|
+
|
|
118
|
+
默认使用 SQLite(零配置),设置 `DATABASE_URL` 即切换到 PostgreSQL:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
# SQLite (默认,零配置)
|
|
122
|
+
bunx @sleep2agi/commhub-server
|
|
123
|
+
|
|
124
|
+
# PostgreSQL
|
|
125
|
+
DATABASE_URL=postgres://user:pass@localhost:5432/commhub bunx @sleep2agi/commhub-server
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
PostgreSQL 模式需要 `pg` 包:`bun add pg`
|
|
129
|
+
|
|
130
|
+
所有 SQL 自动翻译(datetime→NOW, 参数占位符→$N 等),代码零修改。
|
|
131
|
+
|
|
132
|
+
## 环境变量
|
|
133
|
+
|
|
134
|
+
| 变量 | 默认 | 说明 |
|
|
135
|
+
|------|------|------|
|
|
136
|
+
| `PORT` | 9200 | 监听端口 |
|
|
137
|
+
| `HOST` | 0.0.0.0 | 监听地址 |
|
|
138
|
+
| `COMMHUB_AUTH_TOKEN` | (无) | Bearer token 鉴权 |
|
|
139
|
+
| `COMMHUB_DB` | ~/.commhub/commhub.db | SQLite 数据库路径 |
|
|
140
|
+
| `DATABASE_URL` | (无) | PostgreSQL 连接串 (设置后使用 PG) |
|
|
141
|
+
|
|
142
|
+
## 鉴权
|
|
143
|
+
|
|
144
|
+
两种认证方式:
|
|
145
|
+
1. **V3 用户系统** (推荐): `POST /api/auth/register` → 获取 `atok_xxx` token
|
|
146
|
+
2. **全局 token** (传统): `COMMHUB_AUTH_TOKEN` 环境变量
|
|
147
|
+
|
|
148
|
+
Header: `Authorization: Bearer <token>` 或 Query: `?token=<token>`
|
|
149
|
+
|
|
150
|
+
## V3 功能
|
|
151
|
+
|
|
152
|
+
- **用户系统**: 注册/登录/Token 认证
|
|
153
|
+
- **多网络**: 每个用户可创建多个独立网络
|
|
154
|
+
- **网络隔离**: 不同网络的数据完全隔离
|
|
155
|
+
- **试用授权**: 14 天免费试用, 到期需授权码
|
|
156
|
+
- **审计日志**: 所有操作记录
|
|
157
|
+
- **限流**: 注册 30/min, 登录 10/min (per IP)
|
|
158
|
+
- `/health` 不需要 auth
|
|
159
|
+
|
|
160
|
+
## License
|
|
161
|
+
|
|
162
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.5.0-preview.
|
|
4
|
-
"description": "CommHub
|
|
3
|
+
"version": "0.5.0-preview.31",
|
|
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
|
-
"
|
|
24
|
+
"server",
|
|
25
25
|
"orchestration",
|
|
26
|
-
"
|
|
27
|
-
"
|
|
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-
|
|
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
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* V3 Auth module — user registration, login, token management
|
|
3
|
+
*/
|
|
4
|
+
import { db, generateId, hashPassword, hashToken, generateToken, generateUserToken, generateNetworkToken, uuidv4 } from "./db.js";
|
|
5
|
+
|
|
6
|
+
export interface AuthUser {
|
|
7
|
+
user_id: string;
|
|
8
|
+
username: string;
|
|
9
|
+
display_name: string | null;
|
|
10
|
+
email: string | null;
|
|
11
|
+
role: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface AuthResult {
|
|
15
|
+
ok: boolean;
|
|
16
|
+
error?: string;
|
|
17
|
+
user?: AuthUser;
|
|
18
|
+
token?: string; // user token (utok_)
|
|
19
|
+
network_token?: string; // network token (ntok_) for default network
|
|
20
|
+
network_id?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function register(username: string, password: string, email?: string, displayName?: string): AuthResult {
|
|
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)" };
|
|
26
|
+
if (!password || password.length < 6) return { ok: false, error: "password must be at least 6 characters" };
|
|
27
|
+
if (!/^[a-zA-Z0-9_\-\u4e00-\u9fff]+$/.test(username)) return { ok: false, error: "username contains invalid characters" };
|
|
28
|
+
|
|
29
|
+
const existing = db.get<any>("SELECT user_id FROM users WHERE username = ?1", username);
|
|
30
|
+
if (existing) return { ok: false, error: "username already taken" };
|
|
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
|
+
|
|
36
|
+
const userId = generateId("u");
|
|
37
|
+
const pwHash = hashPassword(password);
|
|
38
|
+
|
|
39
|
+
db.run(
|
|
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"]
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// Auto-create default network + add as owner member
|
|
45
|
+
const networkId = generateId("net");
|
|
46
|
+
db.run(
|
|
47
|
+
"INSERT INTO networks (network_id, network_name, owner_id, description) VALUES (?1, ?2, ?3, ?4)",
|
|
48
|
+
[networkId, "default", userId, "Auto-created default network"]
|
|
49
|
+
);
|
|
50
|
+
db.run(
|
|
51
|
+
"INSERT INTO network_members (network_id, user_id, role) VALUES (?1, ?2, 'owner')",
|
|
52
|
+
[networkId, userId]
|
|
53
|
+
);
|
|
54
|
+
|
|
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");
|
|
66
|
+
db.run(
|
|
67
|
+
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
68
|
+
[networkTokenId, hashToken(networkToken), userId, networkId, "default-network", "network"]
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
ok: true,
|
|
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,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function login(username: string, password: string): AuthResult {
|
|
81
|
+
const user = db.get<any>(
|
|
82
|
+
"SELECT user_id, username, password_hash, display_name, email, role FROM users WHERE username = ?1",
|
|
83
|
+
username);
|
|
84
|
+
|
|
85
|
+
if (!user) return { ok: false, error: "invalid username or password" };
|
|
86
|
+
if (user.password_hash !== hashPassword(password)) return { ok: false, error: "invalid username or password" };
|
|
87
|
+
|
|
88
|
+
// Generate/rotate user token (utok_, not bound to network)
|
|
89
|
+
let userTokenRow = db.get<any>(
|
|
90
|
+
"SELECT token_id FROM api_tokens WHERE user_id = ?1 AND scope = 'user' ORDER BY created_at DESC LIMIT 1",
|
|
91
|
+
user.user_id);
|
|
92
|
+
|
|
93
|
+
const userToken = generateUserToken();
|
|
94
|
+
if (userTokenRow) {
|
|
95
|
+
db.run("UPDATE api_tokens SET token_hash = ?1, last_used_at = datetime('now') WHERE token_id = ?2",
|
|
96
|
+
[hashToken(userToken), userTokenRow.token_id]);
|
|
97
|
+
} else {
|
|
98
|
+
const tokenId = generateId("tok");
|
|
99
|
+
db.run(
|
|
100
|
+
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
101
|
+
[tokenId, hashToken(userToken), user.user_id, null, "user-login", "user"]
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Find default network
|
|
106
|
+
const defaultNet = db.get<any>(
|
|
107
|
+
"SELECT network_id FROM network_members WHERE user_id = ?1 ORDER BY role = 'owner' DESC LIMIT 1",
|
|
108
|
+
user.user_id);
|
|
109
|
+
const networkId = defaultNet?.network_id || null;
|
|
110
|
+
|
|
111
|
+
// Backward compat: also try old atok_ tokens
|
|
112
|
+
const token = userToken;
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
ok: true,
|
|
116
|
+
user: { user_id: user.user_id, username: user.username, display_name: user.display_name, email: user.email, role: user.role },
|
|
117
|
+
token,
|
|
118
|
+
network_id: networkId,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Create a network-scoped token (ntok_) for a specific node */
|
|
123
|
+
export function createNetworkTokenForNode(userId: string, networkId: string, nodeName: string): { ok: boolean; token?: string; error?: string } {
|
|
124
|
+
// Verify user is a member of this network with write access
|
|
125
|
+
const role = getUserNetworkRole(userId, networkId);
|
|
126
|
+
if (!role || role === "viewer") return { ok: false, error: "no write access to this network" };
|
|
127
|
+
const token = generateNetworkToken();
|
|
128
|
+
const tokenId = generateId("tok");
|
|
129
|
+
db.run(
|
|
130
|
+
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
131
|
+
[tokenId, hashToken(token), userId, networkId, `node:${nodeName}`, "network"]
|
|
132
|
+
);
|
|
133
|
+
return { ok: true, token };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function resolveToken(token: string): { user: AuthUser; networkId: string | null } | null {
|
|
137
|
+
const tHash = hashToken(token);
|
|
138
|
+
const row = db.get<any>(
|
|
139
|
+
`SELECT t.user_id, t.network_id, t.scope, u.username, u.display_name, u.email, u.role
|
|
140
|
+
FROM api_tokens t JOIN users u ON t.user_id = u.user_id
|
|
141
|
+
WHERE t.token_hash = ?1 AND (t.expires_at IS NULL OR t.expires_at > datetime('now'))`,
|
|
142
|
+
tHash);
|
|
143
|
+
|
|
144
|
+
if (!row) return null;
|
|
145
|
+
|
|
146
|
+
// Update last_used
|
|
147
|
+
db.run("UPDATE api_tokens SET last_used_at = datetime('now') WHERE token_hash = ?1", [tHash]);
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
user: { user_id: row.user_id, username: row.username, display_name: row.display_name, email: row.email, role: row.role },
|
|
151
|
+
networkId: row.network_id,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function getUserNetworks(userId: string) {
|
|
156
|
+
return db.all<any>(
|
|
157
|
+
"SELECT * FROM networks WHERE owner_id = ?1 ORDER BY created_at",
|
|
158
|
+
userId);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Quota limits by plan
|
|
162
|
+
const QUOTAS: Record<string, { max_networks_owned: number; max_networks_joined: number }> = {
|
|
163
|
+
free: { max_networks_owned: 2, max_networks_joined: 3 },
|
|
164
|
+
pro: { max_networks_owned: 10, max_networks_joined: 20 },
|
|
165
|
+
admin: { max_networks_owned: Infinity, max_networks_joined: Infinity },
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
export function createNetwork(userId: string, name: string, description?: string) {
|
|
169
|
+
// Quota check
|
|
170
|
+
const user = db.get<any>("SELECT plan, role FROM users WHERE user_id = ?1", userId);
|
|
171
|
+
const plan = user?.role === "admin" ? "admin" : (user?.plan || "free");
|
|
172
|
+
const quota = QUOTAS[plan] || QUOTAS.free;
|
|
173
|
+
const ownedCount = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM networks WHERE owner_id = ?1", userId);
|
|
174
|
+
if ((ownedCount?.cnt || 0) >= quota.max_networks_owned) {
|
|
175
|
+
return { ok: false, error: `quota exceeded: max ${quota.max_networks_owned} networks for ${plan} plan` };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const existing = db.get<any>(
|
|
179
|
+
"SELECT network_id FROM networks WHERE owner_id = ?1 AND network_name = ?2",
|
|
180
|
+
userId, name);
|
|
181
|
+
if (existing) return { ok: false, error: "network name already exists" };
|
|
182
|
+
|
|
183
|
+
const networkId = generateId("net");
|
|
184
|
+
db.run(
|
|
185
|
+
"INSERT INTO networks (network_id, network_name, owner_id, description) VALUES (?1, ?2, ?3, ?4)",
|
|
186
|
+
[networkId, name, userId, description || null]
|
|
187
|
+
);
|
|
188
|
+
db.run(
|
|
189
|
+
"INSERT INTO network_members (network_id, user_id, role) VALUES (?1, ?2, 'owner')",
|
|
190
|
+
[networkId, userId]
|
|
191
|
+
);
|
|
192
|
+
return { ok: true, network_id: networkId, network_name: name };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function listTokens(userId: string) {
|
|
196
|
+
return db.all<any>(
|
|
197
|
+
"SELECT token_id, name, scope, network_id, last_used_at, created_at FROM api_tokens WHERE user_id = ?1 ORDER BY created_at DESC",
|
|
198
|
+
userId);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function renameNetwork(userId: string, networkId: string, newName: string): { ok: boolean; error?: string } {
|
|
202
|
+
const net = db.get<any>("SELECT * FROM networks WHERE network_id = ?1", networkId);
|
|
203
|
+
if (!net) return { ok: false, error: "network not found" };
|
|
204
|
+
if (net.owner_id !== userId) return { ok: false, error: "not your network" };
|
|
205
|
+
const dup = db.get<any>("SELECT network_id FROM networks WHERE owner_id = ?1 AND network_name = ?2", userId, newName);
|
|
206
|
+
if (dup) return { ok: false, error: "name already taken" };
|
|
207
|
+
db.run("UPDATE networks SET network_name = ?1, updated_at = datetime('now') WHERE network_id = ?2", [newName, networkId]);
|
|
208
|
+
return { ok: true };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function deleteNetwork(userId: string, networkId: string): { ok: boolean; error?: string } {
|
|
212
|
+
const net = db.get<any>("SELECT * FROM networks WHERE network_id = ?1", networkId);
|
|
213
|
+
if (!net) return { ok: false, error: "network not found" };
|
|
214
|
+
if (net.owner_id !== userId) return { ok: false, error: "not your network" };
|
|
215
|
+
// Check if any sessions/tasks still reference this network
|
|
216
|
+
const sessions = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM sessions WHERE network_id = ?1", networkId);
|
|
217
|
+
if (sessions && sessions.cnt > 0) return { ok: false, error: `network has ${sessions.cnt} active session(s) — stop them first` };
|
|
218
|
+
db.run("DELETE FROM networks WHERE network_id = ?1 AND owner_id = ?2", [networkId, userId]);
|
|
219
|
+
return { ok: true };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function createToken(userId: string, name: string, networkId?: string): { ok: boolean; token?: string; token_id?: string; error?: string } {
|
|
223
|
+
// Security: verify user is a member of the target network
|
|
224
|
+
if (networkId) {
|
|
225
|
+
const role = getUserNetworkRole(userId, networkId);
|
|
226
|
+
if (!role) return { ok: false, error: "not a member of this network" };
|
|
227
|
+
if (role === "viewer") return { ok: false, error: "viewer cannot create full-access network tokens" };
|
|
228
|
+
}
|
|
229
|
+
const token = generateToken();
|
|
230
|
+
const tokenId = generateId("tok");
|
|
231
|
+
db.run(
|
|
232
|
+
"INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
233
|
+
[tokenId, hashToken(token), userId, networkId || null, name, "full"]
|
|
234
|
+
);
|
|
235
|
+
return { ok: true, token, token_id: tokenId };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export function revokeToken(userId: string, tokenId: string): { ok: boolean; error?: string } {
|
|
239
|
+
const result = db.run("DELETE FROM api_tokens WHERE token_id = ?1 AND user_id = ?2", [tokenId, userId]);
|
|
240
|
+
return result.changes > 0 ? { ok: true } : { ok: false, error: "token not found" };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function changePassword(userId: string, oldPassword: string, newPassword: string): { ok: boolean; error?: string } {
|
|
244
|
+
if (!newPassword || newPassword.length < 6) return { ok: false, error: "new password must be at least 6 characters" };
|
|
245
|
+
const user = db.get<any>("SELECT password_hash FROM users WHERE user_id = ?1", userId);
|
|
246
|
+
if (!user) return { ok: false, error: "user not found" };
|
|
247
|
+
if (user.password_hash !== hashPassword(oldPassword)) return { ok: false, error: "incorrect current password" };
|
|
248
|
+
db.run("UPDATE users SET password_hash = ?1, updated_at = datetime('now') WHERE user_id = ?2", [hashPassword(newPassword), userId]);
|
|
249
|
+
return { ok: true };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ══════════════════════════════════════
|
|
253
|
+
// V3.13: Network Members
|
|
254
|
+
// ══════════════════════════════════════
|
|
255
|
+
|
|
256
|
+
export function getNetworkMembers(networkId: string) {
|
|
257
|
+
return db.all<any>(
|
|
258
|
+
`SELECT nm.user_id, nm.role, nm.joined_at, nm.invited_by, u.username, u.display_name
|
|
259
|
+
FROM network_members nm JOIN users u ON nm.user_id = u.user_id
|
|
260
|
+
WHERE nm.network_id = ?1 ORDER BY nm.joined_at`,
|
|
261
|
+
networkId);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function getUserNetworkRole(userId: string, networkId: string): string | null {
|
|
265
|
+
const row = db.get<any>("SELECT role FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId);
|
|
266
|
+
return row?.role || null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function addNetworkMember(networkId: string, userId: string, role: string, invitedBy?: string): { ok: boolean; error?: string } {
|
|
270
|
+
const existing = db.get<any>("SELECT 1 FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId);
|
|
271
|
+
if (existing) return { ok: false, error: "user already a member" };
|
|
272
|
+
db.run("INSERT INTO network_members (network_id, user_id, role, invited_by) VALUES (?1, ?2, ?3, ?4)",
|
|
273
|
+
[networkId, userId, role, invitedBy || null]);
|
|
274
|
+
return { ok: true };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function updateMemberRole(networkId: string, userId: string, newRole: string): { ok: boolean; error?: string } {
|
|
278
|
+
if (newRole === "owner") return { ok: false, error: "cannot assign owner role" };
|
|
279
|
+
const result = db.run("UPDATE network_members SET role = ?1 WHERE network_id = ?2 AND user_id = ?3 AND role != 'owner'",
|
|
280
|
+
[newRole, networkId, userId]);
|
|
281
|
+
return result.changes > 0 ? { ok: true } : { ok: false, error: "member not found or is owner" };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function removeNetworkMember(networkId: string, userId: string): { ok: boolean; error?: string } {
|
|
285
|
+
const member = db.get<any>("SELECT role FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId);
|
|
286
|
+
if (!member) return { ok: false, error: "not a member" };
|
|
287
|
+
if (member.role === "owner") return { ok: false, error: "cannot remove owner" };
|
|
288
|
+
db.run("DELETE FROM network_members WHERE network_id = ?1 AND user_id = ?2", [networkId, userId]);
|
|
289
|
+
return { ok: true };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ══════════════════════════════════════
|
|
293
|
+
// V3.13: Invite Codes
|
|
294
|
+
// ══════════════════════════════════════
|
|
295
|
+
|
|
296
|
+
export function createInvite(networkId: string, createdBy: string, role: string = "member", maxUses: number = 1, expiresInDays?: number): { ok: boolean; invite_code?: string; error?: string } {
|
|
297
|
+
if (!["admin", "member", "viewer"].includes(role)) return { ok: false, error: "invalid role" };
|
|
298
|
+
const code = `inv_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
|
299
|
+
const expiresAt = expiresInDays ? `datetime('now', '+${expiresInDays} days')` : null;
|
|
300
|
+
if (expiresAt) {
|
|
301
|
+
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))",
|
|
302
|
+
[code, networkId, role, createdBy, maxUses, `+${expiresInDays} days`]);
|
|
303
|
+
} else {
|
|
304
|
+
db.run("INSERT INTO network_invites (invite_code, network_id, role, created_by, max_uses) VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
305
|
+
[code, networkId, role, createdBy, maxUses]);
|
|
306
|
+
}
|
|
307
|
+
return { ok: true, invite_code: code };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function joinByInvite(inviteCode: string, userId: string): { ok: boolean; network_id?: string; role?: string; error?: string } {
|
|
311
|
+
const invite = db.get<any>("SELECT * FROM network_invites WHERE invite_code = ?1", inviteCode);
|
|
312
|
+
if (!invite) return { ok: false, error: "invalid invite code" };
|
|
313
|
+
if (invite.max_uses > 0 && invite.used_count >= invite.max_uses) return { ok: false, error: "invite code fully used" };
|
|
314
|
+
if (invite.expires_at) {
|
|
315
|
+
const now = new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
316
|
+
if (invite.expires_at < now) return { ok: false, error: "invite code expired" };
|
|
317
|
+
}
|
|
318
|
+
// Check not already member
|
|
319
|
+
const existing = db.get<any>("SELECT 1 FROM network_members WHERE network_id = ?1 AND user_id = ?2", invite.network_id, userId);
|
|
320
|
+
if (existing) return { ok: false, error: "already a member of this network" };
|
|
321
|
+
// Add member + increment used count
|
|
322
|
+
db.run("INSERT INTO network_members (network_id, user_id, role, invited_by) VALUES (?1, ?2, ?3, ?4)",
|
|
323
|
+
[invite.network_id, userId, invite.role, invite.created_by]);
|
|
324
|
+
db.run("UPDATE network_invites SET used_count = used_count + 1 WHERE invite_code = ?1", [inviteCode]);
|
|
325
|
+
// Auto-create a token for this network
|
|
326
|
+
const token = generateToken();
|
|
327
|
+
const tokenId = generateId("tok");
|
|
328
|
+
db.run("INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
329
|
+
[tokenId, hashToken(token), userId, invite.network_id, "auto-join", "full"]);
|
|
330
|
+
return { ok: true, network_id: invite.network_id, role: invite.role };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Get all networks a user is a member of (replaces owner-only query) */
|
|
334
|
+
export function getUserAllNetworks(userId: string) {
|
|
335
|
+
return db.all<any>(
|
|
336
|
+
`SELECT n.*, nm.role as member_role
|
|
337
|
+
FROM networks n JOIN network_members nm ON n.network_id = nm.network_id
|
|
338
|
+
WHERE nm.user_id = ?1 ORDER BY nm.role = 'owner' DESC, n.created_at`,
|
|
339
|
+
userId);
|
|
340
|
+
}
|