@zshuangmu/agenthub 0.4.15 → 0.4.16
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/LICENSE +21 -21
- package/README.md +268 -268
- package/package.json +41 -41
- package/src/api-server.js +518 -244
- package/src/cli.js +714 -671
- package/src/commands/api.js +9 -9
- package/src/commands/doctor.js +335 -335
- package/src/commands/info.js +15 -15
- package/src/commands/install.js +56 -56
- package/src/commands/list.js +78 -78
- package/src/commands/pack.js +249 -156
- package/src/commands/publish-remote.js +9 -9
- package/src/commands/publish.js +7 -7
- package/src/commands/rollback.js +59 -59
- package/src/commands/search.js +14 -14
- package/src/commands/serve.js +9 -9
- package/src/commands/stats.js +105 -105
- package/src/commands/uninstall.js +76 -76
- package/src/commands/update.js +54 -54
- package/src/commands/verify.js +133 -133
- package/src/commands/versions.js +75 -75
- package/src/commands/web.js +9 -9
- package/src/index.js +18 -18
- package/src/lib/auth.js +301 -0
- package/src/lib/bundle-transfer.js +58 -58
- package/src/lib/colors.js +60 -60
- package/src/lib/database.js +450 -244
- package/src/lib/debug.js +135 -135
- package/src/lib/fs-utils.js +107 -50
- package/src/lib/html.js +2163 -2163
- package/src/lib/http.js +168 -168
- package/src/lib/install.js +60 -60
- package/src/lib/manifest.js +124 -124
- package/src/lib/openclaw-config.js +40 -40
- package/src/lib/permissions.js +105 -0
- package/src/lib/privacy-engine.js +220 -0
- package/src/lib/registry.js +130 -130
- package/src/lib/remote.js +11 -11
- package/src/lib/security-scanner.js +233 -233
- package/src/lib/signing.js +158 -0
- package/src/lib/version-manager.js +77 -77
- package/src/server.js +176 -176
- package/src/web-server.js +135 -135
package/src/api-server.js
CHANGED
|
@@ -1,244 +1,518 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
const manifest = await
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
}
|
|
191
|
-
const
|
|
192
|
-
await
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import { infoCommand, installCommand, publishCommand, searchCommand } from "./index.js";
|
|
6
|
+
import { publishUploadedBundle, serializeBundleDir } from "./lib/bundle-transfer.js";
|
|
7
|
+
import { notFound, readJsonBody, sendJson } from "./lib/http.js";
|
|
8
|
+
import {
|
|
9
|
+
initDatabase,
|
|
10
|
+
incrementDownloads,
|
|
11
|
+
getAgentDownloads,
|
|
12
|
+
getAgentsDownloads,
|
|
13
|
+
getTotalDownloads,
|
|
14
|
+
getDownloadRanking,
|
|
15
|
+
getRecentDownloads,
|
|
16
|
+
getDatabaseStats,
|
|
17
|
+
createUser,
|
|
18
|
+
findUserByUsername,
|
|
19
|
+
saveApiToken,
|
|
20
|
+
findTokenByHash,
|
|
21
|
+
listUserTokens,
|
|
22
|
+
revokeToken,
|
|
23
|
+
addAuditLog,
|
|
24
|
+
queryAuditLogs,
|
|
25
|
+
} from "./lib/database.js";
|
|
26
|
+
import {
|
|
27
|
+
hashPassword, verifyPassword, generateApiToken, hashToken, extractToken, hasScope, SCOPES, DEFAULT_SCOPES,
|
|
28
|
+
getConfiguredProviders, getOAuthAuthorizeUrl, exchangeCodeForToken, getOAuthUserInfo, isOAuthConfigured,
|
|
29
|
+
} from "./lib/auth.js";
|
|
30
|
+
|
|
31
|
+
// 安全配置
|
|
32
|
+
const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS?.split(",") || ["*"];
|
|
33
|
+
const MAX_UPLOAD_SIZE = parseInt(process.env.MAX_UPLOAD_SIZE || "10485760", 10); // 10MB default
|
|
34
|
+
const RATE_LIMIT_WINDOW = 60000; // 1 minute
|
|
35
|
+
const RATE_LIMIT_MAX = parseInt(process.env.RATE_LIMIT_MAX || "100", 10); // 100 requests per minute
|
|
36
|
+
|
|
37
|
+
// 简单的速率限制器
|
|
38
|
+
const rateLimiter = new Map();
|
|
39
|
+
let cleanupInterval = null;
|
|
40
|
+
|
|
41
|
+
function startRateLimiterCleanup() {
|
|
42
|
+
if (cleanupInterval) return;
|
|
43
|
+
cleanupInterval = setInterval(() => {
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
const windowStart = now - RATE_LIMIT_WINDOW;
|
|
46
|
+
for (const [ip, requests] of rateLimiter.entries()) {
|
|
47
|
+
const recentRequests = requests.filter(t => t > windowStart);
|
|
48
|
+
if (recentRequests.length === 0) {
|
|
49
|
+
rateLimiter.delete(ip);
|
|
50
|
+
} else if (recentRequests.length !== requests.length) {
|
|
51
|
+
rateLimiter.set(ip, recentRequests);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}, RATE_LIMIT_WINDOW);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function stopRateLimiterCleanup() {
|
|
58
|
+
if (cleanupInterval) {
|
|
59
|
+
clearInterval(cleanupInterval);
|
|
60
|
+
cleanupInterval = null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function checkRateLimit(ip) {
|
|
65
|
+
const now = Date.now();
|
|
66
|
+
const windowStart = now - RATE_LIMIT_WINDOW;
|
|
67
|
+
const requests = rateLimiter.get(ip) || [];
|
|
68
|
+
const recentRequests = requests.filter(t => t > windowStart);
|
|
69
|
+
if (recentRequests.length >= RATE_LIMIT_MAX) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
recentRequests.push(now);
|
|
73
|
+
rateLimiter.set(ip, recentRequests);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// slug 格式验证
|
|
78
|
+
function isValidSlug(slug) {
|
|
79
|
+
return /^[a-z0-9-]+$/.test(slug) && slug.length <= 100;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function createApiServer({ registryDir, port = 3000, host = "0.0.0.0" }) {
|
|
83
|
+
// 初始化数据库
|
|
84
|
+
await initDatabase(registryDir);
|
|
85
|
+
|
|
86
|
+
const server = http.createServer(async (request, response) => {
|
|
87
|
+
const origin = request.headers.origin || "*";
|
|
88
|
+
const allowedOrigin = ALLOWED_ORIGINS.includes("*") || ALLOWED_ORIGINS.includes(origin)
|
|
89
|
+
? (ALLOWED_ORIGINS.includes("*") ? "*" : origin)
|
|
90
|
+
: ALLOWED_ORIGINS[0];
|
|
91
|
+
|
|
92
|
+
// CORS 头
|
|
93
|
+
const corsHeaders = {
|
|
94
|
+
"Access-Control-Allow-Origin": allowedOrigin,
|
|
95
|
+
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
|
|
96
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-Api-Token",
|
|
97
|
+
"X-Content-Type-Options": "nosniff",
|
|
98
|
+
"X-Frame-Options": "DENY"
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// 速率限制检查
|
|
102
|
+
const clientIp = request.socket.remoteAddress;
|
|
103
|
+
if (!checkRateLimit(clientIp)) {
|
|
104
|
+
response.writeHead(429, { "Content-Type": "application/json", ...corsHeaders });
|
|
105
|
+
response.end(JSON.stringify({ error: "Too many requests" }));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 处理预检请求
|
|
110
|
+
if (request.method === "OPTIONS") {
|
|
111
|
+
response.writeHead(204, corsHeaders);
|
|
112
|
+
response.end();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
const url = new URL(request.url, "http://127.0.0.1");
|
|
118
|
+
|
|
119
|
+
// === Token 认证(可选,不强制) ===
|
|
120
|
+
let authenticatedUser = null;
|
|
121
|
+
const rawToken = extractToken(request);
|
|
122
|
+
if (rawToken) {
|
|
123
|
+
const tHash = hashToken(rawToken);
|
|
124
|
+
const tokenRecord = await findTokenByHash(registryDir, tHash);
|
|
125
|
+
if (tokenRecord) {
|
|
126
|
+
authenticatedUser = {
|
|
127
|
+
userId: tokenRecord.userId,
|
|
128
|
+
username: tokenRecord.username,
|
|
129
|
+
role: tokenRecord.role,
|
|
130
|
+
scopes: tokenRecord.scopes,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// API: 获取 AgentHub Discover Skill
|
|
136
|
+
if (url.pathname === "/api/skills/agenthub-discover") {
|
|
137
|
+
try {
|
|
138
|
+
const skillPath = path.join(process.cwd(), "skills", "agenthub-discover", "SKILL.md");
|
|
139
|
+
const content = await readFile(skillPath, "utf8");
|
|
140
|
+
response.writeHead(200, {
|
|
141
|
+
"Content-Type": "text/markdown; charset=utf-8",
|
|
142
|
+
...corsHeaders
|
|
143
|
+
});
|
|
144
|
+
response.end(content);
|
|
145
|
+
} catch {
|
|
146
|
+
response.writeHead(404, { "Content-Type": "application/json", ...corsHeaders });
|
|
147
|
+
response.end(JSON.stringify({ error: "Skill not found" }));
|
|
148
|
+
}
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// API: 获取 Agent 列表
|
|
153
|
+
if (url.pathname === "/api/agents") {
|
|
154
|
+
const agents = await searchCommand(url.searchParams.get("q") ?? "", { registry: registryDir });
|
|
155
|
+
const slugs = agents.map(a => a.slug);
|
|
156
|
+
const downloads = await getAgentsDownloads(registryDir, slugs);
|
|
157
|
+
const agentsWithDownloads = agents.map(a => ({ ...a, downloads: downloads[a.slug] || 0 }));
|
|
158
|
+
sendJson(response, 200, { agents: agentsWithDownloads }, corsHeaders);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// API: 下载 Agent Bundle(远程安装)
|
|
163
|
+
if (url.pathname.startsWith("/api/agents/") && url.pathname.endsWith("/download")) {
|
|
164
|
+
const slug = url.pathname.slice("/api/agents/".length, -"/download".length);
|
|
165
|
+
if (!isValidSlug(slug)) {
|
|
166
|
+
sendJson(response, 400, { error: "Invalid slug format" }, corsHeaders);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const version = url.searchParams.get("version") || undefined;
|
|
171
|
+
const manifest = await infoCommand(version ? `${slug}:${version}` : slug, { registry: registryDir });
|
|
172
|
+
const bundleDir = path.join(registryDir, "agents", manifest.slug, manifest.version);
|
|
173
|
+
const payload = await serializeBundleDir(bundleDir);
|
|
174
|
+
// 记录下载(包含元数据)
|
|
175
|
+
await incrementDownloads(registryDir, manifest.slug, {
|
|
176
|
+
ip: request.socket.remoteAddress,
|
|
177
|
+
userAgent: request.headers['user-agent']
|
|
178
|
+
});
|
|
179
|
+
sendJson(response, 200, payload, corsHeaders);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// API: 获取单个 Agent 详情
|
|
184
|
+
if (url.pathname.startsWith("/api/agents/")) {
|
|
185
|
+
const slug = url.pathname.slice("/api/agents/".length);
|
|
186
|
+
// 安全检查:验证 slug 格式
|
|
187
|
+
if (!isValidSlug(slug)) {
|
|
188
|
+
sendJson(response, 400, { error: "Invalid slug format" }, corsHeaders);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const manifest = await infoCommand(slug, { registry: registryDir });
|
|
192
|
+
const downloads = await getAgentDownloads(registryDir, slug);
|
|
193
|
+
sendJson(response, 200, { ...manifest, downloads }, corsHeaders);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// API: 发布 Agent(含审计日志)
|
|
198
|
+
if (url.pathname === "/api/publish" && request.method === "POST") {
|
|
199
|
+
const body = await readJsonBody(request);
|
|
200
|
+
const manifest = await publishCommand(body.bundleDir, { registry: registryDir });
|
|
201
|
+
await addAuditLog(registryDir, {
|
|
202
|
+
username: authenticatedUser?.username,
|
|
203
|
+
action: "publish",
|
|
204
|
+
resource: `${manifest.slug}@${manifest.version}`,
|
|
205
|
+
ipAddress: clientIp,
|
|
206
|
+
});
|
|
207
|
+
sendJson(response, 200, manifest, corsHeaders);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// API: 上传发布 Agent(含审计日志)
|
|
212
|
+
if (url.pathname === "/api/publish-upload" && request.method === "POST") {
|
|
213
|
+
const body = await readJsonBody(request);
|
|
214
|
+
const manifest = await publishUploadedBundle({ payload: body, registryDir });
|
|
215
|
+
await addAuditLog(registryDir, {
|
|
216
|
+
username: authenticatedUser?.username,
|
|
217
|
+
action: "publish-upload",
|
|
218
|
+
resource: `${manifest.slug}@${manifest.version}`,
|
|
219
|
+
ipAddress: clientIp,
|
|
220
|
+
});
|
|
221
|
+
sendJson(response, 200, manifest, corsHeaders);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// API: 安装 Agent(记录下载)
|
|
226
|
+
if (url.pathname === "/api/install" && request.method === "POST") {
|
|
227
|
+
const body = await readJsonBody(request);
|
|
228
|
+
const result = await installCommand(body.agent, {
|
|
229
|
+
registry: registryDir,
|
|
230
|
+
targetWorkspace: body.targetWorkspace,
|
|
231
|
+
});
|
|
232
|
+
const slug = body.agent.split(":")[0];
|
|
233
|
+
await incrementDownloads(registryDir, slug, {
|
|
234
|
+
targetWorkspace: body.targetWorkspace,
|
|
235
|
+
ip: request.socket.remoteAddress,
|
|
236
|
+
userAgent: request.headers['user-agent']
|
|
237
|
+
});
|
|
238
|
+
sendJson(response, 200, result, corsHeaders);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// API: 获取下载统计
|
|
243
|
+
if (url.pathname === "/api/stats") {
|
|
244
|
+
const stats = await getDatabaseStats(registryDir);
|
|
245
|
+
const ranking = await getDownloadRanking(registryDir, 10);
|
|
246
|
+
const recent = await getRecentDownloads(registryDir, 20);
|
|
247
|
+
sendJson(response, 200, { stats, ranking, recent }, corsHeaders);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// API: 获取下载排行
|
|
252
|
+
if (url.pathname === "/api/stats/ranking") {
|
|
253
|
+
const limit = parseInt(url.searchParams.get("limit") || "10", 10);
|
|
254
|
+
const ranking = await getDownloadRanking(registryDir, limit);
|
|
255
|
+
sendJson(response, 200, { ranking }, corsHeaders);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// API: 健康检查
|
|
260
|
+
if (url.pathname === "/api/health") {
|
|
261
|
+
sendJson(response, 200, { status: "ok", timestamp: new Date().toISOString() }, corsHeaders);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// === P1: 认证 API ===
|
|
266
|
+
|
|
267
|
+
// API: 用户注册
|
|
268
|
+
if (url.pathname === "/api/auth/register" && request.method === "POST") {
|
|
269
|
+
const body = await readJsonBody(request);
|
|
270
|
+
if (!body.username || !body.password) {
|
|
271
|
+
sendJson(response, 400, { error: "username and password are required" }, corsHeaders);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (body.username.length < 3 || body.username.length > 50) {
|
|
275
|
+
sendJson(response, 400, { error: "username must be 3-50 characters" }, corsHeaders);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (body.password.length < 6) {
|
|
279
|
+
sendJson(response, 400, { error: "password must be at least 6 characters" }, corsHeaders);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const existing = await findUserByUsername(registryDir, body.username);
|
|
283
|
+
if (existing) {
|
|
284
|
+
sendJson(response, 409, { error: "username already exists" }, corsHeaders);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const passwordHash = hashPassword(body.password);
|
|
288
|
+
const user = await createUser(registryDir, {
|
|
289
|
+
username: body.username,
|
|
290
|
+
passwordHash,
|
|
291
|
+
email: body.email,
|
|
292
|
+
});
|
|
293
|
+
// 自动生成初始 Token
|
|
294
|
+
const { token, tokenHash } = generateApiToken();
|
|
295
|
+
await saveApiToken(registryDir, { userId: user.id, tokenHash, label: "initial", scopes: DEFAULT_SCOPES });
|
|
296
|
+
await addAuditLog(registryDir, { userId: user.id, username: user.username, action: "register", ipAddress: clientIp });
|
|
297
|
+
sendJson(response, 201, { user: { id: user.id, username: user.username }, token }, corsHeaders);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// API: 用户登录
|
|
302
|
+
if (url.pathname === "/api/auth/login" && request.method === "POST") {
|
|
303
|
+
const body = await readJsonBody(request);
|
|
304
|
+
if (!body.username || !body.password) {
|
|
305
|
+
sendJson(response, 400, { error: "username and password are required" }, corsHeaders);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const user = await findUserByUsername(registryDir, body.username);
|
|
309
|
+
if (!user || !verifyPassword(body.password, user.passwordHash)) {
|
|
310
|
+
sendJson(response, 401, { error: "invalid credentials" }, corsHeaders);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
// 生成新 Token
|
|
314
|
+
const { token, tokenHash } = generateApiToken();
|
|
315
|
+
await saveApiToken(registryDir, { userId: user.id, tokenHash, label: body.label || "login", scopes: DEFAULT_SCOPES });
|
|
316
|
+
await addAuditLog(registryDir, { userId: user.id, username: user.username, action: "login", ipAddress: clientIp });
|
|
317
|
+
sendJson(response, 200, { user: { id: user.id, username: user.username, role: user.role }, token }, corsHeaders);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// API: 查看当前用户信息
|
|
322
|
+
if (url.pathname === "/api/auth/me" && request.method === "GET") {
|
|
323
|
+
if (!authenticatedUser) {
|
|
324
|
+
sendJson(response, 401, { error: "Authentication required" }, corsHeaders);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const tokens = await listUserTokens(registryDir, authenticatedUser.userId);
|
|
328
|
+
sendJson(response, 200, { user: authenticatedUser, tokens }, corsHeaders);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// API: 生成新 Token
|
|
333
|
+
if (url.pathname === "/api/auth/tokens" && request.method === "POST") {
|
|
334
|
+
if (!authenticatedUser) {
|
|
335
|
+
sendJson(response, 401, { error: "Authentication required" }, corsHeaders);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const body = await readJsonBody(request);
|
|
339
|
+
const { token, tokenHash } = generateApiToken();
|
|
340
|
+
await saveApiToken(registryDir, {
|
|
341
|
+
userId: authenticatedUser.userId,
|
|
342
|
+
tokenHash,
|
|
343
|
+
label: body.label || "api",
|
|
344
|
+
scopes: body.scopes || DEFAULT_SCOPES,
|
|
345
|
+
});
|
|
346
|
+
sendJson(response, 201, { token, label: body.label || "api" }, corsHeaders);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// API: 吊销 Token
|
|
351
|
+
if (url.pathname.startsWith("/api/auth/tokens/") && request.method === "DELETE") {
|
|
352
|
+
if (!authenticatedUser) {
|
|
353
|
+
sendJson(response, 401, { error: "Authentication required" }, corsHeaders);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const tokenId = parseInt(url.pathname.split("/").pop(), 10);
|
|
357
|
+
await revokeToken(registryDir, tokenId);
|
|
358
|
+
sendJson(response, 200, { revoked: true }, corsHeaders);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// API: 查询审计日志
|
|
363
|
+
if (url.pathname === "/api/audit" && request.method === "GET") {
|
|
364
|
+
if (!authenticatedUser || authenticatedUser.role !== "admin") {
|
|
365
|
+
sendJson(response, 403, { error: "Admin access required" }, corsHeaders);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
const logs = await queryAuditLogs(registryDir, {
|
|
369
|
+
action: url.searchParams.get("action") || undefined,
|
|
370
|
+
username: url.searchParams.get("username") || undefined,
|
|
371
|
+
limit: parseInt(url.searchParams.get("limit") || "50", 10),
|
|
372
|
+
});
|
|
373
|
+
sendJson(response, 200, { logs }, corsHeaders);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// === OAuth 路由 ===
|
|
378
|
+
|
|
379
|
+
// API: 获取可用的 OAuth 提供商
|
|
380
|
+
if (url.pathname === "/api/auth/providers" && request.method === "GET") {
|
|
381
|
+
const providers = getConfiguredProviders().map((p) => ({
|
|
382
|
+
id: p,
|
|
383
|
+
name: p === "github" ? "GitHub" : "Google",
|
|
384
|
+
loginUrl: `/api/auth/oauth/${p}`,
|
|
385
|
+
}));
|
|
386
|
+
sendJson(response, 200, { providers, localAuth: true }, corsHeaders);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// API: OAuth 授权跳转
|
|
391
|
+
if (url.pathname.startsWith("/api/auth/oauth/") && !url.pathname.includes("/callback") && request.method === "GET") {
|
|
392
|
+
const provider = url.pathname.split("/").pop();
|
|
393
|
+
if (!isOAuthConfigured(provider)) {
|
|
394
|
+
sendJson(response, 400, { error: `${provider} OAuth not configured. Set ${provider.toUpperCase()}_CLIENT_ID and ${provider.toUpperCase()}_CLIENT_SECRET environment variables.` }, corsHeaders);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const baseUrl = url.searchParams.get("redirect_base") || `http://${request.headers.host}`;
|
|
398
|
+
const redirectUri = `${baseUrl}/api/auth/callback/${provider}`;
|
|
399
|
+
const { url: authorizeUrl, state } = getOAuthAuthorizeUrl(provider, redirectUri);
|
|
400
|
+
// 重定向到 OAuth 提供商
|
|
401
|
+
response.writeHead(302, { Location: authorizeUrl, ...corsHeaders });
|
|
402
|
+
response.end();
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// API: OAuth 回调处理
|
|
407
|
+
if (url.pathname.startsWith("/api/auth/callback/") && request.method === "GET") {
|
|
408
|
+
const provider = url.pathname.split("/").pop();
|
|
409
|
+
const code = url.searchParams.get("code");
|
|
410
|
+
const error = url.searchParams.get("error");
|
|
411
|
+
|
|
412
|
+
if (error) {
|
|
413
|
+
sendJson(response, 400, { error: `OAuth denied: ${error}` }, corsHeaders);
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
if (!code) {
|
|
417
|
+
sendJson(response, 400, { error: "Missing authorization code" }, corsHeaders);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
try {
|
|
422
|
+
const baseUrl = `http://${request.headers.host}`;
|
|
423
|
+
const redirectUri = `${baseUrl}/api/auth/callback/${provider}`;
|
|
424
|
+
|
|
425
|
+
// 1. 用 code 换 access token
|
|
426
|
+
const accessToken = await exchangeCodeForToken(provider, code, redirectUri);
|
|
427
|
+
|
|
428
|
+
// 2. 获取用户信息
|
|
429
|
+
const oauthUser = await getOAuthUserInfo(provider, accessToken);
|
|
430
|
+
|
|
431
|
+
// 3. 查找或创建本地用户
|
|
432
|
+
const oauthUsername = `${provider}_${oauthUser.username}`;
|
|
433
|
+
let localUser = await findUserByUsername(registryDir, oauthUsername);
|
|
434
|
+
|
|
435
|
+
if (!localUser) {
|
|
436
|
+
// 自动注册 -- 密码用随机值(OAuth 用户不需要密码)
|
|
437
|
+
const randomPass = hashPassword(randomBytes(32).toString("hex"));
|
|
438
|
+
localUser = await createUser(registryDir, {
|
|
439
|
+
username: oauthUsername,
|
|
440
|
+
passwordHash: randomPass,
|
|
441
|
+
email: oauthUser.email,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// 4. 生成 AgentHub Token
|
|
446
|
+
const { token, tokenHash } = generateApiToken();
|
|
447
|
+
await saveApiToken(registryDir, {
|
|
448
|
+
userId: localUser.id,
|
|
449
|
+
tokenHash,
|
|
450
|
+
label: `${provider}-oauth`,
|
|
451
|
+
scopes: DEFAULT_SCOPES,
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
await addAuditLog(registryDir, {
|
|
455
|
+
userId: localUser.id,
|
|
456
|
+
username: oauthUsername,
|
|
457
|
+
action: `oauth-login-${provider}`,
|
|
458
|
+
details: { oauthId: oauthUser.id, email: oauthUser.email },
|
|
459
|
+
ipAddress: clientIp,
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
// 5. 返回结果(支持 JSON 和 HTML 跳转两种模式)
|
|
463
|
+
const acceptsJson = request.headers.accept?.includes("application/json");
|
|
464
|
+
if (acceptsJson) {
|
|
465
|
+
sendJson(response, 200, {
|
|
466
|
+
user: { id: localUser.id, username: oauthUsername, provider },
|
|
467
|
+
oauthProfile: { displayName: oauthUser.displayName, avatar: oauthUser.avatar, email: oauthUser.email },
|
|
468
|
+
token,
|
|
469
|
+
}, corsHeaders);
|
|
470
|
+
} else {
|
|
471
|
+
// HTML 模式:通过页面展示 Token 并引导用户复制
|
|
472
|
+
const html = `<!DOCTYPE html><html><head><title>AgentHub - Login Success</title>
|
|
473
|
+
<style>body{font-family:system-ui;max-width:500px;margin:80px auto;text-align:center}
|
|
474
|
+
.token{background:#1a1a2e;color:#0f0;padding:12px;border-radius:8px;word-break:break-all;font-family:monospace;font-size:14px;margin:16px 0}
|
|
475
|
+
.btn{background:#4f46e5;color:#fff;border:none;padding:10px 24px;border-radius:6px;cursor:pointer;font-size:16px}
|
|
476
|
+
.btn:hover{background:#4338ca}
|
|
477
|
+
.avatar{width:64px;height:64px;border-radius:50%;margin:12px}</style></head>
|
|
478
|
+
<body>
|
|
479
|
+
<h1>✅ 登录成功</h1>
|
|
480
|
+
<img class="avatar" src="${oauthUser.avatar || ''}" alt="avatar">
|
|
481
|
+
<p>欢迎,<strong>${oauthUser.displayName || oauthUsername}</strong></p>
|
|
482
|
+
<p>你的 API Token:</p>
|
|
483
|
+
<div class="token" id="token">${token}</div>
|
|
484
|
+
<button class="btn" onclick="navigator.clipboard.writeText(document.getElementById('token').textContent).then(()=>this.textContent='✅ 已复制')">📋 复制 Token</button>
|
|
485
|
+
<p style="color:#888;margin-top:24px">请妙善保管此 Token,它不会再次显示。</p>
|
|
486
|
+
<p style="color:#888">CLI 使用: <code>agenthub publish --token ${token.slice(0, 10)}...</code></p>
|
|
487
|
+
</body></html>`;
|
|
488
|
+
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8", ...corsHeaders });
|
|
489
|
+
response.end(html);
|
|
490
|
+
}
|
|
491
|
+
return;
|
|
492
|
+
} catch (oauthErr) {
|
|
493
|
+
sendJson(response, 500, { error: `OAuth failed: ${oauthErr.message}` }, corsHeaders);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
notFound(response, corsHeaders);
|
|
499
|
+
} catch (error) {
|
|
500
|
+
sendJson(response, 500, { error: error.message }, corsHeaders);
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
startRateLimiterCleanup();
|
|
505
|
+
await new Promise((resolve) => server.listen(port, host, resolve));
|
|
506
|
+
const address = server.address();
|
|
507
|
+
const actualPort = typeof address === "object" && address ? address.port : port;
|
|
508
|
+
|
|
509
|
+
return {
|
|
510
|
+
server,
|
|
511
|
+
port: actualPort,
|
|
512
|
+
baseUrl: `http://127.0.0.1:${actualPort}`,
|
|
513
|
+
close: () => new Promise((resolve, reject) => {
|
|
514
|
+
stopRateLimiterCleanup();
|
|
515
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
516
|
+
}),
|
|
517
|
+
};
|
|
518
|
+
}
|