@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/dist/hub/server.js
CHANGED
|
@@ -51,6 +51,10 @@ class HubServer {
|
|
|
51
51
|
static RATE_LIMIT_DEFAULT = 60;
|
|
52
52
|
static RATE_LIMIT_SEARCH = 30;
|
|
53
53
|
rateBuckets = new Map();
|
|
54
|
+
static OFFLINE_THRESHOLD_MS = 2 * 60 * 1000;
|
|
55
|
+
static OFFLINE_CHECK_INTERVAL_MS = 30 * 1000;
|
|
56
|
+
offlineCheckTimer;
|
|
57
|
+
knownOnlineUsers = new Set();
|
|
54
58
|
constructor(opts) {
|
|
55
59
|
this.opts = opts;
|
|
56
60
|
this.userManager = new user_manager_1.HubUserManager(opts.store, opts.log);
|
|
@@ -109,9 +113,15 @@ class HubServer {
|
|
|
109
113
|
this.saveAuthState();
|
|
110
114
|
this.opts.log.info(`memos-local: bootstrap admin token persisted to ${this.authStatePath}`);
|
|
111
115
|
}
|
|
116
|
+
this.initOnlineTracking();
|
|
117
|
+
this.offlineCheckTimer = setInterval(() => this.checkOfflineUsers(), HubServer.OFFLINE_CHECK_INTERVAL_MS);
|
|
112
118
|
return `http://127.0.0.1:${this.port}`;
|
|
113
119
|
}
|
|
114
120
|
async stop() {
|
|
121
|
+
if (this.offlineCheckTimer) {
|
|
122
|
+
clearInterval(this.offlineCheckTimer);
|
|
123
|
+
this.offlineCheckTimer = undefined;
|
|
124
|
+
}
|
|
115
125
|
if (!this.server)
|
|
116
126
|
return;
|
|
117
127
|
const server = this.server;
|
|
@@ -163,6 +173,20 @@ class HubServer {
|
|
|
163
173
|
this.opts.log.warn(`hub: embedding shared chunks failed: ${err}`);
|
|
164
174
|
});
|
|
165
175
|
}
|
|
176
|
+
embedSkillAsync(skillId, name, description, sourceUserId, sourceSkillId) {
|
|
177
|
+
const embedder = this.opts.embedder;
|
|
178
|
+
if (!embedder)
|
|
179
|
+
return;
|
|
180
|
+
const text = `${name}: ${description}`;
|
|
181
|
+
embedder.embed([text]).then((vectors) => {
|
|
182
|
+
if (vectors[0]) {
|
|
183
|
+
this.opts.store.upsertHubSkillEmbedding(skillId, Array.from(vectors[0]), sourceUserId, sourceSkillId);
|
|
184
|
+
this.opts.log.info(`hub: embedded shared skill ${skillId}`);
|
|
185
|
+
}
|
|
186
|
+
}).catch((err) => {
|
|
187
|
+
this.opts.log.warn(`hub: embedding shared skill failed: ${err}`);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
166
190
|
embedMemoryAsync(memoryId, summary, content) {
|
|
167
191
|
const embedder = this.opts.embedder;
|
|
168
192
|
if (!embedder)
|
|
@@ -193,29 +217,73 @@ class HubServer {
|
|
|
193
217
|
return this.json(res, 403, { error: "invalid_team_token" });
|
|
194
218
|
}
|
|
195
219
|
const username = String(body.username || `user-${(0, crypto_1.randomUUID)().slice(0, 8)}`);
|
|
196
|
-
const
|
|
197
|
-
|
|
220
|
+
const joinIp = (typeof body.clientIp === "string" && body.clientIp)
|
|
221
|
+
|| req.headers["x-client-ip"]?.trim()
|
|
222
|
+
|| req.headers["x-forwarded-for"]?.split(",")[0]?.trim()
|
|
223
|
+
|| req.socket.remoteAddress || "";
|
|
224
|
+
const identityKey = typeof body.identityKey === "string" ? body.identityKey.trim() : "";
|
|
225
|
+
let existingUser = identityKey
|
|
226
|
+
? this.userManager.findByIdentityKey(identityKey)
|
|
227
|
+
: null;
|
|
228
|
+
if (!existingUser) {
|
|
229
|
+
const existingUsers = this.opts.store.listHubUsers();
|
|
230
|
+
existingUser = existingUsers.find(u => u.username === username && u.status !== "left" && u.status !== "removed") ?? null;
|
|
231
|
+
}
|
|
198
232
|
if (existingUser) {
|
|
233
|
+
try {
|
|
234
|
+
this.opts.store.updateHubUserActivity(existingUser.id, joinIp);
|
|
235
|
+
}
|
|
236
|
+
catch { /* best-effort */ }
|
|
199
237
|
if (existingUser.status === "active") {
|
|
200
238
|
const token = (0, auth_1.issueUserToken)({ userId: existingUser.id, username: existingUser.username, role: existingUser.role, status: "active" }, this.authSecret);
|
|
201
239
|
this.userManager.approveUser(existingUser.id, token);
|
|
202
|
-
|
|
240
|
+
if (identityKey && !existingUser.identityKey) {
|
|
241
|
+
this.opts.store.upsertHubUser({ ...existingUser, identityKey });
|
|
242
|
+
}
|
|
243
|
+
return this.json(res, 200, { status: "active", userId: existingUser.id, userToken: token, identityKey: existingUser.identityKey || identityKey });
|
|
203
244
|
}
|
|
204
245
|
if (existingUser.status === "pending") {
|
|
205
|
-
|
|
246
|
+
this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
|
|
247
|
+
return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
|
|
206
248
|
}
|
|
207
249
|
if (existingUser.status === "rejected") {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
250
|
+
if (body.reapply === true) {
|
|
251
|
+
this.userManager.resetToPending(existingUser.id);
|
|
252
|
+
this.notifyAdmins("user_join_request", "user", username, "");
|
|
253
|
+
this.opts.log.info(`Hub: rejected user "${username}" (${existingUser.id}) re-applied, reset to pending`);
|
|
254
|
+
return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
|
|
255
|
+
}
|
|
256
|
+
return this.json(res, 200, { status: "rejected", userId: existingUser.id });
|
|
257
|
+
}
|
|
258
|
+
if (existingUser.status === "removed") {
|
|
259
|
+
this.userManager.rejoinUser(existingUser.id);
|
|
260
|
+
this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
|
|
261
|
+
this.opts.log.info(`Hub: removed user "${username}" (${existingUser.id}) re-applied via rejoin, reset to pending`);
|
|
262
|
+
return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
|
|
263
|
+
}
|
|
264
|
+
if (existingUser.status === "left") {
|
|
265
|
+
this.userManager.rejoinUser(existingUser.id);
|
|
266
|
+
this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
|
|
267
|
+
this.opts.log.info(`Hub: left user "${username}" (${existingUser.id}) re-applied via rejoin, reset to pending`);
|
|
268
|
+
return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
|
|
269
|
+
}
|
|
270
|
+
if (existingUser.status === "blocked") {
|
|
271
|
+
return this.json(res, 200, { status: "blocked", userId: existingUser.id });
|
|
211
272
|
}
|
|
212
273
|
}
|
|
274
|
+
const generatedIdentityKey = identityKey || (0, crypto_1.randomUUID)();
|
|
213
275
|
const user = this.userManager.createPendingUser({
|
|
214
276
|
username,
|
|
215
277
|
deviceName: typeof body.deviceName === "string" ? body.deviceName : undefined,
|
|
278
|
+
identityKey: generatedIdentityKey,
|
|
216
279
|
});
|
|
280
|
+
try {
|
|
281
|
+
this.opts.store.updateHubUserActivity(user.id, joinIp);
|
|
282
|
+
}
|
|
283
|
+
catch { /* best-effort */ }
|
|
217
284
|
this.opts.log.info(`Hub: user "${username}" (${user.id}) registered as pending, awaiting admin approval`);
|
|
218
|
-
|
|
285
|
+
this.notifyAdmins("user_join_request", "user", username, "");
|
|
286
|
+
return this.json(res, 200, { status: "pending", userId: user.id, identityKey: generatedIdentityKey });
|
|
219
287
|
}
|
|
220
288
|
if (req.method === "POST" && routePath === "/api/v1/hub/registration-status") {
|
|
221
289
|
const body = await this.readJson(req);
|
|
@@ -234,6 +302,15 @@ class HubServer {
|
|
|
234
302
|
if (user.status === "rejected") {
|
|
235
303
|
return this.json(res, 200, { status: "rejected" });
|
|
236
304
|
}
|
|
305
|
+
if (user.status === "blocked") {
|
|
306
|
+
return this.json(res, 200, { status: "blocked" });
|
|
307
|
+
}
|
|
308
|
+
if (user.status === "left") {
|
|
309
|
+
return this.json(res, 200, { status: "left" });
|
|
310
|
+
}
|
|
311
|
+
if (user.status === "removed") {
|
|
312
|
+
return this.json(res, 200, { status: "removed" });
|
|
313
|
+
}
|
|
237
314
|
if (user.status === "active") {
|
|
238
315
|
const token = (0, auth_1.issueUserToken)({ userId: user.id, username: user.username, role: user.role, status: user.status }, this.authSecret);
|
|
239
316
|
return this.json(res, 200, { status: "active", userToken: token });
|
|
@@ -248,6 +325,16 @@ class HubServer {
|
|
|
248
325
|
if (!this.checkRateLimit(auth.userId, endpointKey)) {
|
|
249
326
|
return this.json(res, 429, { error: "rate_limit_exceeded", retryAfterMs: HubServer.RATE_WINDOW_MS });
|
|
250
327
|
}
|
|
328
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/heartbeat") {
|
|
329
|
+
return this.json(res, 200, { ok: true });
|
|
330
|
+
}
|
|
331
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/leave") {
|
|
332
|
+
this.userManager.markUserLeft(auth.userId);
|
|
333
|
+
this.knownOnlineUsers.delete(auth.userId);
|
|
334
|
+
this.notifyAdmins("user_offline", "user", auth.username, auth.userId);
|
|
335
|
+
this.opts.log.info(`Hub: user "${auth.username}" (${auth.userId}) left voluntarily, status set to "left"`);
|
|
336
|
+
return this.json(res, 200, { ok: true });
|
|
337
|
+
}
|
|
251
338
|
if (req.method === "GET" && routePath === "/api/v1/hub/me") {
|
|
252
339
|
const user = this.opts.store.getHubUser(auth.userId);
|
|
253
340
|
if (!user)
|
|
@@ -268,7 +355,8 @@ class HubServer {
|
|
|
268
355
|
const updated = this.userManager.updateUsername(auth.userId, newUsername);
|
|
269
356
|
if (!updated)
|
|
270
357
|
return this.json(res, 404, { error: "not_found" });
|
|
271
|
-
const
|
|
358
|
+
const ttlMs = updated.role === "admin" ? 3650 * 24 * 60 * 60 * 1000 : undefined;
|
|
359
|
+
const newToken = (0, auth_1.issueUserToken)({ userId: updated.id, username: newUsername, role: updated.role, status: updated.status }, this.authSecret, ttlMs);
|
|
272
360
|
this.userManager.approveUser(updated.id, newToken);
|
|
273
361
|
this.opts.log.info(`Hub: user "${auth.userId}" renamed to "${newUsername}"`);
|
|
274
362
|
return this.json(res, 200, { ok: true, username: newUsername, userToken: newToken });
|
|
@@ -286,6 +374,10 @@ class HubServer {
|
|
|
286
374
|
const approved = this.userManager.approveUser(String(body.userId), token);
|
|
287
375
|
if (!approved)
|
|
288
376
|
return this.json(res, 404, { error: "not_found" });
|
|
377
|
+
try {
|
|
378
|
+
this.opts.store.updateHubUserActivity(String(body.userId), "");
|
|
379
|
+
}
|
|
380
|
+
catch { /* best-effort */ }
|
|
289
381
|
return this.json(res, 200, { status: "active", token });
|
|
290
382
|
}
|
|
291
383
|
if (req.method === "POST" && routePath === "/api/v1/hub/admin/reject-user") {
|
|
@@ -301,102 +393,88 @@ class HubServer {
|
|
|
301
393
|
if (auth.role !== "admin")
|
|
302
394
|
return this.json(res, 403, { error: "forbidden" });
|
|
303
395
|
const users = this.opts.store.listHubUsers().filter(u => u.status === "active");
|
|
304
|
-
|
|
396
|
+
const contribs = this.opts.store.getHubUserContributions();
|
|
397
|
+
const ownerId = this.authState.bootstrapAdminUserId || "";
|
|
398
|
+
const now = Date.now();
|
|
399
|
+
return this.json(res, 200, { users: users.map(u => {
|
|
400
|
+
const c = contribs[u.id] || { memoryCount: 0, taskCount: 0, skillCount: 0 };
|
|
401
|
+
const isOnline = u.id === ownerId || (!!u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS);
|
|
402
|
+
return {
|
|
403
|
+
id: u.id, username: u.username, role: u.role, status: u.status,
|
|
404
|
+
deviceName: u.deviceName, createdAt: u.createdAt, approvedAt: u.approvedAt,
|
|
405
|
+
lastIp: u.lastIp || "", lastActiveAt: u.lastActiveAt,
|
|
406
|
+
isOwner: u.id === ownerId, isOnline,
|
|
407
|
+
memoryCount: c.memoryCount, taskCount: c.taskCount, skillCount: c.skillCount,
|
|
408
|
+
};
|
|
409
|
+
}) });
|
|
305
410
|
}
|
|
306
|
-
|
|
307
|
-
if (req.method === "GET" && routePath === "/api/v1/hub/groups") {
|
|
411
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/admin/change-role") {
|
|
308
412
|
if (auth.role !== "admin")
|
|
309
413
|
return this.json(res, 403, { error: "forbidden" });
|
|
310
|
-
const
|
|
311
|
-
|
|
414
|
+
const body = await this.readJson(req);
|
|
415
|
+
const userId = String(body?.userId || "");
|
|
416
|
+
const newRole = String(body?.role || "");
|
|
417
|
+
if (!userId || (newRole !== "admin" && newRole !== "member"))
|
|
418
|
+
return this.json(res, 400, { error: "invalid_params" });
|
|
419
|
+
if (newRole === "member" && userId === this.authState.bootstrapAdminUserId) {
|
|
420
|
+
return this.json(res, 403, { error: "cannot_demote_owner", message: "The hub owner cannot be demoted" });
|
|
421
|
+
}
|
|
422
|
+
const user = this.opts.store.getHubUser(userId);
|
|
423
|
+
if (!user || user.status !== "active")
|
|
424
|
+
return this.json(res, 404, { error: "not_found" });
|
|
425
|
+
const updatedUser = { ...user, role: newRole };
|
|
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 });
|
|
312
429
|
}
|
|
313
|
-
if (req.method === "POST" && routePath === "/api/v1/hub/
|
|
430
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/admin/rename-user") {
|
|
314
431
|
if (auth.role !== "admin")
|
|
315
432
|
return this.json(res, 403, { error: "forbidden" });
|
|
316
433
|
const body = await this.readJson(req);
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
this.opts.store.upsertHubGroup({
|
|
322
|
-
id: groupId,
|
|
323
|
-
name,
|
|
324
|
-
description: String(body.description || ""),
|
|
325
|
-
createdAt: Date.now(),
|
|
326
|
-
});
|
|
327
|
-
return this.json(res, 201, { id: groupId, name });
|
|
328
|
-
}
|
|
329
|
-
const groupDetailMatch = routePath.match(/^\/api\/v1\/hub\/groups\/([^/]+)$/);
|
|
330
|
-
if (groupDetailMatch) {
|
|
331
|
-
const groupId = decodeURIComponent(groupDetailMatch[1]);
|
|
332
|
-
if (req.method === "GET") {
|
|
333
|
-
if (auth.role !== "admin")
|
|
334
|
-
return this.json(res, 403, { error: "forbidden" });
|
|
335
|
-
const group = this.opts.store.getHubGroupById(groupId);
|
|
336
|
-
if (!group)
|
|
337
|
-
return this.json(res, 404, { error: "not_found" });
|
|
338
|
-
const members = this.opts.store.listHubGroupMembers(groupId);
|
|
339
|
-
return this.json(res, 200, { ...group, members });
|
|
340
|
-
}
|
|
341
|
-
if (req.method === "PUT") {
|
|
342
|
-
if (auth.role !== "admin")
|
|
343
|
-
return this.json(res, 403, { error: "forbidden" });
|
|
344
|
-
const existing = this.opts.store.getHubGroupById(groupId);
|
|
345
|
-
if (!existing)
|
|
346
|
-
return this.json(res, 404, { error: "not_found" });
|
|
347
|
-
const body = await this.readJson(req);
|
|
348
|
-
this.opts.store.upsertHubGroup({
|
|
349
|
-
id: groupId,
|
|
350
|
-
name: String(body.name || existing.name).trim(),
|
|
351
|
-
description: String(body.description ?? existing.description),
|
|
352
|
-
createdAt: existing.createdAt,
|
|
353
|
-
});
|
|
354
|
-
return this.json(res, 200, { ok: true });
|
|
355
|
-
}
|
|
356
|
-
if (req.method === "DELETE") {
|
|
357
|
-
if (auth.role !== "admin")
|
|
358
|
-
return this.json(res, 403, { error: "forbidden" });
|
|
359
|
-
const deleted = this.opts.store.deleteHubGroup(groupId);
|
|
360
|
-
if (!deleted)
|
|
361
|
-
return this.json(res, 404, { error: "not_found" });
|
|
362
|
-
return this.json(res, 200, { ok: true });
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
const groupMembersMatch = routePath.match(/^\/api\/v1\/hub\/groups\/([^/]+)\/members$/);
|
|
366
|
-
if (groupMembersMatch) {
|
|
367
|
-
const groupId = decodeURIComponent(groupMembersMatch[1]);
|
|
368
|
-
if (req.method === "POST") {
|
|
369
|
-
if (auth.role !== "admin")
|
|
370
|
-
return this.json(res, 403, { error: "forbidden" });
|
|
371
|
-
const group = this.opts.store.getHubGroupById(groupId);
|
|
372
|
-
if (!group)
|
|
373
|
-
return this.json(res, 404, { error: "group_not_found" });
|
|
374
|
-
const body = await this.readJson(req);
|
|
375
|
-
const userId = String(body.userId || "");
|
|
376
|
-
if (!userId)
|
|
377
|
-
return this.json(res, 400, { error: "userId_required" });
|
|
378
|
-
const user = this.opts.store.getHubUser(userId);
|
|
379
|
-
if (!user)
|
|
380
|
-
return this.json(res, 404, { error: "user_not_found" });
|
|
381
|
-
this.opts.store.addHubGroupMember(groupId, userId);
|
|
382
|
-
return this.json(res, 200, { ok: true });
|
|
383
|
-
}
|
|
384
|
-
if (req.method === "DELETE") {
|
|
385
|
-
if (auth.role !== "admin")
|
|
386
|
-
return this.json(res, 403, { error: "forbidden" });
|
|
387
|
-
const body = await this.readJson(req);
|
|
388
|
-
const userId = String(body.userId || "");
|
|
389
|
-
if (!userId)
|
|
390
|
-
return this.json(res, 400, { error: "userId_required" });
|
|
391
|
-
this.opts.store.removeHubGroupMember(groupId, userId);
|
|
392
|
-
return this.json(res, 200, { ok: true });
|
|
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" });
|
|
393
438
|
}
|
|
439
|
+
if (this.userManager.isUsernameTaken(newUsername, userId)) {
|
|
440
|
+
return this.json(res, 409, { error: "username_taken", message: "Username already in use" });
|
|
441
|
+
}
|
|
442
|
+
const user = this.opts.store.getHubUser(userId);
|
|
443
|
+
if (!user || user.status !== "active")
|
|
444
|
+
return this.json(res, 404, { error: "not_found" });
|
|
445
|
+
const ttlMs = user.role === "admin" ? 3650 * 24 * 60 * 60 * 1000 : undefined;
|
|
446
|
+
const newToken = (0, auth_1.issueUserToken)({ userId: user.id, username: newUsername, role: user.role, status: user.status }, this.authSecret, ttlMs);
|
|
447
|
+
this.userManager.approveUser(user.id, newToken);
|
|
448
|
+
const updated = this.opts.store.getHubUser(userId);
|
|
449
|
+
const finalUser = { ...updated, username: newUsername };
|
|
450
|
+
this.opts.store.upsertHubUser(finalUser);
|
|
451
|
+
this.opts.log.info(`Hub: admin "${auth.userId}" renamed user "${userId}" to "${newUsername}"`);
|
|
452
|
+
return this.json(res, 200, { ok: true, username: newUsername });
|
|
453
|
+
}
|
|
454
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/admin/remove-user") {
|
|
455
|
+
if (auth.role !== "admin")
|
|
456
|
+
return this.json(res, 403, { error: "forbidden" });
|
|
457
|
+
const body = await this.readJson(req);
|
|
458
|
+
const userId = String(body?.userId || "");
|
|
459
|
+
if (!userId)
|
|
460
|
+
return this.json(res, 400, { error: "missing_user_id" });
|
|
461
|
+
if (userId === auth.userId)
|
|
462
|
+
return this.json(res, 400, { error: "cannot_remove_self" });
|
|
463
|
+
if (userId === this.authState.bootstrapAdminUserId)
|
|
464
|
+
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)
|
|
468
|
+
return this.json(res, 404, { error: "not_found" });
|
|
469
|
+
this.opts.log.info(`Hub: admin "${auth.userId}" removed user "${userId}" (cleanResources=${cleanResources})`);
|
|
470
|
+
return this.json(res, 200, { ok: true });
|
|
394
471
|
}
|
|
395
472
|
if (req.method === "POST" && routePath === "/api/v1/hub/tasks/share") {
|
|
396
473
|
const body = await this.readJson(req);
|
|
397
474
|
if (!body?.task)
|
|
398
475
|
return this.json(res, 400, { error: "invalid_payload" });
|
|
399
476
|
const task = { ...body.task, sourceUserId: auth.userId };
|
|
477
|
+
const existingTask = task.sourceTaskId ? this.opts.store.getHubTaskBySource(auth.userId, task.sourceTaskId) : null;
|
|
400
478
|
this.opts.store.upsertHubTask(task);
|
|
401
479
|
const chunks = Array.isArray(body.chunks) ? body.chunks : [];
|
|
402
480
|
const chunkIds = [];
|
|
@@ -404,15 +482,22 @@ class HubServer {
|
|
|
404
482
|
this.opts.store.upsertHubChunk({ ...chunk, sourceUserId: auth.userId });
|
|
405
483
|
chunkIds.push(chunk.id);
|
|
406
484
|
}
|
|
407
|
-
// Async embedding: don't block the response
|
|
408
485
|
if (this.opts.embedder && chunkIds.length > 0) {
|
|
409
486
|
this.embedChunksAsync(chunkIds, chunks);
|
|
410
487
|
}
|
|
488
|
+
if (!existingTask) {
|
|
489
|
+
this.notifyAdmins("resource_shared", "task", String(task.title || task.sourceTaskId || ""), auth.userId);
|
|
490
|
+
}
|
|
411
491
|
return this.json(res, 200, { ok: true, chunks: chunkIds.length });
|
|
412
492
|
}
|
|
413
493
|
if (req.method === "POST" && routePath === "/api/v1/hub/tasks/unshare") {
|
|
414
494
|
const body = await this.readJson(req);
|
|
415
|
-
|
|
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
|
+
}
|
|
416
501
|
return this.json(res, 200, { ok: true });
|
|
417
502
|
}
|
|
418
503
|
if (req.method === "POST" && routePath === "/api/v1/hub/memories/share") {
|
|
@@ -444,6 +529,9 @@ class HubServer {
|
|
|
444
529
|
if (this.opts.embedder) {
|
|
445
530
|
this.embedMemoryAsync(memoryId, String(m.summary || ""), String(m.content || ""));
|
|
446
531
|
}
|
|
532
|
+
if (!existing) {
|
|
533
|
+
this.notifyAdmins("resource_shared", "memory", String(m.summary || m.content?.slice(0, 60) || memoryId), auth.userId);
|
|
534
|
+
}
|
|
447
535
|
return this.json(res, 200, { ok: true, memoryId, visibility });
|
|
448
536
|
}
|
|
449
537
|
if (req.method === "POST" && routePath === "/api/v1/hub/memories/unshare") {
|
|
@@ -451,7 +539,11 @@ class HubServer {
|
|
|
451
539
|
const sourceChunkId = String(body?.sourceChunkId || "");
|
|
452
540
|
if (!sourceChunkId)
|
|
453
541
|
return this.json(res, 400, { error: "missing_source_chunk_id" });
|
|
542
|
+
const existing = this.opts.store.getHubMemoryBySource(auth.userId, sourceChunkId);
|
|
454
543
|
this.opts.store.deleteHubMemoryBySource(auth.userId, sourceChunkId);
|
|
544
|
+
if (existing) {
|
|
545
|
+
this.notifyAdmins("resource_unshared", "memory", existing.summary || existing.content?.slice(0, 60) || sourceChunkId, auth.userId);
|
|
546
|
+
}
|
|
455
547
|
return this.json(res, 200, { ok: true });
|
|
456
548
|
}
|
|
457
549
|
if (req.method === "GET" && routePath === "/api/v1/hub/memories") {
|
|
@@ -565,19 +657,73 @@ class HubServer {
|
|
|
565
657
|
return this.json(res, 200, { hits, meta: { totalCandidates: hits.length, searchedGroups: [], includedPublic: true } });
|
|
566
658
|
}
|
|
567
659
|
if (req.method === "GET" && routePath === "/api/v1/hub/skills") {
|
|
568
|
-
const
|
|
660
|
+
const skillQuery = String(url.searchParams.get("query") || "");
|
|
661
|
+
const skillMaxResults = Number(url.searchParams.get("maxResults") || 10);
|
|
662
|
+
const ftsSkillHits = this.opts.store.searchHubSkills(skillQuery, {
|
|
569
663
|
userId: auth.userId,
|
|
570
|
-
maxResults:
|
|
571
|
-
})
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
664
|
+
maxResults: skillMaxResults * 2,
|
|
665
|
+
});
|
|
666
|
+
let mergedSkillIds;
|
|
667
|
+
if (this.opts.embedder && skillQuery) {
|
|
668
|
+
try {
|
|
669
|
+
const [queryVec] = await this.opts.embedder.embed([skillQuery]);
|
|
670
|
+
if (queryVec) {
|
|
671
|
+
const skillEmbs = this.opts.store.getVisibleHubSkillEmbeddings();
|
|
672
|
+
const cosineSim = (vec) => {
|
|
673
|
+
let dot = 0, nA = 0, nB = 0;
|
|
674
|
+
for (let i = 0; i < queryVec.length && i < vec.length; i++) {
|
|
675
|
+
dot += queryVec[i] * vec[i];
|
|
676
|
+
nA += queryVec[i] * queryVec[i];
|
|
677
|
+
nB += vec[i] * vec[i];
|
|
678
|
+
}
|
|
679
|
+
return nA > 0 && nB > 0 ? dot / (Math.sqrt(nA) * Math.sqrt(nB)) : 0;
|
|
680
|
+
};
|
|
681
|
+
const vecScored = skillEmbs
|
|
682
|
+
.map(e => ({ id: e.skillId, score: cosineSim(e.vector) }))
|
|
683
|
+
.filter(e => e.score > 0.3)
|
|
684
|
+
.sort((a, b) => b.score - a.score)
|
|
685
|
+
.slice(0, skillMaxResults * 2);
|
|
686
|
+
const K = 60;
|
|
687
|
+
const rrfScores = new Map();
|
|
688
|
+
ftsSkillHits.forEach(({ hit }, idx) => {
|
|
689
|
+
rrfScores.set(hit.id, (rrfScores.get(hit.id) ?? 0) + 1 / (K + idx + 1));
|
|
690
|
+
});
|
|
691
|
+
vecScored.forEach(({ id }, idx) => {
|
|
692
|
+
rrfScores.set(id, (rrfScores.get(id) ?? 0) + 1 / (K + idx + 1));
|
|
693
|
+
});
|
|
694
|
+
mergedSkillIds = [...rrfScores.entries()].sort((a, b) => b[1] - a[1]).slice(0, skillMaxResults).map(([id]) => id);
|
|
695
|
+
}
|
|
696
|
+
else {
|
|
697
|
+
mergedSkillIds = ftsSkillHits.slice(0, skillMaxResults).map(({ hit }) => hit.id);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
catch {
|
|
701
|
+
mergedSkillIds = ftsSkillHits.slice(0, skillMaxResults).map(({ hit }) => hit.id);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
else {
|
|
705
|
+
mergedSkillIds = ftsSkillHits.slice(0, skillMaxResults).map(({ hit }) => hit.id);
|
|
706
|
+
}
|
|
707
|
+
const ftsSkillMap = new Map(ftsSkillHits.map(({ hit }) => [hit.id, hit]));
|
|
708
|
+
const hits = mergedSkillIds.map(id => {
|
|
709
|
+
const hit = ftsSkillMap.get(id);
|
|
710
|
+
if (hit) {
|
|
711
|
+
return {
|
|
712
|
+
skillId: hit.id, name: hit.name, description: hit.description,
|
|
713
|
+
version: hit.version, visibility: hit.visibility, groupName: hit.group_name,
|
|
714
|
+
ownerName: hit.owner_name || "unknown", ownerStatus: hit.owner_status || "",
|
|
715
|
+
qualityScore: hit.quality_score,
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
const skill = this.opts.store.getHubSkillById(id);
|
|
719
|
+
if (!skill)
|
|
720
|
+
return null;
|
|
721
|
+
return {
|
|
722
|
+
skillId: skill.id, name: skill.name, description: skill.description,
|
|
723
|
+
version: skill.version, visibility: skill.visibility, groupName: "",
|
|
724
|
+
ownerName: "unknown", ownerStatus: "", qualityScore: skill.qualityScore,
|
|
725
|
+
};
|
|
726
|
+
}).filter(Boolean);
|
|
581
727
|
return this.json(res, 200, { hits });
|
|
582
728
|
}
|
|
583
729
|
if (req.method === "POST" && routePath === "/api/v1/hub/skills/publish") {
|
|
@@ -603,6 +749,10 @@ class HubServer {
|
|
|
603
749
|
createdAt: existing?.createdAt ?? Date.now(),
|
|
604
750
|
updatedAt: Date.now(),
|
|
605
751
|
});
|
|
752
|
+
this.embedSkillAsync(skillId, String(metadata.name || sourceSkillId), String(metadata.description || ""), auth.userId, sourceSkillId);
|
|
753
|
+
if (!existing) {
|
|
754
|
+
this.notifyAdmins("resource_shared", "skill", String(metadata.name || sourceSkillId), auth.userId);
|
|
755
|
+
}
|
|
606
756
|
return this.json(res, 200, { ok: true, skillId, visibility });
|
|
607
757
|
}
|
|
608
758
|
const skillBundleMatch = req.method === "GET" ? routePath.match(/^\/api\/v1\/hub\/skills\/([^/]+)\/bundle$/) : null;
|
|
@@ -624,7 +774,12 @@ class HubServer {
|
|
|
624
774
|
}
|
|
625
775
|
if (req.method === "POST" && routePath === "/api/v1/hub/skills/unpublish") {
|
|
626
776
|
const body = await this.readJson(req);
|
|
627
|
-
|
|
777
|
+
const srcSkillId = String(body?.sourceSkillId || "");
|
|
778
|
+
const existing = this.opts.store.getHubSkillBySource(auth.userId, srcSkillId);
|
|
779
|
+
this.opts.store.deleteHubSkillBySource(auth.userId, srcSkillId);
|
|
780
|
+
if (existing) {
|
|
781
|
+
this.notifyAdmins("resource_unshared", "skill", existing.name || srcSkillId, auth.userId);
|
|
782
|
+
}
|
|
628
783
|
return this.json(res, 200, { ok: true });
|
|
629
784
|
}
|
|
630
785
|
// ── Admin: shared tasks & skills management ──
|
|
@@ -634,14 +789,51 @@ class HubServer {
|
|
|
634
789
|
const tasks = this.opts.store.listAllHubTasks();
|
|
635
790
|
return this.json(res, 200, { tasks });
|
|
636
791
|
}
|
|
792
|
+
const hubTaskDetailMatch = req.method === "GET" ? routePath.match(/^\/api\/v1\/hub\/shared-tasks\/([^/]+)\/detail$/) : null;
|
|
793
|
+
if (hubTaskDetailMatch) {
|
|
794
|
+
const taskId = decodeURIComponent(hubTaskDetailMatch[1]);
|
|
795
|
+
const task = this.opts.store.getHubTaskById(taskId);
|
|
796
|
+
if (!task)
|
|
797
|
+
return this.json(res, 404, { error: "not_found" });
|
|
798
|
+
const chunks = this.opts.store.listHubChunksByTaskId(taskId);
|
|
799
|
+
return this.json(res, 200, {
|
|
800
|
+
id: task.id, title: task.title, summary: task.summary,
|
|
801
|
+
startedAt: task.createdAt, endedAt: task.updatedAt,
|
|
802
|
+
chunks: chunks.map(c => ({ role: c.role, content: c.content, summary: c.summary, kind: c.kind, createdAt: c.createdAt })),
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
const hubSkillDetailMatch = req.method === "GET" ? routePath.match(/^\/api\/v1\/hub\/shared-skills\/([^/]+)\/detail$/) : null;
|
|
806
|
+
if (hubSkillDetailMatch) {
|
|
807
|
+
const skillId = decodeURIComponent(hubSkillDetailMatch[1]);
|
|
808
|
+
const skill = this.opts.store.getHubSkillById(skillId);
|
|
809
|
+
if (!skill)
|
|
810
|
+
return this.json(res, 404, { error: "not_found" });
|
|
811
|
+
let files = [];
|
|
812
|
+
try {
|
|
813
|
+
const bundle = JSON.parse(skill.bundle || "{}");
|
|
814
|
+
if (Array.isArray(bundle.files)) {
|
|
815
|
+
files = bundle.files.map((f) => ({ path: f.path ?? f.name ?? "unknown", type: f.type ?? "file", size: f.size ?? (f.content ? f.content.length : 0) }));
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
catch { /* ignore parse error */ }
|
|
819
|
+
return this.json(res, 200, {
|
|
820
|
+
skill: { id: skill.id, name: skill.name, description: skill.description, version: skill.version, qualityScore: skill.qualityScore, status: "published" },
|
|
821
|
+
files,
|
|
822
|
+
versions: [],
|
|
823
|
+
});
|
|
824
|
+
}
|
|
637
825
|
const adminTaskDeleteMatch = req.method === "DELETE" ? routePath.match(/^\/api\/v1\/hub\/admin\/shared-tasks\/([^/]+)$/) : null;
|
|
638
826
|
if (adminTaskDeleteMatch) {
|
|
639
827
|
if (auth.role !== "admin")
|
|
640
828
|
return this.json(res, 403, { error: "forbidden" });
|
|
641
829
|
const taskId = decodeURIComponent(adminTaskDeleteMatch[1]);
|
|
830
|
+
const taskInfo = this.opts.store.getHubTaskById(taskId);
|
|
642
831
|
const deleted = this.opts.store.deleteHubTaskById(taskId);
|
|
643
832
|
if (!deleted)
|
|
644
833
|
return this.json(res, 404, { error: "not_found" });
|
|
834
|
+
if (taskInfo) {
|
|
835
|
+
this.opts.store.insertHubNotification({ id: (0, crypto_1.randomUUID)(), userId: taskInfo.sourceUserId, type: "resource_removed", resource: "task", title: taskInfo.title });
|
|
836
|
+
}
|
|
645
837
|
return this.json(res, 200, { ok: true });
|
|
646
838
|
}
|
|
647
839
|
if (req.method === "GET" && routePath === "/api/v1/hub/admin/shared-skills") {
|
|
@@ -655,9 +847,13 @@ class HubServer {
|
|
|
655
847
|
if (auth.role !== "admin")
|
|
656
848
|
return this.json(res, 403, { error: "forbidden" });
|
|
657
849
|
const skillId = decodeURIComponent(adminSkillDeleteMatch[1]);
|
|
850
|
+
const skillInfo = this.opts.store.getHubSkillById(skillId);
|
|
658
851
|
const deleted = this.opts.store.deleteHubSkillById(skillId);
|
|
659
852
|
if (!deleted)
|
|
660
853
|
return this.json(res, 404, { error: "not_found" });
|
|
854
|
+
if (skillInfo) {
|
|
855
|
+
this.opts.store.insertHubNotification({ id: (0, crypto_1.randomUUID)(), userId: skillInfo.sourceUserId, type: "resource_removed", resource: "skill", title: skillInfo.name });
|
|
856
|
+
}
|
|
661
857
|
return this.json(res, 200, { ok: true });
|
|
662
858
|
}
|
|
663
859
|
if (req.method === "GET" && routePath === "/api/v1/hub/admin/shared-memories") {
|
|
@@ -671,9 +867,13 @@ class HubServer {
|
|
|
671
867
|
if (auth.role !== "admin")
|
|
672
868
|
return this.json(res, 403, { error: "forbidden" });
|
|
673
869
|
const memoryId = decodeURIComponent(adminMemoryDeleteMatch[1]);
|
|
870
|
+
const memInfo = this.opts.store.getHubMemoryById(memoryId);
|
|
674
871
|
const deleted = this.opts.store.deleteHubMemoryById(memoryId);
|
|
675
872
|
if (!deleted)
|
|
676
873
|
return this.json(res, 404, { error: "not_found" });
|
|
874
|
+
if (memInfo) {
|
|
875
|
+
this.opts.store.insertHubNotification({ id: (0, crypto_1.randomUUID)(), userId: memInfo.sourceUserId, type: "resource_removed", resource: "memory", title: memInfo.summary || memInfo.id });
|
|
876
|
+
}
|
|
677
877
|
return this.json(res, 200, { ok: true });
|
|
678
878
|
}
|
|
679
879
|
if (req.method === "POST" && routePath === "/api/v1/hub/memory-detail") {
|
|
@@ -698,8 +898,85 @@ class HubServer {
|
|
|
698
898
|
source: { ts: chunk.createdAt, role: chunk.role },
|
|
699
899
|
});
|
|
700
900
|
}
|
|
901
|
+
if (req.method === "GET" && routePath === "/api/v1/hub/notifications") {
|
|
902
|
+
const unread = (new URL(req.url, `http://${req.headers.host}`)).searchParams.get("unread") === "1";
|
|
903
|
+
const list = this.opts.store.listHubNotifications(auth.userId, { unreadOnly: unread, limit: 50 });
|
|
904
|
+
const unreadCount = this.opts.store.countUnreadHubNotifications(auth.userId);
|
|
905
|
+
return this.json(res, 200, { notifications: list, unreadCount });
|
|
906
|
+
}
|
|
907
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/notifications/read") {
|
|
908
|
+
const body = await this.readJson(req);
|
|
909
|
+
const ids = Array.isArray(body.ids) ? body.ids : undefined;
|
|
910
|
+
this.opts.store.markHubNotificationsRead(auth.userId, ids);
|
|
911
|
+
return this.json(res, 200, { ok: true });
|
|
912
|
+
}
|
|
913
|
+
if (req.method === "POST" && routePath === "/api/v1/hub/notifications/clear") {
|
|
914
|
+
this.opts.store.clearHubNotifications(auth.userId);
|
|
915
|
+
return this.json(res, 200, { ok: true });
|
|
916
|
+
}
|
|
701
917
|
return this.json(res, 404, { error: "not_found" });
|
|
702
918
|
}
|
|
919
|
+
notifyAdmins(type, resource, title, fromUserId, opts) {
|
|
920
|
+
try {
|
|
921
|
+
const admins = this.opts.store.listHubUsers("active").filter(u => u.role === "admin" && u.id !== fromUserId);
|
|
922
|
+
for (const admin of admins) {
|
|
923
|
+
if (opts?.dedup && this.opts.store.hasRecentHubNotification(admin.id, type, resource, opts.deduoWindowMs ?? 300_000)) {
|
|
924
|
+
continue;
|
|
925
|
+
}
|
|
926
|
+
this.opts.store.insertHubNotification({ id: (0, crypto_1.randomUUID)(), userId: admin.id, type, resource, title });
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
catch { /* best-effort */ }
|
|
930
|
+
}
|
|
931
|
+
initOnlineTracking() {
|
|
932
|
+
try {
|
|
933
|
+
const ownerId = this.authState.bootstrapAdminUserId || "";
|
|
934
|
+
const users = this.opts.store.listHubUsers("active");
|
|
935
|
+
const now = Date.now();
|
|
936
|
+
for (const u of users) {
|
|
937
|
+
if (u.id === ownerId)
|
|
938
|
+
continue;
|
|
939
|
+
if (u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS) {
|
|
940
|
+
this.knownOnlineUsers.add(u.id);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
catch { /* best-effort */ }
|
|
945
|
+
}
|
|
946
|
+
checkOfflineUsers() {
|
|
947
|
+
try {
|
|
948
|
+
const ownerId = this.authState.bootstrapAdminUserId || "";
|
|
949
|
+
const users = this.opts.store.listHubUsers("active");
|
|
950
|
+
const now = Date.now();
|
|
951
|
+
const currentlyOnline = new Set();
|
|
952
|
+
for (const u of users) {
|
|
953
|
+
if (u.id === ownerId)
|
|
954
|
+
continue;
|
|
955
|
+
if (u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS) {
|
|
956
|
+
currentlyOnline.add(u.id);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
for (const uid of this.knownOnlineUsers) {
|
|
960
|
+
if (!currentlyOnline.has(uid)) {
|
|
961
|
+
const user = users.find(u => u.id === uid);
|
|
962
|
+
if (user) {
|
|
963
|
+
this.notifyAdmins("user_offline", "user", user.username, uid);
|
|
964
|
+
this.opts.log.info(`Hub: user "${user.username}" (${uid}) went offline`);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
for (const uid of currentlyOnline) {
|
|
969
|
+
if (!this.knownOnlineUsers.has(uid)) {
|
|
970
|
+
const user = users.find(u => u.id === uid);
|
|
971
|
+
if (user) {
|
|
972
|
+
this.notifyAdmins("user_online", "user", user.username, uid);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
this.knownOnlineUsers = currentlyOnline;
|
|
977
|
+
}
|
|
978
|
+
catch { /* best-effort */ }
|
|
979
|
+
}
|
|
703
980
|
authenticate(req) {
|
|
704
981
|
const header = req.headers.authorization;
|
|
705
982
|
if (!header || !header.startsWith("Bearer "))
|
|
@@ -714,6 +991,13 @@ class HubServer {
|
|
|
714
991
|
const hash = (0, crypto_1.createHash)("sha256").update(token).digest("hex");
|
|
715
992
|
if (user.tokenHash !== hash)
|
|
716
993
|
return null;
|
|
994
|
+
const clientIp = req.headers["x-client-ip"]?.trim()
|
|
995
|
+
|| req.headers["x-forwarded-for"]?.split(",")[0]?.trim()
|
|
996
|
+
|| req.socket.remoteAddress || "";
|
|
997
|
+
try {
|
|
998
|
+
this.opts.store.updateHubUserActivity(user.id, clientIp);
|
|
999
|
+
}
|
|
1000
|
+
catch { /* best-effort */ }
|
|
717
1001
|
return {
|
|
718
1002
|
userId: user.id,
|
|
719
1003
|
username: user.username,
|