@memtensor/memos-local-openclaw-plugin 1.0.4-beta.1 → 1.0.4-beta.11
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/.env.example +7 -0
- package/README.md +24 -24
- package/dist/capture/index.d.ts +1 -1
- package/dist/capture/index.d.ts.map +1 -1
- package/dist/capture/index.js +34 -2
- package/dist/capture/index.js.map +1 -1
- package/dist/client/connector.d.ts +2 -2
- package/dist/client/connector.d.ts.map +1 -1
- package/dist/client/connector.js +122 -26
- package/dist/client/connector.js.map +1 -1
- package/dist/client/hub.d.ts.map +1 -1
- package/dist/client/hub.js +22 -0
- package/dist/client/hub.js.map +1 -1
- package/dist/client/skill-sync.d.ts +7 -0
- package/dist/client/skill-sync.d.ts.map +1 -1
- package/dist/client/skill-sync.js +10 -0
- package/dist/client/skill-sync.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +0 -2
- package/dist/config.js.map +1 -1
- package/dist/hub/server.d.ts +8 -0
- package/dist/hub/server.d.ts.map +1 -1
- package/dist/hub/server.js +390 -106
- package/dist/hub/server.js.map +1 -1
- package/dist/hub/user-manager.d.ts +11 -0
- package/dist/hub/user-manager.d.ts.map +1 -1
- package/dist/hub/user-manager.js +31 -3
- package/dist/hub/user-manager.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -2
- package/dist/index.js.map +1 -1
- package/dist/ingest/providers/index.d.ts.map +1 -1
- package/dist/ingest/providers/index.js +37 -6
- package/dist/ingest/providers/index.js.map +1 -1
- package/dist/recall/engine.d.ts.map +1 -1
- package/dist/recall/engine.js +93 -1
- package/dist/recall/engine.js.map +1 -1
- package/dist/shared/llm-call.d.ts +1 -0
- package/dist/shared/llm-call.d.ts.map +1 -1
- package/dist/shared/llm-call.js +82 -8
- package/dist/shared/llm-call.js.map +1 -1
- package/dist/sharing/types.d.ts +1 -1
- package/dist/sharing/types.d.ts.map +1 -1
- package/dist/skill/evolver.d.ts +4 -0
- package/dist/skill/evolver.d.ts.map +1 -1
- package/dist/skill/evolver.js +59 -5
- package/dist/skill/evolver.js.map +1 -1
- package/dist/skill/generator.d.ts +2 -0
- package/dist/skill/generator.d.ts.map +1 -1
- package/dist/skill/generator.js +45 -3
- package/dist/skill/generator.js.map +1 -1
- package/dist/skill/installer.d.ts +26 -0
- package/dist/skill/installer.d.ts.map +1 -1
- package/dist/skill/installer.js +80 -4
- package/dist/skill/installer.js.map +1 -1
- package/dist/skill/upgrader.d.ts +2 -0
- package/dist/skill/upgrader.d.ts.map +1 -1
- package/dist/skill/upgrader.js +139 -1
- package/dist/skill/upgrader.js.map +1 -1
- package/dist/skill/validator.d.ts +3 -0
- package/dist/skill/validator.d.ts.map +1 -1
- package/dist/skill/validator.js +75 -0
- package/dist/skill/validator.js.map +1 -1
- package/dist/storage/ensure-binding.d.ts +12 -0
- package/dist/storage/ensure-binding.d.ts.map +1 -0
- package/dist/storage/ensure-binding.js +53 -0
- package/dist/storage/ensure-binding.js.map +1 -0
- package/dist/storage/sqlite.d.ts +89 -20
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +374 -124
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/telemetry.d.ts +12 -5
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +156 -40
- package/dist/telemetry.js.map +1 -1
- package/dist/tools/memory-search.d.ts +3 -1
- package/dist/tools/memory-search.d.ts.map +1 -1
- package/dist/tools/memory-search.js +3 -1
- package/dist/tools/memory-search.js.map +1 -1
- package/dist/types.d.ts +11 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +4 -0
- package/dist/types.js.map +1 -1
- package/dist/viewer/html.d.ts.map +1 -1
- package/dist/viewer/html.js +2671 -879
- package/dist/viewer/html.js.map +1 -1
- package/dist/viewer/server.d.ts +30 -8
- package/dist/viewer/server.d.ts.map +1 -1
- package/dist/viewer/server.js +990 -198
- package/dist/viewer/server.js.map +1 -1
- package/index.ts +700 -56
- package/openclaw.plugin.json +1 -1
- package/package.json +3 -2
- package/scripts/postinstall.cjs +1 -1
- package/skill/memos-memory-guide/SKILL.md +64 -26
- package/src/capture/index.ts +37 -1
- package/src/client/connector.ts +124 -28
- package/src/client/hub.ts +18 -0
- package/src/client/skill-sync.ts +14 -0
- package/src/config.ts +0 -2
- package/src/hub/server.ts +374 -97
- package/src/hub/user-manager.ts +48 -8
- package/src/index.ts +10 -2
- package/src/ingest/providers/index.ts +41 -7
- package/src/recall/engine.ts +86 -1
- package/src/shared/llm-call.ts +97 -9
- package/src/sharing/types.ts +1 -1
- package/src/skill/evolver.ts +63 -6
- package/src/skill/generator.ts +44 -5
- package/src/skill/installer.ts +107 -4
- package/src/skill/upgrader.ts +139 -1
- package/src/skill/validator.ts +79 -0
- package/src/storage/ensure-binding.ts +52 -0
- package/src/storage/sqlite.ts +395 -148
- package/src/telemetry.ts +172 -41
- package/src/tools/memory-search.ts +2 -1
- package/src/types.ts +12 -2
- package/src/viewer/html.ts +2671 -879
- package/src/viewer/server.ts +913 -182
package/src/hub/server.ts
CHANGED
|
@@ -34,6 +34,11 @@ export class HubServer {
|
|
|
34
34
|
private static readonly RATE_LIMIT_SEARCH = 30;
|
|
35
35
|
private rateBuckets = new Map<string, { count: number; windowStart: number }>();
|
|
36
36
|
|
|
37
|
+
private static readonly OFFLINE_THRESHOLD_MS = 2 * 60 * 1000;
|
|
38
|
+
private static readonly OFFLINE_CHECK_INTERVAL_MS = 30 * 1000;
|
|
39
|
+
private offlineCheckTimer?: ReturnType<typeof setInterval>;
|
|
40
|
+
private knownOnlineUsers = new Set<string>();
|
|
41
|
+
|
|
37
42
|
constructor(private opts: HubServerOptions) {
|
|
38
43
|
this.userManager = new HubUserManager(opts.store, opts.log);
|
|
39
44
|
this.authStatePath = path.join(opts.dataDir, "hub-auth.json");
|
|
@@ -101,10 +106,14 @@ export class HubServer {
|
|
|
101
106
|
this.opts.log.info(`memos-local: bootstrap admin token persisted to ${this.authStatePath}`);
|
|
102
107
|
}
|
|
103
108
|
|
|
109
|
+
this.initOnlineTracking();
|
|
110
|
+
this.offlineCheckTimer = setInterval(() => this.checkOfflineUsers(), HubServer.OFFLINE_CHECK_INTERVAL_MS);
|
|
111
|
+
|
|
104
112
|
return `http://127.0.0.1:${this.port}`;
|
|
105
113
|
}
|
|
106
114
|
|
|
107
115
|
async stop(): Promise<void> {
|
|
116
|
+
if (this.offlineCheckTimer) { clearInterval(this.offlineCheckTimer); this.offlineCheckTimer = undefined; }
|
|
108
117
|
if (!this.server) return;
|
|
109
118
|
const server = this.server;
|
|
110
119
|
this.server = undefined;
|
|
@@ -160,6 +169,20 @@ export class HubServer {
|
|
|
160
169
|
});
|
|
161
170
|
}
|
|
162
171
|
|
|
172
|
+
private embedSkillAsync(skillId: string, name: string, description: string, sourceUserId: string, sourceSkillId: string): void {
|
|
173
|
+
const embedder = this.opts.embedder;
|
|
174
|
+
if (!embedder) return;
|
|
175
|
+
const text = `${name}: ${description}`;
|
|
176
|
+
embedder.embed([text]).then((vectors) => {
|
|
177
|
+
if (vectors[0]) {
|
|
178
|
+
this.opts.store.upsertHubSkillEmbedding(skillId, Array.from(vectors[0]), sourceUserId, sourceSkillId);
|
|
179
|
+
this.opts.log.info(`hub: embedded shared skill ${skillId}`);
|
|
180
|
+
}
|
|
181
|
+
}).catch((err) => {
|
|
182
|
+
this.opts.log.warn(`hub: embedding shared skill failed: ${err}`);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
163
186
|
private embedMemoryAsync(memoryId: string, summary: string, content: string): void {
|
|
164
187
|
const embedder = this.opts.embedder;
|
|
165
188
|
if (!embedder) return;
|
|
@@ -192,32 +215,74 @@ export class HubServer {
|
|
|
192
215
|
return this.json(res, 403, { error: "invalid_team_token" });
|
|
193
216
|
}
|
|
194
217
|
const username = String(body.username || `user-${randomUUID().slice(0, 8)}`);
|
|
195
|
-
const
|
|
196
|
-
|
|
218
|
+
const joinIp = (typeof body.clientIp === "string" && body.clientIp)
|
|
219
|
+
|| (req.headers["x-client-ip"] as string)?.trim()
|
|
220
|
+
|| (req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim()
|
|
221
|
+
|| req.socket.remoteAddress || "";
|
|
222
|
+
const identityKey = typeof body.identityKey === "string" ? body.identityKey.trim() : "";
|
|
223
|
+
|
|
224
|
+
let existingUser = identityKey
|
|
225
|
+
? this.userManager.findByIdentityKey(identityKey)
|
|
226
|
+
: null;
|
|
227
|
+
if (!existingUser) {
|
|
228
|
+
const existingUsers = this.opts.store.listHubUsers();
|
|
229
|
+
existingUser = existingUsers.find(u => u.username === username && u.status !== "left" && u.status !== "removed") ?? null;
|
|
230
|
+
}
|
|
231
|
+
|
|
197
232
|
if (existingUser) {
|
|
233
|
+
try { this.opts.store.updateHubUserActivity(existingUser.id, joinIp); } catch { /* best-effort */ }
|
|
234
|
+
|
|
198
235
|
if (existingUser.status === "active") {
|
|
199
236
|
const token = issueUserToken(
|
|
200
237
|
{ userId: existingUser.id, username: existingUser.username, role: existingUser.role, status: "active" },
|
|
201
238
|
this.authSecret,
|
|
202
239
|
);
|
|
203
240
|
this.userManager.approveUser(existingUser.id, token);
|
|
204
|
-
|
|
241
|
+
if (identityKey && !existingUser.identityKey) {
|
|
242
|
+
this.opts.store.upsertHubUser({ ...existingUser, identityKey });
|
|
243
|
+
}
|
|
244
|
+
return this.json(res, 200, { status: "active", userId: existingUser.id, userToken: token, identityKey: existingUser.identityKey || identityKey });
|
|
205
245
|
}
|
|
206
246
|
if (existingUser.status === "pending") {
|
|
207
|
-
|
|
247
|
+
this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
|
|
248
|
+
return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
|
|
208
249
|
}
|
|
209
250
|
if (existingUser.status === "rejected") {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
251
|
+
if (body.reapply === true) {
|
|
252
|
+
this.userManager.resetToPending(existingUser.id);
|
|
253
|
+
this.notifyAdmins("user_join_request", "user", username, "");
|
|
254
|
+
this.opts.log.info(`Hub: rejected user "${username}" (${existingUser.id}) re-applied, reset to pending`);
|
|
255
|
+
return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
|
|
256
|
+
}
|
|
257
|
+
return this.json(res, 200, { status: "rejected", userId: existingUser.id });
|
|
258
|
+
}
|
|
259
|
+
if (existingUser.status === "removed") {
|
|
260
|
+
this.userManager.rejoinUser(existingUser.id);
|
|
261
|
+
this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
|
|
262
|
+
this.opts.log.info(`Hub: removed user "${username}" (${existingUser.id}) re-applied via rejoin, reset to pending`);
|
|
263
|
+
return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
|
|
264
|
+
}
|
|
265
|
+
if (existingUser.status === "left") {
|
|
266
|
+
this.userManager.rejoinUser(existingUser.id);
|
|
267
|
+
this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
|
|
268
|
+
this.opts.log.info(`Hub: left user "${username}" (${existingUser.id}) re-applied via rejoin, reset to pending`);
|
|
269
|
+
return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
|
|
270
|
+
}
|
|
271
|
+
if (existingUser.status === "blocked") {
|
|
272
|
+
return this.json(res, 200, { status: "blocked", userId: existingUser.id });
|
|
213
273
|
}
|
|
214
274
|
}
|
|
275
|
+
|
|
276
|
+
const generatedIdentityKey = identityKey || randomUUID();
|
|
215
277
|
const user = this.userManager.createPendingUser({
|
|
216
278
|
username,
|
|
217
279
|
deviceName: typeof body.deviceName === "string" ? body.deviceName : undefined,
|
|
280
|
+
identityKey: generatedIdentityKey,
|
|
218
281
|
});
|
|
282
|
+
try { this.opts.store.updateHubUserActivity(user.id, joinIp); } catch { /* best-effort */ }
|
|
219
283
|
this.opts.log.info(`Hub: user "${username}" (${user.id}) registered as pending, awaiting admin approval`);
|
|
220
|
-
|
|
284
|
+
this.notifyAdmins("user_join_request", "user", username, "");
|
|
285
|
+
return this.json(res, 200, { status: "pending", userId: user.id, identityKey: generatedIdentityKey });
|
|
221
286
|
}
|
|
222
287
|
|
|
223
288
|
if (req.method === "POST" && routePath === "/api/v1/hub/registration-status") {
|
|
@@ -235,6 +300,15 @@ export class HubServer {
|
|
|
235
300
|
if (user.status === "rejected") {
|
|
236
301
|
return this.json(res, 200, { status: "rejected" });
|
|
237
302
|
}
|
|
303
|
+
if (user.status === "blocked") {
|
|
304
|
+
return this.json(res, 200, { status: "blocked" });
|
|
305
|
+
}
|
|
306
|
+
if (user.status === "left") {
|
|
307
|
+
return this.json(res, 200, { status: "left" });
|
|
308
|
+
}
|
|
309
|
+
if (user.status === "removed") {
|
|
310
|
+
return this.json(res, 200, { status: "removed" });
|
|
311
|
+
}
|
|
238
312
|
if (user.status === "active") {
|
|
239
313
|
const token = issueUserToken(
|
|
240
314
|
{ userId: user.id, username: user.username, role: user.role, status: user.status },
|
|
@@ -254,6 +328,18 @@ export class HubServer {
|
|
|
254
328
|
return this.json(res, 429, { error: "rate_limit_exceeded", retryAfterMs: HubServer.RATE_WINDOW_MS });
|
|
255
329
|
}
|
|
256
330
|
|
|
331
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/heartbeat") {
|
|
332
|
+
return this.json(res, 200, { ok: true });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/leave") {
|
|
336
|
+
this.userManager.markUserLeft(auth.userId);
|
|
337
|
+
this.knownOnlineUsers.delete(auth.userId);
|
|
338
|
+
this.notifyAdmins("user_offline", "user", auth.username, auth.userId);
|
|
339
|
+
this.opts.log.info(`Hub: user "${auth.username}" (${auth.userId}) left voluntarily, status set to "left"`);
|
|
340
|
+
return this.json(res, 200, { ok: true });
|
|
341
|
+
}
|
|
342
|
+
|
|
257
343
|
if (req.method === "GET" && routePath === "/api/v1/hub/me") {
|
|
258
344
|
const user = this.opts.store.getHubUser(auth.userId);
|
|
259
345
|
if (!user) return this.json(res, 401, { error: "unauthorized" });
|
|
@@ -272,9 +358,11 @@ export class HubServer {
|
|
|
272
358
|
}
|
|
273
359
|
const updated = this.userManager.updateUsername(auth.userId, newUsername);
|
|
274
360
|
if (!updated) return this.json(res, 404, { error: "not_found" });
|
|
361
|
+
const ttlMs = updated.role === "admin" ? 3650 * 24 * 60 * 60 * 1000 : undefined;
|
|
275
362
|
const newToken = issueUserToken(
|
|
276
363
|
{ userId: updated.id, username: newUsername, role: updated.role, status: updated.status },
|
|
277
364
|
this.authSecret,
|
|
365
|
+
ttlMs,
|
|
278
366
|
);
|
|
279
367
|
this.userManager.approveUser(updated.id, newToken);
|
|
280
368
|
this.opts.log.info(`Hub: user "${auth.userId}" renamed to "${newUsername}"`);
|
|
@@ -292,6 +380,7 @@ export class HubServer {
|
|
|
292
380
|
const token = issueUserToken({ userId: String(body.userId), username: String(body.username || ""), role: "member", status: "active" }, this.authSecret);
|
|
293
381
|
const approved = this.userManager.approveUser(String(body.userId), token);
|
|
294
382
|
if (!approved) return this.json(res, 404, { error: "not_found" });
|
|
383
|
+
try { this.opts.store.updateHubUserActivity(String(body.userId), ""); } catch { /* best-effort */ }
|
|
295
384
|
return this.json(res, 200, { status: "active", token });
|
|
296
385
|
}
|
|
297
386
|
|
|
@@ -306,97 +395,85 @@ export class HubServer {
|
|
|
306
395
|
if (req.method === "GET" && routePath === "/api/v1/hub/admin/users") {
|
|
307
396
|
if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
|
|
308
397
|
const users = this.opts.store.listHubUsers().filter(u => u.status === "active");
|
|
309
|
-
|
|
398
|
+
const contribs = this.opts.store.getHubUserContributions();
|
|
399
|
+
const ownerId = this.authState.bootstrapAdminUserId || "";
|
|
400
|
+
const now = Date.now();
|
|
401
|
+
return this.json(res, 200, { users: users.map(u => {
|
|
402
|
+
const c = contribs[u.id] || { memoryCount: 0, taskCount: 0, skillCount: 0 };
|
|
403
|
+
const isOnline = u.id === ownerId || (!!u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS);
|
|
404
|
+
return {
|
|
405
|
+
id: u.id, username: u.username, role: u.role, status: u.status,
|
|
406
|
+
deviceName: u.deviceName, createdAt: u.createdAt, approvedAt: u.approvedAt,
|
|
407
|
+
lastIp: u.lastIp || "", lastActiveAt: u.lastActiveAt,
|
|
408
|
+
isOwner: u.id === ownerId, isOnline,
|
|
409
|
+
memoryCount: c.memoryCount, taskCount: c.taskCount, skillCount: c.skillCount,
|
|
410
|
+
};
|
|
411
|
+
}) });
|
|
310
412
|
}
|
|
311
413
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
if (req.method === "GET" && routePath === "/api/v1/hub/groups") {
|
|
414
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/admin/change-role") {
|
|
315
415
|
if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
|
|
316
|
-
const
|
|
317
|
-
|
|
416
|
+
const body = await this.readJson(req);
|
|
417
|
+
const userId = String(body?.userId || "");
|
|
418
|
+
const newRole = String(body?.role || "");
|
|
419
|
+
if (!userId || (newRole !== "admin" && newRole !== "member")) return this.json(res, 400, { error: "invalid_params" });
|
|
420
|
+
if (newRole === "member" && userId === this.authState.bootstrapAdminUserId) {
|
|
421
|
+
return this.json(res, 403, { error: "cannot_demote_owner", message: "The hub owner cannot be demoted" });
|
|
422
|
+
}
|
|
423
|
+
const user = this.opts.store.getHubUser(userId);
|
|
424
|
+
if (!user || user.status !== "active") return this.json(res, 404, { error: "not_found" });
|
|
425
|
+
const updatedUser = { ...user, role: newRole as "admin" | "member" };
|
|
426
|
+
this.opts.store.upsertHubUser(updatedUser);
|
|
427
|
+
this.opts.log.info(`Hub: admin "${auth.userId}" changed role of "${userId}" to "${newRole}"`);
|
|
428
|
+
return this.json(res, 200, { ok: true, role: newRole });
|
|
318
429
|
}
|
|
319
430
|
|
|
320
|
-
if (req.method === "POST" && routePath === "/api/v1/hub/
|
|
431
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/admin/rename-user") {
|
|
321
432
|
if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
|
|
322
433
|
const body = await this.readJson(req);
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
id: groupId,
|
|
328
|
-
name,
|
|
329
|
-
description: String(body.description || ""),
|
|
330
|
-
createdAt: Date.now(),
|
|
331
|
-
});
|
|
332
|
-
return this.json(res, 201, { id: groupId, name });
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
const groupDetailMatch = routePath.match(/^\/api\/v1\/hub\/groups\/([^/]+)$/);
|
|
336
|
-
if (groupDetailMatch) {
|
|
337
|
-
const groupId = decodeURIComponent(groupDetailMatch[1]);
|
|
338
|
-
|
|
339
|
-
if (req.method === "GET") {
|
|
340
|
-
if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
|
|
341
|
-
const group = this.opts.store.getHubGroupById(groupId);
|
|
342
|
-
if (!group) return this.json(res, 404, { error: "not_found" });
|
|
343
|
-
const members = this.opts.store.listHubGroupMembers(groupId);
|
|
344
|
-
return this.json(res, 200, { ...group, members });
|
|
434
|
+
const userId = String(body?.userId || "");
|
|
435
|
+
const newUsername = String(body?.username || "").trim();
|
|
436
|
+
if (!userId || !newUsername || newUsername.length < 2 || newUsername.length > 32) {
|
|
437
|
+
return this.json(res, 400, { error: "invalid_params", message: "userId and username (2-32 chars) required" });
|
|
345
438
|
}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
|
|
349
|
-
const existing = this.opts.store.getHubGroupById(groupId);
|
|
350
|
-
if (!existing) return this.json(res, 404, { error: "not_found" });
|
|
351
|
-
const body = await this.readJson(req);
|
|
352
|
-
this.opts.store.upsertHubGroup({
|
|
353
|
-
id: groupId,
|
|
354
|
-
name: String(body.name || existing.name).trim(),
|
|
355
|
-
description: String(body.description ?? existing.description),
|
|
356
|
-
createdAt: existing.createdAt,
|
|
357
|
-
});
|
|
358
|
-
return this.json(res, 200, { ok: true });
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
if (req.method === "DELETE") {
|
|
362
|
-
if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
|
|
363
|
-
const deleted = this.opts.store.deleteHubGroup(groupId);
|
|
364
|
-
if (!deleted) return this.json(res, 404, { error: "not_found" });
|
|
365
|
-
return this.json(res, 200, { ok: true });
|
|
439
|
+
if (this.userManager.isUsernameTaken(newUsername, userId)) {
|
|
440
|
+
return this.json(res, 409, { error: "username_taken", message: "Username already in use" });
|
|
366
441
|
}
|
|
442
|
+
const user = this.opts.store.getHubUser(userId);
|
|
443
|
+
if (!user || user.status !== "active") return this.json(res, 404, { error: "not_found" });
|
|
444
|
+
const ttlMs = user.role === "admin" ? 3650 * 24 * 60 * 60 * 1000 : undefined;
|
|
445
|
+
const newToken = issueUserToken(
|
|
446
|
+
{ userId: user.id, username: newUsername, role: user.role, status: user.status },
|
|
447
|
+
this.authSecret,
|
|
448
|
+
ttlMs,
|
|
449
|
+
);
|
|
450
|
+
this.userManager.approveUser(user.id, newToken);
|
|
451
|
+
const updated = this.opts.store.getHubUser(userId)!;
|
|
452
|
+
const finalUser = { ...updated, username: newUsername };
|
|
453
|
+
this.opts.store.upsertHubUser(finalUser);
|
|
454
|
+
this.opts.log.info(`Hub: admin "${auth.userId}" renamed user "${userId}" to "${newUsername}"`);
|
|
455
|
+
return this.json(res, 200, { ok: true, username: newUsername });
|
|
367
456
|
}
|
|
368
457
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
const
|
|
372
|
-
|
|
373
|
-
if (
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
if (!user) return this.json(res, 404, { error: "user_not_found" });
|
|
382
|
-
this.opts.store.addHubGroupMember(groupId, userId);
|
|
383
|
-
return this.json(res, 200, { ok: true });
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
if (req.method === "DELETE") {
|
|
387
|
-
if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
|
|
388
|
-
const body = await this.readJson(req);
|
|
389
|
-
const userId = String(body.userId || "");
|
|
390
|
-
if (!userId) return this.json(res, 400, { error: "userId_required" });
|
|
391
|
-
this.opts.store.removeHubGroupMember(groupId, userId);
|
|
392
|
-
return this.json(res, 200, { ok: true });
|
|
393
|
-
}
|
|
458
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/admin/remove-user") {
|
|
459
|
+
if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
|
|
460
|
+
const body = await this.readJson(req);
|
|
461
|
+
const userId = String(body?.userId || "");
|
|
462
|
+
if (!userId) return this.json(res, 400, { error: "missing_user_id" });
|
|
463
|
+
if (userId === auth.userId) return this.json(res, 400, { error: "cannot_remove_self" });
|
|
464
|
+
if (userId === this.authState.bootstrapAdminUserId) return this.json(res, 403, { error: "cannot_remove_owner", message: "The hub owner cannot be removed" });
|
|
465
|
+
const cleanResources = body?.cleanResources === true;
|
|
466
|
+
const deleted = this.opts.store.deleteHubUser(userId, cleanResources);
|
|
467
|
+
if (!deleted) return this.json(res, 404, { error: "not_found" });
|
|
468
|
+
this.opts.log.info(`Hub: admin "${auth.userId}" removed user "${userId}" (cleanResources=${cleanResources})`);
|
|
469
|
+
return this.json(res, 200, { ok: true });
|
|
394
470
|
}
|
|
395
471
|
|
|
396
472
|
if (req.method === "POST" && routePath === "/api/v1/hub/tasks/share") {
|
|
397
473
|
const body = await this.readJson(req);
|
|
398
474
|
if (!body?.task) return this.json(res, 400, { error: "invalid_payload" });
|
|
399
475
|
const task = { ...body.task, sourceUserId: auth.userId };
|
|
476
|
+
const existingTask = task.sourceTaskId ? this.opts.store.getHubTaskBySource(auth.userId, task.sourceTaskId) : null;
|
|
400
477
|
this.opts.store.upsertHubTask(task);
|
|
401
478
|
const chunks = Array.isArray(body.chunks) ? body.chunks : [];
|
|
402
479
|
const chunkIds: string[] = [];
|
|
@@ -404,16 +481,23 @@ export class HubServer {
|
|
|
404
481
|
this.opts.store.upsertHubChunk({ ...chunk, sourceUserId: auth.userId });
|
|
405
482
|
chunkIds.push(chunk.id);
|
|
406
483
|
}
|
|
407
|
-
// Async embedding: don't block the response
|
|
408
484
|
if (this.opts.embedder && chunkIds.length > 0) {
|
|
409
485
|
this.embedChunksAsync(chunkIds, chunks);
|
|
410
486
|
}
|
|
487
|
+
if (!existingTask) {
|
|
488
|
+
this.notifyAdmins("resource_shared", "task", String(task.title || task.sourceTaskId || ""), auth.userId);
|
|
489
|
+
}
|
|
411
490
|
return this.json(res, 200, { ok: true, chunks: chunkIds.length });
|
|
412
491
|
}
|
|
413
492
|
|
|
414
493
|
if (req.method === "POST" && routePath === "/api/v1/hub/tasks/unshare") {
|
|
415
494
|
const body = await this.readJson(req);
|
|
416
|
-
|
|
495
|
+
const srcTaskId = String(body.sourceTaskId);
|
|
496
|
+
const existing = this.opts.store.getHubTaskBySource(auth.userId, srcTaskId);
|
|
497
|
+
this.opts.store.deleteHubTaskBySource(auth.userId, srcTaskId);
|
|
498
|
+
if (existing) {
|
|
499
|
+
this.notifyAdmins("resource_unshared", "task", existing.title || srcTaskId, auth.userId);
|
|
500
|
+
}
|
|
417
501
|
return this.json(res, 200, { ok: true });
|
|
418
502
|
}
|
|
419
503
|
|
|
@@ -444,6 +528,9 @@ export class HubServer {
|
|
|
444
528
|
if (this.opts.embedder) {
|
|
445
529
|
this.embedMemoryAsync(memoryId, String(m.summary || ""), String(m.content || ""));
|
|
446
530
|
}
|
|
531
|
+
if (!existing) {
|
|
532
|
+
this.notifyAdmins("resource_shared", "memory", String(m.summary || m.content?.slice(0, 60) || memoryId), auth.userId);
|
|
533
|
+
}
|
|
447
534
|
return this.json(res, 200, { ok: true, memoryId, visibility });
|
|
448
535
|
}
|
|
449
536
|
|
|
@@ -451,7 +538,11 @@ export class HubServer {
|
|
|
451
538
|
const body = await this.readJson(req);
|
|
452
539
|
const sourceChunkId = String(body?.sourceChunkId || "");
|
|
453
540
|
if (!sourceChunkId) return this.json(res, 400, { error: "missing_source_chunk_id" });
|
|
541
|
+
const existing = this.opts.store.getHubMemoryBySource(auth.userId, sourceChunkId);
|
|
454
542
|
this.opts.store.deleteHubMemoryBySource(auth.userId, sourceChunkId);
|
|
543
|
+
if (existing) {
|
|
544
|
+
this.notifyAdmins("resource_unshared", "memory", existing.summary || existing.content?.slice(0, 60) || sourceChunkId, auth.userId);
|
|
545
|
+
}
|
|
455
546
|
return this.json(res, 200, { ok: true });
|
|
456
547
|
}
|
|
457
548
|
|
|
@@ -563,19 +654,70 @@ export class HubServer {
|
|
|
563
654
|
}
|
|
564
655
|
|
|
565
656
|
if (req.method === "GET" && routePath === "/api/v1/hub/skills") {
|
|
566
|
-
const
|
|
657
|
+
const skillQuery = String(url.searchParams.get("query") || "");
|
|
658
|
+
const skillMaxResults = Number(url.searchParams.get("maxResults") || 10);
|
|
659
|
+
const ftsSkillHits = this.opts.store.searchHubSkills(skillQuery, {
|
|
567
660
|
userId: auth.userId,
|
|
568
|
-
maxResults:
|
|
569
|
-
})
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
661
|
+
maxResults: skillMaxResults * 2,
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
let mergedSkillIds: string[];
|
|
665
|
+
if (this.opts.embedder && skillQuery) {
|
|
666
|
+
try {
|
|
667
|
+
const [queryVec] = await this.opts.embedder.embed([skillQuery]);
|
|
668
|
+
if (queryVec) {
|
|
669
|
+
const skillEmbs = this.opts.store.getVisibleHubSkillEmbeddings();
|
|
670
|
+
const cosineSim = (vec: Float32Array) => {
|
|
671
|
+
let dot = 0, nA = 0, nB = 0;
|
|
672
|
+
for (let i = 0; i < queryVec.length && i < vec.length; i++) {
|
|
673
|
+
dot += queryVec[i] * vec[i]; nA += queryVec[i] * queryVec[i]; nB += vec[i] * vec[i];
|
|
674
|
+
}
|
|
675
|
+
return nA > 0 && nB > 0 ? dot / (Math.sqrt(nA) * Math.sqrt(nB)) : 0;
|
|
676
|
+
};
|
|
677
|
+
const vecScored = skillEmbs
|
|
678
|
+
.map(e => ({ id: e.skillId, score: cosineSim(e.vector) }))
|
|
679
|
+
.filter(e => e.score > 0.3)
|
|
680
|
+
.sort((a, b) => b.score - a.score)
|
|
681
|
+
.slice(0, skillMaxResults * 2);
|
|
682
|
+
|
|
683
|
+
const K = 60;
|
|
684
|
+
const rrfScores = new Map<string, number>();
|
|
685
|
+
ftsSkillHits.forEach(({ hit }, idx) => {
|
|
686
|
+
rrfScores.set(hit.id, (rrfScores.get(hit.id) ?? 0) + 1 / (K + idx + 1));
|
|
687
|
+
});
|
|
688
|
+
vecScored.forEach(({ id }, idx) => {
|
|
689
|
+
rrfScores.set(id, (rrfScores.get(id) ?? 0) + 1 / (K + idx + 1));
|
|
690
|
+
});
|
|
691
|
+
mergedSkillIds = [...rrfScores.entries()].sort((a, b) => b[1] - a[1]).slice(0, skillMaxResults).map(([id]) => id);
|
|
692
|
+
} else {
|
|
693
|
+
mergedSkillIds = ftsSkillHits.slice(0, skillMaxResults).map(({ hit }) => hit.id);
|
|
694
|
+
}
|
|
695
|
+
} catch {
|
|
696
|
+
mergedSkillIds = ftsSkillHits.slice(0, skillMaxResults).map(({ hit }) => hit.id);
|
|
697
|
+
}
|
|
698
|
+
} else {
|
|
699
|
+
mergedSkillIds = ftsSkillHits.slice(0, skillMaxResults).map(({ hit }) => hit.id);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const ftsSkillMap = new Map(ftsSkillHits.map(({ hit }) => [hit.id, hit]));
|
|
703
|
+
const hits = mergedSkillIds.map(id => {
|
|
704
|
+
const hit = ftsSkillMap.get(id);
|
|
705
|
+
if (hit) {
|
|
706
|
+
return {
|
|
707
|
+
skillId: hit.id, name: hit.name, description: hit.description,
|
|
708
|
+
version: hit.version, visibility: hit.visibility, groupName: hit.group_name,
|
|
709
|
+
ownerName: hit.owner_name || "unknown", ownerStatus: hit.owner_status || "",
|
|
710
|
+
qualityScore: hit.quality_score,
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
const skill = this.opts.store.getHubSkillById(id);
|
|
714
|
+
if (!skill) return null;
|
|
715
|
+
return {
|
|
716
|
+
skillId: skill.id, name: skill.name, description: skill.description,
|
|
717
|
+
version: skill.version, visibility: skill.visibility, groupName: "",
|
|
718
|
+
ownerName: "unknown", ownerStatus: "", qualityScore: skill.qualityScore,
|
|
719
|
+
};
|
|
720
|
+
}).filter(Boolean);
|
|
579
721
|
return this.json(res, 200, { hits });
|
|
580
722
|
}
|
|
581
723
|
|
|
@@ -601,6 +743,10 @@ export class HubServer {
|
|
|
601
743
|
createdAt: existing?.createdAt ?? Date.now(),
|
|
602
744
|
updatedAt: Date.now(),
|
|
603
745
|
});
|
|
746
|
+
this.embedSkillAsync(skillId, String(metadata.name || sourceSkillId), String(metadata.description || ""), auth.userId, sourceSkillId);
|
|
747
|
+
if (!existing) {
|
|
748
|
+
this.notifyAdmins("resource_shared", "skill", String(metadata.name || sourceSkillId), auth.userId);
|
|
749
|
+
}
|
|
604
750
|
return this.json(res, 200, { ok: true, skillId, visibility });
|
|
605
751
|
}
|
|
606
752
|
|
|
@@ -623,7 +769,12 @@ export class HubServer {
|
|
|
623
769
|
|
|
624
770
|
if (req.method === "POST" && routePath === "/api/v1/hub/skills/unpublish") {
|
|
625
771
|
const body = await this.readJson(req);
|
|
626
|
-
|
|
772
|
+
const srcSkillId = String(body?.sourceSkillId || "");
|
|
773
|
+
const existing = this.opts.store.getHubSkillBySource(auth.userId, srcSkillId);
|
|
774
|
+
this.opts.store.deleteHubSkillBySource(auth.userId, srcSkillId);
|
|
775
|
+
if (existing) {
|
|
776
|
+
this.notifyAdmins("resource_unshared", "skill", existing.name || srcSkillId, auth.userId);
|
|
777
|
+
}
|
|
627
778
|
return this.json(res, 200, { ok: true });
|
|
628
779
|
}
|
|
629
780
|
|
|
@@ -635,12 +786,48 @@ export class HubServer {
|
|
|
635
786
|
return this.json(res, 200, { tasks });
|
|
636
787
|
}
|
|
637
788
|
|
|
789
|
+
const hubTaskDetailMatch = req.method === "GET" ? routePath.match(/^\/api\/v1\/hub\/shared-tasks\/([^/]+)\/detail$/) : null;
|
|
790
|
+
if (hubTaskDetailMatch) {
|
|
791
|
+
const taskId = decodeURIComponent(hubTaskDetailMatch[1]);
|
|
792
|
+
const task = this.opts.store.getHubTaskById(taskId);
|
|
793
|
+
if (!task) return this.json(res, 404, { error: "not_found" });
|
|
794
|
+
const chunks = this.opts.store.listHubChunksByTaskId(taskId);
|
|
795
|
+
return this.json(res, 200, {
|
|
796
|
+
id: task.id, title: task.title, summary: task.summary,
|
|
797
|
+
startedAt: task.createdAt, endedAt: task.updatedAt,
|
|
798
|
+
chunks: chunks.map(c => ({ role: c.role, content: c.content, summary: c.summary, kind: c.kind, createdAt: c.createdAt })),
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
const hubSkillDetailMatch = req.method === "GET" ? routePath.match(/^\/api\/v1\/hub\/shared-skills\/([^/]+)\/detail$/) : null;
|
|
803
|
+
if (hubSkillDetailMatch) {
|
|
804
|
+
const skillId = decodeURIComponent(hubSkillDetailMatch[1]);
|
|
805
|
+
const skill = this.opts.store.getHubSkillById(skillId);
|
|
806
|
+
if (!skill) return this.json(res, 404, { error: "not_found" });
|
|
807
|
+
let files: Array<{ path: string; type: string; size: number }> = [];
|
|
808
|
+
try {
|
|
809
|
+
const bundle = JSON.parse(skill.bundle || "{}");
|
|
810
|
+
if (Array.isArray(bundle.files)) {
|
|
811
|
+
files = bundle.files.map((f: any) => ({ path: f.path ?? f.name ?? "unknown", type: f.type ?? "file", size: f.size ?? (f.content ? f.content.length : 0) }));
|
|
812
|
+
}
|
|
813
|
+
} catch { /* ignore parse error */ }
|
|
814
|
+
return this.json(res, 200, {
|
|
815
|
+
skill: { id: skill.id, name: skill.name, description: skill.description, version: skill.version, qualityScore: skill.qualityScore, status: "published" },
|
|
816
|
+
files,
|
|
817
|
+
versions: [],
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
|
|
638
821
|
const adminTaskDeleteMatch = req.method === "DELETE" ? routePath.match(/^\/api\/v1\/hub\/admin\/shared-tasks\/([^/]+)$/) : null;
|
|
639
822
|
if (adminTaskDeleteMatch) {
|
|
640
823
|
if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
|
|
641
824
|
const taskId = decodeURIComponent(adminTaskDeleteMatch[1]);
|
|
825
|
+
const taskInfo = this.opts.store.getHubTaskById(taskId);
|
|
642
826
|
const deleted = this.opts.store.deleteHubTaskById(taskId);
|
|
643
827
|
if (!deleted) return this.json(res, 404, { error: "not_found" });
|
|
828
|
+
if (taskInfo) {
|
|
829
|
+
this.opts.store.insertHubNotification({ id: randomUUID(), userId: taskInfo.sourceUserId, type: "resource_removed", resource: "task", title: taskInfo.title });
|
|
830
|
+
}
|
|
644
831
|
return this.json(res, 200, { ok: true });
|
|
645
832
|
}
|
|
646
833
|
|
|
@@ -654,8 +841,12 @@ export class HubServer {
|
|
|
654
841
|
if (adminSkillDeleteMatch) {
|
|
655
842
|
if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
|
|
656
843
|
const skillId = decodeURIComponent(adminSkillDeleteMatch[1]);
|
|
844
|
+
const skillInfo = this.opts.store.getHubSkillById(skillId);
|
|
657
845
|
const deleted = this.opts.store.deleteHubSkillById(skillId);
|
|
658
846
|
if (!deleted) return this.json(res, 404, { error: "not_found" });
|
|
847
|
+
if (skillInfo) {
|
|
848
|
+
this.opts.store.insertHubNotification({ id: randomUUID(), userId: skillInfo.sourceUserId, type: "resource_removed", resource: "skill", title: skillInfo.name });
|
|
849
|
+
}
|
|
659
850
|
return this.json(res, 200, { ok: true });
|
|
660
851
|
}
|
|
661
852
|
|
|
@@ -669,8 +860,12 @@ export class HubServer {
|
|
|
669
860
|
if (adminMemoryDeleteMatch) {
|
|
670
861
|
if (auth.role !== "admin") return this.json(res, 403, { error: "forbidden" });
|
|
671
862
|
const memoryId = decodeURIComponent(adminMemoryDeleteMatch[1]);
|
|
863
|
+
const memInfo = this.opts.store.getHubMemoryById(memoryId);
|
|
672
864
|
const deleted = this.opts.store.deleteHubMemoryById(memoryId);
|
|
673
865
|
if (!deleted) return this.json(res, 404, { error: "not_found" });
|
|
866
|
+
if (memInfo) {
|
|
867
|
+
this.opts.store.insertHubNotification({ id: randomUUID(), userId: memInfo.sourceUserId, type: "resource_removed", resource: "memory", title: memInfo.summary || memInfo.id });
|
|
868
|
+
}
|
|
674
869
|
return this.json(res, 200, { ok: true });
|
|
675
870
|
}
|
|
676
871
|
|
|
@@ -693,9 +888,87 @@ export class HubServer {
|
|
|
693
888
|
});
|
|
694
889
|
}
|
|
695
890
|
|
|
891
|
+
if (req.method === "GET" && routePath === "/api/v1/hub/notifications") {
|
|
892
|
+
const unread = (new URL(req.url!, `http://${req.headers.host}`)).searchParams.get("unread") === "1";
|
|
893
|
+
const list = this.opts.store.listHubNotifications(auth.userId, { unreadOnly: unread, limit: 50 });
|
|
894
|
+
const unreadCount = this.opts.store.countUnreadHubNotifications(auth.userId);
|
|
895
|
+
return this.json(res, 200, { notifications: list, unreadCount });
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/notifications/read") {
|
|
899
|
+
const body = await this.readJson(req);
|
|
900
|
+
const ids = Array.isArray(body.ids) ? body.ids as string[] : undefined;
|
|
901
|
+
this.opts.store.markHubNotificationsRead(auth.userId, ids);
|
|
902
|
+
return this.json(res, 200, { ok: true });
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/notifications/clear") {
|
|
906
|
+
this.opts.store.clearHubNotifications(auth.userId);
|
|
907
|
+
return this.json(res, 200, { ok: true });
|
|
908
|
+
}
|
|
909
|
+
|
|
696
910
|
return this.json(res, 404, { error: "not_found" });
|
|
697
911
|
}
|
|
698
912
|
|
|
913
|
+
private notifyAdmins(type: string, resource: string, title: string, fromUserId: string, opts?: { dedup?: boolean; deduoWindowMs?: number }): void {
|
|
914
|
+
try {
|
|
915
|
+
const admins = this.opts.store.listHubUsers("active").filter(u => u.role === "admin" && u.id !== fromUserId);
|
|
916
|
+
for (const admin of admins) {
|
|
917
|
+
if (opts?.dedup && this.opts.store.hasRecentHubNotification(admin.id, type, resource, opts.deduoWindowMs ?? 300_000)) {
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
this.opts.store.insertHubNotification({ id: randomUUID(), userId: admin.id, type, resource, title });
|
|
921
|
+
}
|
|
922
|
+
} catch { /* best-effort */ }
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
private initOnlineTracking(): void {
|
|
926
|
+
try {
|
|
927
|
+
const ownerId = this.authState.bootstrapAdminUserId || "";
|
|
928
|
+
const users = this.opts.store.listHubUsers("active");
|
|
929
|
+
const now = Date.now();
|
|
930
|
+
for (const u of users) {
|
|
931
|
+
if (u.id === ownerId) continue;
|
|
932
|
+
if (u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS) {
|
|
933
|
+
this.knownOnlineUsers.add(u.id);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
} catch { /* best-effort */ }
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
private checkOfflineUsers(): void {
|
|
940
|
+
try {
|
|
941
|
+
const ownerId = this.authState.bootstrapAdminUserId || "";
|
|
942
|
+
const users = this.opts.store.listHubUsers("active");
|
|
943
|
+
const now = Date.now();
|
|
944
|
+
const currentlyOnline = new Set<string>();
|
|
945
|
+
for (const u of users) {
|
|
946
|
+
if (u.id === ownerId) continue;
|
|
947
|
+
if (u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS) {
|
|
948
|
+
currentlyOnline.add(u.id);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
for (const uid of this.knownOnlineUsers) {
|
|
952
|
+
if (!currentlyOnline.has(uid)) {
|
|
953
|
+
const user = users.find(u => u.id === uid);
|
|
954
|
+
if (user) {
|
|
955
|
+
this.notifyAdmins("user_offline", "user", user.username, uid);
|
|
956
|
+
this.opts.log.info(`Hub: user "${user.username}" (${uid}) went offline`);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
for (const uid of currentlyOnline) {
|
|
961
|
+
if (!this.knownOnlineUsers.has(uid)) {
|
|
962
|
+
const user = users.find(u => u.id === uid);
|
|
963
|
+
if (user) {
|
|
964
|
+
this.notifyAdmins("user_online", "user", user.username, uid);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
this.knownOnlineUsers = currentlyOnline;
|
|
969
|
+
} catch { /* best-effort */ }
|
|
970
|
+
}
|
|
971
|
+
|
|
699
972
|
private authenticate(req: http.IncomingMessage) {
|
|
700
973
|
const header = req.headers.authorization;
|
|
701
974
|
if (!header || !header.startsWith("Bearer ")) return null;
|
|
@@ -706,6 +979,10 @@ export class HubServer {
|
|
|
706
979
|
if (!user || user.status !== "active") return null;
|
|
707
980
|
const hash = createHash("sha256").update(token).digest("hex");
|
|
708
981
|
if (user.tokenHash !== hash) return null;
|
|
982
|
+
const clientIp = (req.headers["x-client-ip"] as string)?.trim()
|
|
983
|
+
|| (req.headers["x-forwarded-for"] as string)?.split(",")[0]?.trim()
|
|
984
|
+
|| req.socket.remoteAddress || "";
|
|
985
|
+
try { this.opts.store.updateHubUserActivity(user.id, clientIp); } catch { /* best-effort */ }
|
|
709
986
|
return {
|
|
710
987
|
userId: user.id,
|
|
711
988
|
username: user.username,
|