(req: http.IncomingMessage, res: http.ServerResponse)
| 271 | } |
| 272 | |
| 273 | private async handle(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> { |
| 274 | const url = new URL(req.url || "/", `http://127.0.0.1:${this.port}`); |
| 275 | const routePath = url.pathname; |
| 276 | |
| 277 | if (req.method === "GET" && routePath === "/api/v1/hub/info") { |
| 278 | return this.json(res, 200, { |
| 279 | teamName: this.teamName, |
| 280 | version: "0.0.0", |
| 281 | apiVersion: "v1", |
| 282 | hubInstanceId: this.hubInstanceId, |
| 283 | }); |
| 284 | } |
| 285 | |
| 286 | if (req.method === "POST" && routePath === "/api/v1/hub/join") { |
| 287 | const body = await this.readJson(req); |
| 288 | if (!body || body.teamToken !== this.teamToken) { |
| 289 | return this.json(res, 403, { error: "invalid_team_token" }); |
| 290 | } |
| 291 | const username = String(body.username || `user-${randomUUID().slice(0, 8)}`); |
| 292 | const joinIp = (typeof body.clientIp === "string" && body.clientIp) |
| 293 | || (req.headers["x-client-ip"] as string)?.trim() |
| 294 | || (req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim() |
| 295 | || req.socket.remoteAddress || ""; |
| 296 | const identityKey = typeof body.identityKey === "string" ? body.identityKey.trim() : ""; |
| 297 | const dryRun = body.dryRun === true; |
| 298 | |
| 299 | const identityMatch = identityKey |
| 300 | ? this.userManager.findByIdentityKey(identityKey) |
| 301 | : null; |
| 302 | |
| 303 | if (identityMatch) { |
| 304 | if (!dryRun) { |
| 305 | try { this.opts.store.updateHubUserActivity(identityMatch.id, joinIp); } catch { /* best-effort */ } |
| 306 | } |
| 307 | |
| 308 | if (identityMatch.status === "active") { |
| 309 | if (dryRun) return this.json(res, 200, { status: "active", dryRun: true }); |
| 310 | const token = issueUserToken( |
| 311 | { userId: identityMatch.id, username: identityMatch.username, role: identityMatch.role, status: "active" }, |
| 312 | this.authSecret, |
| 313 | ); |
| 314 | this.userManager.approveUser(identityMatch.id, token); |
| 315 | return this.json(res, 200, { status: "active", userId: identityMatch.id, userToken: token, identityKey: identityMatch.identityKey || identityKey }); |
| 316 | } |
| 317 | if (identityMatch.status === "pending") { |
| 318 | if (dryRun) return this.json(res, 200, { status: "pending", dryRun: true }); |
| 319 | this.notifyAdmins("user_join_request", "user", identityMatch.username, "", { dedup: true }); |
| 320 | return this.json(res, 200, { status: "pending", userId: identityMatch.id, identityKey: identityMatch.identityKey || identityKey }); |
| 321 | } |
| 322 | if (identityMatch.status === "rejected") { |
| 323 | if (dryRun) return this.json(res, 200, { status: "rejected", dryRun: true }); |
| 324 | if (body.reapply === true) { |
| 325 | this.userManager.resetToPending(identityMatch.id); |
| 326 | this.notifyAdmins("user_join_request", "user", identityMatch.username, ""); |
| 327 | this.opts.log.info(`Hub: rejected user "${identityMatch.username}" (${identityMatch.id}) re-applied, reset to pending`); |
| 328 | return this.json(res, 200, { status: "pending", userId: identityMatch.id, identityKey: identityMatch.identityKey || identityKey }); |
| 329 | } |
| 330 | return this.json(res, 200, { status: "rejected", userId: identityMatch.id }); |
no test coverage detected