@memtensor/memos-local-openclaw-plugin 1.0.4-beta.2 → 1.0.4-beta.20

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.
Files changed (124) hide show
  1. package/.env.example +7 -0
  2. package/README.md +111 -44
  3. package/dist/capture/index.d.ts +1 -1
  4. package/dist/capture/index.d.ts.map +1 -1
  5. package/dist/capture/index.js +36 -2
  6. package/dist/capture/index.js.map +1 -1
  7. package/dist/client/connector.d.ts +6 -2
  8. package/dist/client/connector.d.ts.map +1 -1
  9. package/dist/client/connector.js +160 -26
  10. package/dist/client/connector.js.map +1 -1
  11. package/dist/client/hub.d.ts.map +1 -1
  12. package/dist/client/hub.js +22 -0
  13. package/dist/client/hub.js.map +1 -1
  14. package/dist/client/skill-sync.d.ts +7 -0
  15. package/dist/client/skill-sync.d.ts.map +1 -1
  16. package/dist/client/skill-sync.js +10 -0
  17. package/dist/client/skill-sync.js.map +1 -1
  18. package/dist/config.d.ts.map +1 -1
  19. package/dist/config.js +2 -3
  20. package/dist/config.js.map +1 -1
  21. package/dist/hub/server.d.ts +9 -0
  22. package/dist/hub/server.d.ts.map +1 -1
  23. package/dist/hub/server.js +500 -112
  24. package/dist/hub/server.js.map +1 -1
  25. package/dist/hub/user-manager.d.ts +11 -0
  26. package/dist/hub/user-manager.d.ts.map +1 -1
  27. package/dist/hub/user-manager.js +31 -3
  28. package/dist/hub/user-manager.js.map +1 -1
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +7 -2
  31. package/dist/index.js.map +1 -1
  32. package/dist/ingest/chunker.d.ts +2 -1
  33. package/dist/ingest/chunker.d.ts.map +1 -1
  34. package/dist/ingest/chunker.js +14 -10
  35. package/dist/ingest/chunker.js.map +1 -1
  36. package/dist/ingest/providers/index.d.ts.map +1 -1
  37. package/dist/ingest/providers/index.js +37 -6
  38. package/dist/ingest/providers/index.js.map +1 -1
  39. package/dist/recall/engine.d.ts.map +1 -1
  40. package/dist/recall/engine.js +96 -1
  41. package/dist/recall/engine.js.map +1 -1
  42. package/dist/shared/llm-call.d.ts +1 -0
  43. package/dist/shared/llm-call.d.ts.map +1 -1
  44. package/dist/shared/llm-call.js +84 -9
  45. package/dist/shared/llm-call.js.map +1 -1
  46. package/dist/sharing/types.d.ts +1 -1
  47. package/dist/sharing/types.d.ts.map +1 -1
  48. package/dist/skill/evolver.d.ts +4 -0
  49. package/dist/skill/evolver.d.ts.map +1 -1
  50. package/dist/skill/evolver.js +59 -5
  51. package/dist/skill/evolver.js.map +1 -1
  52. package/dist/skill/generator.d.ts +2 -0
  53. package/dist/skill/generator.d.ts.map +1 -1
  54. package/dist/skill/generator.js +45 -3
  55. package/dist/skill/generator.js.map +1 -1
  56. package/dist/skill/installer.d.ts +26 -0
  57. package/dist/skill/installer.d.ts.map +1 -1
  58. package/dist/skill/installer.js +80 -4
  59. package/dist/skill/installer.js.map +1 -1
  60. package/dist/skill/upgrader.d.ts +2 -0
  61. package/dist/skill/upgrader.d.ts.map +1 -1
  62. package/dist/skill/upgrader.js +139 -1
  63. package/dist/skill/upgrader.js.map +1 -1
  64. package/dist/skill/validator.d.ts +3 -0
  65. package/dist/skill/validator.d.ts.map +1 -1
  66. package/dist/skill/validator.js +75 -0
  67. package/dist/skill/validator.js.map +1 -1
  68. package/dist/storage/ensure-binding.d.ts +12 -0
  69. package/dist/storage/ensure-binding.d.ts.map +1 -0
  70. package/dist/storage/ensure-binding.js +53 -0
  71. package/dist/storage/ensure-binding.js.map +1 -0
  72. package/dist/storage/sqlite.d.ts +115 -20
  73. package/dist/storage/sqlite.d.ts.map +1 -1
  74. package/dist/storage/sqlite.js +458 -110
  75. package/dist/storage/sqlite.js.map +1 -1
  76. package/dist/telemetry.d.ts +12 -5
  77. package/dist/telemetry.d.ts.map +1 -1
  78. package/dist/telemetry.js +156 -40
  79. package/dist/telemetry.js.map +1 -1
  80. package/dist/tools/memory-search.d.ts +3 -1
  81. package/dist/tools/memory-search.d.ts.map +1 -1
  82. package/dist/tools/memory-search.js +3 -1
  83. package/dist/tools/memory-search.js.map +1 -1
  84. package/dist/types.d.ts +11 -2
  85. package/dist/types.d.ts.map +1 -1
  86. package/dist/types.js +4 -0
  87. package/dist/types.js.map +1 -1
  88. package/dist/viewer/html.d.ts.map +1 -1
  89. package/dist/viewer/html.js +2952 -910
  90. package/dist/viewer/html.js.map +1 -1
  91. package/dist/viewer/server.d.ts +39 -8
  92. package/dist/viewer/server.d.ts.map +1 -1
  93. package/dist/viewer/server.js +1198 -227
  94. package/dist/viewer/server.js.map +1 -1
  95. package/index.ts +774 -74
  96. package/openclaw.plugin.json +2 -2
  97. package/package.json +3 -2
  98. package/scripts/postinstall.cjs +1 -1
  99. package/skill/memos-memory-guide/SKILL.md +64 -26
  100. package/src/capture/index.ts +40 -1
  101. package/src/client/connector.ts +161 -28
  102. package/src/client/hub.ts +18 -0
  103. package/src/client/skill-sync.ts +14 -0
  104. package/src/config.ts +2 -3
  105. package/src/hub/server.ts +481 -107
  106. package/src/hub/user-manager.ts +48 -8
  107. package/src/index.ts +10 -2
  108. package/src/ingest/chunker.ts +19 -13
  109. package/src/ingest/providers/index.ts +41 -7
  110. package/src/recall/engine.ts +89 -1
  111. package/src/shared/llm-call.ts +99 -10
  112. package/src/sharing/types.ts +1 -1
  113. package/src/skill/evolver.ts +63 -6
  114. package/src/skill/generator.ts +44 -5
  115. package/src/skill/installer.ts +107 -4
  116. package/src/skill/upgrader.ts +139 -1
  117. package/src/skill/validator.ts +79 -0
  118. package/src/storage/ensure-binding.ts +52 -0
  119. package/src/storage/sqlite.ts +498 -137
  120. package/src/telemetry.ts +172 -41
  121. package/src/tools/memory-search.ts +2 -1
  122. package/src/types.ts +12 -2
  123. package/src/viewer/html.ts +2952 -910
  124. package/src/viewer/server.ts +1109 -212
@@ -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);
@@ -89,18 +93,32 @@ class HubServer {
89
93
  res.end(JSON.stringify({ error: message }));
90
94
  }
91
95
  });
96
+ const MAX_PORT_RETRIES = 3;
97
+ let hubPort = this.port;
92
98
  await new Promise((resolve, reject) => {
99
+ let retries = 0;
93
100
  const onError = (err) => {
94
- this.server?.off("listening", onListening);
95
- reject(err);
101
+ if (err.code === "EADDRINUSE" && retries < MAX_PORT_RETRIES) {
102
+ retries++;
103
+ hubPort = this.port + retries;
104
+ this.opts.log.warn(`Hub port ${hubPort - 1} in use, trying ${hubPort}`);
105
+ this.server.listen(hubPort, "0.0.0.0");
106
+ }
107
+ else {
108
+ this.server?.off("listening", onListening);
109
+ reject(err);
110
+ }
96
111
  };
97
112
  const onListening = () => {
98
113
  this.server?.off("error", onError);
114
+ if (hubPort !== this.port) {
115
+ this.opts.log.info(`Hub started on fallback port ${hubPort} (configured: ${this.port})`);
116
+ }
99
117
  resolve();
100
118
  };
101
- this.server.once("error", onError);
119
+ this.server.on("error", onError);
102
120
  this.server.once("listening", onListening);
103
- this.server.listen(this.port, "0.0.0.0");
121
+ this.server.listen(hubPort, "0.0.0.0");
104
122
  });
105
123
  const bootstrap = this.userManager.ensureBootstrapAdmin(this.authSecret, "admin", this.authState.bootstrapAdminUserId, this.authState.bootstrapAdminToken);
106
124
  if (bootstrap.token) {
@@ -109,17 +127,43 @@ class HubServer {
109
127
  this.saveAuthState();
110
128
  this.opts.log.info(`memos-local: bootstrap admin token persisted to ${this.authStatePath}`);
111
129
  }
112
- return `http://127.0.0.1:${this.port}`;
130
+ this.initOnlineTracking();
131
+ this.offlineCheckTimer = setInterval(() => this.checkOfflineUsers(), HubServer.OFFLINE_CHECK_INTERVAL_MS);
132
+ return `http://127.0.0.1:${hubPort}`;
113
133
  }
114
134
  async stop() {
135
+ if (this.offlineCheckTimer) {
136
+ clearInterval(this.offlineCheckTimer);
137
+ this.offlineCheckTimer = undefined;
138
+ }
115
139
  if (!this.server)
116
140
  return;
141
+ try {
142
+ const activeUsers = this.opts.store.listHubUsers("active");
143
+ const ownerId = this.authState.bootstrapAdminUserId || "";
144
+ for (const u of activeUsers) {
145
+ if (u.id === ownerId)
146
+ continue;
147
+ try {
148
+ this.opts.store.insertHubNotification({
149
+ id: (0, crypto_1.randomUUID)(), userId: u.id, type: "hub_shutdown",
150
+ resource: "system", title: `Team server "${this.teamName}" has been shut down by the admin.`,
151
+ });
152
+ }
153
+ catch { /* best-effort */ }
154
+ }
155
+ }
156
+ catch { /* best-effort */ }
117
157
  const server = this.server;
118
158
  this.server = undefined;
119
159
  await new Promise((resolve) => server.close(() => resolve()));
120
160
  }
121
161
  get port() {
122
- return this.opts.config.sharing?.hub?.port ?? 18800;
162
+ const configured = this.opts.config.sharing?.hub?.port;
163
+ const derived = this.opts.defaultHubPort;
164
+ if (derived && (!configured || configured === 18800))
165
+ return derived;
166
+ return configured ?? 18800;
123
167
  }
124
168
  get teamName() {
125
169
  return this.opts.config.sharing?.hub?.teamName ?? "";
@@ -163,6 +207,20 @@ class HubServer {
163
207
  this.opts.log.warn(`hub: embedding shared chunks failed: ${err}`);
164
208
  });
165
209
  }
210
+ embedSkillAsync(skillId, name, description, sourceUserId, sourceSkillId) {
211
+ const embedder = this.opts.embedder;
212
+ if (!embedder)
213
+ return;
214
+ const text = `${name}: ${description}`;
215
+ embedder.embed([text]).then((vectors) => {
216
+ if (vectors[0]) {
217
+ this.opts.store.upsertHubSkillEmbedding(skillId, Array.from(vectors[0]), sourceUserId, sourceSkillId);
218
+ this.opts.log.info(`hub: embedded shared skill ${skillId}`);
219
+ }
220
+ }).catch((err) => {
221
+ this.opts.log.warn(`hub: embedding shared skill failed: ${err}`);
222
+ });
223
+ }
166
224
  embedMemoryAsync(memoryId, summary, content) {
167
225
  const embedder = this.opts.embedder;
168
226
  if (!embedder)
@@ -193,29 +251,73 @@ class HubServer {
193
251
  return this.json(res, 403, { error: "invalid_team_token" });
194
252
  }
195
253
  const username = String(body.username || `user-${(0, crypto_1.randomUUID)().slice(0, 8)}`);
196
- const existingUsers = this.opts.store.listHubUsers();
197
- const existingUser = existingUsers.find(u => u.username === username);
254
+ const joinIp = (typeof body.clientIp === "string" && body.clientIp)
255
+ || req.headers["x-client-ip"]?.trim()
256
+ || req.headers["x-forwarded-for"]?.split(",")[0]?.trim()
257
+ || req.socket.remoteAddress || "";
258
+ const identityKey = typeof body.identityKey === "string" ? body.identityKey.trim() : "";
259
+ let existingUser = identityKey
260
+ ? this.userManager.findByIdentityKey(identityKey)
261
+ : null;
262
+ if (!existingUser) {
263
+ const existingUsers = this.opts.store.listHubUsers();
264
+ existingUser = existingUsers.find(u => u.username === username && u.status !== "left" && u.status !== "removed") ?? null;
265
+ }
198
266
  if (existingUser) {
267
+ try {
268
+ this.opts.store.updateHubUserActivity(existingUser.id, joinIp);
269
+ }
270
+ catch { /* best-effort */ }
199
271
  if (existingUser.status === "active") {
200
272
  const token = (0, auth_1.issueUserToken)({ userId: existingUser.id, username: existingUser.username, role: existingUser.role, status: "active" }, this.authSecret);
201
273
  this.userManager.approveUser(existingUser.id, token);
202
- return this.json(res, 200, { status: "active", userId: existingUser.id, userToken: token });
274
+ if (identityKey && !existingUser.identityKey) {
275
+ this.opts.store.upsertHubUser({ ...existingUser, identityKey });
276
+ }
277
+ return this.json(res, 200, { status: "active", userId: existingUser.id, userToken: token, identityKey: existingUser.identityKey || identityKey });
203
278
  }
204
279
  if (existingUser.status === "pending") {
205
- return this.json(res, 200, { status: "pending", userId: existingUser.id });
280
+ this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
281
+ return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
206
282
  }
207
283
  if (existingUser.status === "rejected") {
208
- this.userManager.resetToPending(existingUser.id);
209
- this.opts.log.info(`Hub: rejected user "${username}" (${existingUser.id}) re-applied, reset to pending`);
210
- return this.json(res, 200, { status: "pending", userId: existingUser.id });
284
+ if (body.reapply === true) {
285
+ this.userManager.resetToPending(existingUser.id);
286
+ this.notifyAdmins("user_join_request", "user", username, "");
287
+ this.opts.log.info(`Hub: rejected user "${username}" (${existingUser.id}) re-applied, reset to pending`);
288
+ return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
289
+ }
290
+ return this.json(res, 200, { status: "rejected", userId: existingUser.id });
291
+ }
292
+ if (existingUser.status === "removed") {
293
+ this.userManager.rejoinUser(existingUser.id);
294
+ this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
295
+ this.opts.log.info(`Hub: removed user "${username}" (${existingUser.id}) re-applied via rejoin, reset to pending`);
296
+ return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
297
+ }
298
+ if (existingUser.status === "left") {
299
+ this.userManager.rejoinUser(existingUser.id);
300
+ this.notifyAdmins("user_join_request", "user", username, "", { dedup: true });
301
+ this.opts.log.info(`Hub: left user "${username}" (${existingUser.id}) re-applied via rejoin, reset to pending`);
302
+ return this.json(res, 200, { status: "pending", userId: existingUser.id, identityKey: existingUser.identityKey || identityKey });
303
+ }
304
+ if (existingUser.status === "blocked") {
305
+ return this.json(res, 200, { status: "blocked", userId: existingUser.id });
211
306
  }
212
307
  }
308
+ const generatedIdentityKey = identityKey || (0, crypto_1.randomUUID)();
213
309
  const user = this.userManager.createPendingUser({
214
310
  username,
215
311
  deviceName: typeof body.deviceName === "string" ? body.deviceName : undefined,
312
+ identityKey: generatedIdentityKey,
216
313
  });
314
+ try {
315
+ this.opts.store.updateHubUserActivity(user.id, joinIp);
316
+ }
317
+ catch { /* best-effort */ }
217
318
  this.opts.log.info(`Hub: user "${username}" (${user.id}) registered as pending, awaiting admin approval`);
218
- return this.json(res, 200, { status: "pending", userId: user.id });
319
+ this.notifyAdmins("user_join_request", "user", username, "");
320
+ return this.json(res, 200, { status: "pending", userId: user.id, identityKey: generatedIdentityKey });
219
321
  }
220
322
  if (req.method === "POST" && routePath === "/api/v1/hub/registration-status") {
221
323
  const body = await this.readJson(req);
@@ -234,12 +336,39 @@ class HubServer {
234
336
  if (user.status === "rejected") {
235
337
  return this.json(res, 200, { status: "rejected" });
236
338
  }
339
+ if (user.status === "blocked") {
340
+ return this.json(res, 200, { status: "blocked" });
341
+ }
342
+ if (user.status === "left") {
343
+ return this.json(res, 200, { status: "left" });
344
+ }
345
+ if (user.status === "removed") {
346
+ return this.json(res, 200, { status: "removed" });
347
+ }
237
348
  if (user.status === "active") {
238
349
  const token = (0, auth_1.issueUserToken)({ userId: user.id, username: user.username, role: user.role, status: user.status }, this.authSecret);
350
+ this.userManager.approveUser(user.id, token);
239
351
  return this.json(res, 200, { status: "active", userToken: token });
240
352
  }
241
353
  return this.json(res, 200, { status: user.status });
242
354
  }
355
+ if (req.method === "POST" && routePath === "/api/v1/hub/withdraw-pending") {
356
+ const body = await this.readJson(req);
357
+ if (!body || body.teamToken !== this.teamToken) {
358
+ return this.json(res, 403, { error: "invalid_team_token" });
359
+ }
360
+ const userId = String(body.userId || "");
361
+ if (!userId)
362
+ return this.json(res, 400, { error: "missing_user_id" });
363
+ const user = this.opts.store.getHubUser(userId);
364
+ if (!user)
365
+ return this.json(res, 200, { ok: true });
366
+ if (user.status === "pending") {
367
+ this.userManager.markUserLeft(userId);
368
+ this.opts.log.info(`Hub: user "${user.username}" (${userId}) withdrew pending application`);
369
+ }
370
+ return this.json(res, 200, { ok: true });
371
+ }
243
372
  // All endpoints below require authentication + rate limiting
244
373
  const auth = this.authenticate(req);
245
374
  if (!auth)
@@ -248,6 +377,16 @@ class HubServer {
248
377
  if (!this.checkRateLimit(auth.userId, endpointKey)) {
249
378
  return this.json(res, 429, { error: "rate_limit_exceeded", retryAfterMs: HubServer.RATE_WINDOW_MS });
250
379
  }
380
+ if (req.method === "POST" && routePath === "/api/v1/hub/heartbeat") {
381
+ return this.json(res, 200, { ok: true });
382
+ }
383
+ if (req.method === "POST" && routePath === "/api/v1/hub/leave") {
384
+ this.userManager.markUserLeft(auth.userId);
385
+ this.knownOnlineUsers.delete(auth.userId);
386
+ this.notifyAdmins("user_left", "user", auth.username, auth.userId);
387
+ this.opts.log.info(`Hub: user "${auth.username}" (${auth.userId}) left voluntarily, status set to "left"`);
388
+ return this.json(res, 200, { ok: true });
389
+ }
251
390
  if (req.method === "GET" && routePath === "/api/v1/hub/me") {
252
391
  const user = this.opts.store.getHubUser(auth.userId);
253
392
  if (!user)
@@ -268,8 +407,13 @@ class HubServer {
268
407
  const updated = this.userManager.updateUsername(auth.userId, newUsername);
269
408
  if (!updated)
270
409
  return this.json(res, 404, { error: "not_found" });
271
- const newToken = (0, auth_1.issueUserToken)({ userId: updated.id, username: newUsername, role: updated.role, status: updated.status }, this.authSecret);
410
+ const ttlMs = updated.role === "admin" ? 3650 * 24 * 60 * 60 * 1000 : undefined;
411
+ const newToken = (0, auth_1.issueUserToken)({ userId: updated.id, username: newUsername, role: updated.role, status: updated.status }, this.authSecret, ttlMs);
272
412
  this.userManager.approveUser(updated.id, newToken);
413
+ if (updated.id === this.authState.bootstrapAdminUserId) {
414
+ this.authState.bootstrapAdminToken = newToken;
415
+ this.saveAuthState();
416
+ }
273
417
  this.opts.log.info(`Hub: user "${auth.userId}" renamed to "${newUsername}"`);
274
418
  return this.json(res, 200, { ok: true, username: newUsername, userToken: newToken });
275
419
  }
@@ -282,121 +426,148 @@ class HubServer {
282
426
  if (auth.role !== "admin")
283
427
  return this.json(res, 403, { error: "forbidden" });
284
428
  const body = await this.readJson(req);
285
- const token = (0, auth_1.issueUserToken)({ userId: String(body.userId), username: String(body.username || ""), role: "member", status: "active" }, this.authSecret);
286
- const approved = this.userManager.approveUser(String(body.userId), token);
429
+ const userId = String(body.userId);
430
+ const username = String(body.username || "");
431
+ const token = (0, auth_1.issueUserToken)({ userId, username, role: "member", status: "active" }, this.authSecret);
432
+ const approved = this.userManager.approveUser(userId, token);
287
433
  if (!approved)
288
434
  return this.json(res, 404, { error: "not_found" });
435
+ try {
436
+ this.opts.store.updateHubUserActivity(userId, "");
437
+ }
438
+ catch { /* best-effort */ }
439
+ try {
440
+ this.opts.store.insertHubNotification({
441
+ id: (0, crypto_1.randomUUID)(), userId, type: "membership_approved",
442
+ resource: "user", title: `Your request to join team "${this.teamName}" has been approved. Welcome!`,
443
+ });
444
+ }
445
+ catch { /* best-effort */ }
289
446
  return this.json(res, 200, { status: "active", token });
290
447
  }
291
448
  if (req.method === "POST" && routePath === "/api/v1/hub/admin/reject-user") {
292
449
  if (auth.role !== "admin")
293
450
  return this.json(res, 403, { error: "forbidden" });
294
451
  const body = await this.readJson(req);
295
- const rejected = this.userManager.rejectUser(String(body.userId));
452
+ const userId = String(body.userId);
453
+ const rejected = this.userManager.rejectUser(userId);
296
454
  if (!rejected)
297
455
  return this.json(res, 404, { error: "not_found" });
456
+ try {
457
+ this.opts.store.insertHubNotification({
458
+ id: (0, crypto_1.randomUUID)(), userId, type: "membership_rejected",
459
+ resource: "user", title: `Your request to join team "${this.teamName}" has been declined.`,
460
+ });
461
+ }
462
+ catch { /* best-effort */ }
298
463
  return this.json(res, 200, { status: "rejected" });
299
464
  }
300
465
  if (req.method === "GET" && routePath === "/api/v1/hub/admin/users") {
301
466
  if (auth.role !== "admin")
302
467
  return this.json(res, 403, { error: "forbidden" });
303
468
  const users = this.opts.store.listHubUsers().filter(u => u.status === "active");
304
- return this.json(res, 200, { users: users.map(u => ({ id: u.id, username: u.username, role: u.role, status: u.status })) });
469
+ const contribs = this.opts.store.getHubUserContributions();
470
+ const ownerId = this.authState.bootstrapAdminUserId || "";
471
+ const now = Date.now();
472
+ return this.json(res, 200, { users: users.map(u => {
473
+ const c = contribs[u.id] || { memoryCount: 0, taskCount: 0, skillCount: 0 };
474
+ const isOnline = u.id === ownerId || (!!u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS);
475
+ return {
476
+ id: u.id, username: u.username, role: u.role, status: u.status,
477
+ deviceName: u.deviceName, createdAt: u.createdAt, approvedAt: u.approvedAt,
478
+ lastIp: u.lastIp || "", lastActiveAt: u.lastActiveAt,
479
+ isOwner: u.id === ownerId, isOnline,
480
+ memoryCount: c.memoryCount, taskCount: c.taskCount, skillCount: c.skillCount,
481
+ };
482
+ }) });
305
483
  }
306
- // ── Group management ──
307
- if (req.method === "GET" && routePath === "/api/v1/hub/groups") {
484
+ if (req.method === "POST" && routePath === "/api/v1/hub/admin/change-role") {
308
485
  if (auth.role !== "admin")
309
486
  return this.json(res, 403, { error: "forbidden" });
310
- const groups = this.opts.store.listHubGroups();
311
- return this.json(res, 200, { groups });
487
+ const body = await this.readJson(req);
488
+ const userId = String(body?.userId || "");
489
+ const newRole = String(body?.role || "");
490
+ if (!userId || (newRole !== "admin" && newRole !== "member"))
491
+ return this.json(res, 400, { error: "invalid_params" });
492
+ if (newRole === "member" && userId === this.authState.bootstrapAdminUserId) {
493
+ return this.json(res, 403, { error: "cannot_demote_owner", message: "The hub owner cannot be demoted" });
494
+ }
495
+ const user = this.opts.store.getHubUser(userId);
496
+ if (!user || user.status !== "active")
497
+ return this.json(res, 404, { error: "not_found" });
498
+ const updatedUser = { ...user, role: newRole };
499
+ this.opts.store.upsertHubUser(updatedUser);
500
+ this.opts.log.info(`Hub: admin "${auth.userId}" changed role of "${userId}" to "${newRole}"`);
501
+ try {
502
+ const notifType = newRole === "admin" ? "role_promoted" : "role_demoted";
503
+ this.opts.store.insertHubNotification({
504
+ id: (0, crypto_1.randomUUID)(), userId, type: notifType,
505
+ resource: "user", title: `Your role in team "${this.teamName}" has been changed to ${newRole}.`,
506
+ });
507
+ }
508
+ catch { /* best-effort */ }
509
+ return this.json(res, 200, { ok: true, role: newRole });
312
510
  }
313
- if (req.method === "POST" && routePath === "/api/v1/hub/groups") {
511
+ if (req.method === "POST" && routePath === "/api/v1/hub/admin/rename-user") {
314
512
  if (auth.role !== "admin")
315
513
  return this.json(res, 403, { error: "forbidden" });
316
514
  const body = await this.readJson(req);
317
- const name = String(body.name || "").trim();
318
- if (!name)
319
- return this.json(res, 400, { error: "name_required" });
320
- const groupId = (0, crypto_1.randomUUID)();
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 });
515
+ const userId = String(body?.userId || "");
516
+ const newUsername = String(body?.username || "").trim();
517
+ if (!userId || !newUsername || newUsername.length < 2 || newUsername.length > 32) {
518
+ return this.json(res, 400, { error: "invalid_params", message: "userId and username (2-32 chars) required" });
355
519
  }
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 });
520
+ if (this.userManager.isUsernameTaken(newUsername, userId)) {
521
+ return this.json(res, 409, { error: "username_taken", message: "Username already in use" });
363
522
  }
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 });
523
+ const user = this.opts.store.getHubUser(userId);
524
+ if (!user || user.status !== "active")
525
+ return this.json(res, 404, { error: "not_found" });
526
+ const ttlMs = user.role === "admin" ? 3650 * 24 * 60 * 60 * 1000 : undefined;
527
+ const newToken = (0, auth_1.issueUserToken)({ userId: user.id, username: newUsername, role: user.role, status: user.status }, this.authSecret, ttlMs);
528
+ this.userManager.approveUser(user.id, newToken);
529
+ const updated = this.opts.store.getHubUser(userId);
530
+ const finalUser = { ...updated, username: newUsername };
531
+ this.opts.store.upsertHubUser(finalUser);
532
+ if (userId === this.authState.bootstrapAdminUserId) {
533
+ this.authState.bootstrapAdminToken = newToken;
534
+ this.saveAuthState();
383
535
  }
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 });
536
+ this.opts.log.info(`Hub: admin "${auth.userId}" renamed user "${userId}" to "${newUsername}"`);
537
+ return this.json(res, 200, { ok: true, username: newUsername });
538
+ }
539
+ if (req.method === "POST" && routePath === "/api/v1/hub/admin/remove-user") {
540
+ if (auth.role !== "admin")
541
+ return this.json(res, 403, { error: "forbidden" });
542
+ const body = await this.readJson(req);
543
+ const userId = String(body?.userId || "");
544
+ if (!userId)
545
+ return this.json(res, 400, { error: "missing_user_id" });
546
+ if (userId === auth.userId)
547
+ return this.json(res, 400, { error: "cannot_remove_self" });
548
+ if (userId === this.authState.bootstrapAdminUserId)
549
+ return this.json(res, 403, { error: "cannot_remove_owner", message: "The hub owner cannot be removed" });
550
+ try {
551
+ this.opts.store.insertHubNotification({
552
+ id: (0, crypto_1.randomUUID)(), userId, type: "membership_removed",
553
+ resource: "user", title: `You have been removed from team "${this.teamName}" by the admin.`,
554
+ });
393
555
  }
556
+ catch { /* best-effort */ }
557
+ const cleanResources = body?.cleanResources === true;
558
+ const deleted = this.opts.store.deleteHubUser(userId, cleanResources);
559
+ if (!deleted)
560
+ return this.json(res, 404, { error: "not_found" });
561
+ this.knownOnlineUsers.delete(userId);
562
+ this.opts.log.info(`Hub: admin "${auth.userId}" removed user "${userId}" (cleanResources=${cleanResources})`);
563
+ return this.json(res, 200, { ok: true });
394
564
  }
395
565
  if (req.method === "POST" && routePath === "/api/v1/hub/tasks/share") {
396
566
  const body = await this.readJson(req);
397
567
  if (!body?.task)
398
568
  return this.json(res, 400, { error: "invalid_payload" });
399
569
  const task = { ...body.task, sourceUserId: auth.userId };
570
+ const existingTask = task.sourceTaskId ? this.opts.store.getHubTaskBySource(auth.userId, task.sourceTaskId) : null;
400
571
  this.opts.store.upsertHubTask(task);
401
572
  const chunks = Array.isArray(body.chunks) ? body.chunks : [];
402
573
  const chunkIds = [];
@@ -404,15 +575,22 @@ class HubServer {
404
575
  this.opts.store.upsertHubChunk({ ...chunk, sourceUserId: auth.userId });
405
576
  chunkIds.push(chunk.id);
406
577
  }
407
- // Async embedding: don't block the response
408
578
  if (this.opts.embedder && chunkIds.length > 0) {
409
579
  this.embedChunksAsync(chunkIds, chunks);
410
580
  }
581
+ if (!existingTask) {
582
+ this.notifyAdmins("resource_shared", "task", String(task.title || task.sourceTaskId || ""), auth.userId);
583
+ }
411
584
  return this.json(res, 200, { ok: true, chunks: chunkIds.length });
412
585
  }
413
586
  if (req.method === "POST" && routePath === "/api/v1/hub/tasks/unshare") {
414
587
  const body = await this.readJson(req);
415
- this.opts.store.deleteHubTaskBySource(auth.userId, String(body.sourceTaskId));
588
+ const srcTaskId = String(body.sourceTaskId);
589
+ const existing = this.opts.store.getHubTaskBySource(auth.userId, srcTaskId);
590
+ this.opts.store.deleteHubTaskBySource(auth.userId, srcTaskId);
591
+ if (existing) {
592
+ this.notifyAdmins("resource_unshared", "task", existing.title || srcTaskId, auth.userId);
593
+ }
416
594
  return this.json(res, 200, { ok: true });
417
595
  }
418
596
  if (req.method === "POST" && routePath === "/api/v1/hub/memories/share") {
@@ -444,6 +622,9 @@ class HubServer {
444
622
  if (this.opts.embedder) {
445
623
  this.embedMemoryAsync(memoryId, String(m.summary || ""), String(m.content || ""));
446
624
  }
625
+ if (!existing) {
626
+ this.notifyAdmins("resource_shared", "memory", String(m.summary || m.content?.slice(0, 60) || memoryId), auth.userId);
627
+ }
447
628
  return this.json(res, 200, { ok: true, memoryId, visibility });
448
629
  }
449
630
  if (req.method === "POST" && routePath === "/api/v1/hub/memories/unshare") {
@@ -451,7 +632,11 @@ class HubServer {
451
632
  const sourceChunkId = String(body?.sourceChunkId || "");
452
633
  if (!sourceChunkId)
453
634
  return this.json(res, 400, { error: "missing_source_chunk_id" });
635
+ const existing = this.opts.store.getHubMemoryBySource(auth.userId, sourceChunkId);
454
636
  this.opts.store.deleteHubMemoryBySource(auth.userId, sourceChunkId);
637
+ if (existing) {
638
+ this.notifyAdmins("resource_unshared", "memory", existing.summary || existing.content?.slice(0, 60) || sourceChunkId, auth.userId);
639
+ }
455
640
  return this.json(res, 200, { ok: true });
456
641
  }
457
642
  if (req.method === "GET" && routePath === "/api/v1/hub/memories") {
@@ -565,19 +750,73 @@ class HubServer {
565
750
  return this.json(res, 200, { hits, meta: { totalCandidates: hits.length, searchedGroups: [], includedPublic: true } });
566
751
  }
567
752
  if (req.method === "GET" && routePath === "/api/v1/hub/skills") {
568
- const hits = this.opts.store.searchHubSkills(String(url.searchParams.get("query") || ""), {
753
+ const skillQuery = String(url.searchParams.get("query") || "");
754
+ const skillMaxResults = Number(url.searchParams.get("maxResults") || 10);
755
+ const ftsSkillHits = this.opts.store.searchHubSkills(skillQuery, {
569
756
  userId: auth.userId,
570
- maxResults: Number(url.searchParams.get("maxResults") || 10),
571
- }).map(({ hit }) => ({
572
- skillId: hit.id,
573
- name: hit.name,
574
- description: hit.description,
575
- version: hit.version,
576
- visibility: hit.visibility,
577
- groupName: hit.group_name,
578
- ownerName: hit.owner_name || "unknown",
579
- qualityScore: hit.quality_score,
580
- }));
757
+ maxResults: skillMaxResults * 2,
758
+ });
759
+ let mergedSkillIds;
760
+ if (this.opts.embedder && skillQuery) {
761
+ try {
762
+ const [queryVec] = await this.opts.embedder.embed([skillQuery]);
763
+ if (queryVec) {
764
+ const skillEmbs = this.opts.store.getVisibleHubSkillEmbeddings();
765
+ const cosineSim = (vec) => {
766
+ let dot = 0, nA = 0, nB = 0;
767
+ for (let i = 0; i < queryVec.length && i < vec.length; i++) {
768
+ dot += queryVec[i] * vec[i];
769
+ nA += queryVec[i] * queryVec[i];
770
+ nB += vec[i] * vec[i];
771
+ }
772
+ return nA > 0 && nB > 0 ? dot / (Math.sqrt(nA) * Math.sqrt(nB)) : 0;
773
+ };
774
+ const vecScored = skillEmbs
775
+ .map(e => ({ id: e.skillId, score: cosineSim(e.vector) }))
776
+ .filter(e => e.score > 0.3)
777
+ .sort((a, b) => b.score - a.score)
778
+ .slice(0, skillMaxResults * 2);
779
+ const K = 60;
780
+ const rrfScores = new Map();
781
+ ftsSkillHits.forEach(({ hit }, idx) => {
782
+ rrfScores.set(hit.id, (rrfScores.get(hit.id) ?? 0) + 1 / (K + idx + 1));
783
+ });
784
+ vecScored.forEach(({ id }, idx) => {
785
+ rrfScores.set(id, (rrfScores.get(id) ?? 0) + 1 / (K + idx + 1));
786
+ });
787
+ mergedSkillIds = [...rrfScores.entries()].sort((a, b) => b[1] - a[1]).slice(0, skillMaxResults).map(([id]) => id);
788
+ }
789
+ else {
790
+ mergedSkillIds = ftsSkillHits.slice(0, skillMaxResults).map(({ hit }) => hit.id);
791
+ }
792
+ }
793
+ catch {
794
+ mergedSkillIds = ftsSkillHits.slice(0, skillMaxResults).map(({ hit }) => hit.id);
795
+ }
796
+ }
797
+ else {
798
+ mergedSkillIds = ftsSkillHits.slice(0, skillMaxResults).map(({ hit }) => hit.id);
799
+ }
800
+ const ftsSkillMap = new Map(ftsSkillHits.map(({ hit }) => [hit.id, hit]));
801
+ const hits = mergedSkillIds.map(id => {
802
+ const hit = ftsSkillMap.get(id);
803
+ if (hit) {
804
+ return {
805
+ skillId: hit.id, name: hit.name, description: hit.description,
806
+ version: hit.version, visibility: hit.visibility, groupName: hit.group_name,
807
+ ownerName: hit.owner_name || "unknown", ownerStatus: hit.owner_status || "",
808
+ qualityScore: hit.quality_score,
809
+ };
810
+ }
811
+ const skill = this.opts.store.getHubSkillById(id);
812
+ if (!skill)
813
+ return null;
814
+ return {
815
+ skillId: skill.id, name: skill.name, description: skill.description,
816
+ version: skill.version, visibility: skill.visibility, groupName: "",
817
+ ownerName: "unknown", ownerStatus: "", qualityScore: skill.qualityScore,
818
+ };
819
+ }).filter(Boolean);
581
820
  return this.json(res, 200, { hits });
582
821
  }
583
822
  if (req.method === "POST" && routePath === "/api/v1/hub/skills/publish") {
@@ -603,6 +842,10 @@ class HubServer {
603
842
  createdAt: existing?.createdAt ?? Date.now(),
604
843
  updatedAt: Date.now(),
605
844
  });
845
+ this.embedSkillAsync(skillId, String(metadata.name || sourceSkillId), String(metadata.description || ""), auth.userId, sourceSkillId);
846
+ if (!existing) {
847
+ this.notifyAdmins("resource_shared", "skill", String(metadata.name || sourceSkillId), auth.userId);
848
+ }
606
849
  return this.json(res, 200, { ok: true, skillId, visibility });
607
850
  }
608
851
  const skillBundleMatch = req.method === "GET" ? routePath.match(/^\/api\/v1\/hub\/skills\/([^/]+)\/bundle$/) : null;
@@ -624,7 +867,12 @@ class HubServer {
624
867
  }
625
868
  if (req.method === "POST" && routePath === "/api/v1/hub/skills/unpublish") {
626
869
  const body = await this.readJson(req);
627
- this.opts.store.deleteHubSkillBySource(auth.userId, String(body?.sourceSkillId || ""));
870
+ const srcSkillId = String(body?.sourceSkillId || "");
871
+ const existing = this.opts.store.getHubSkillBySource(auth.userId, srcSkillId);
872
+ this.opts.store.deleteHubSkillBySource(auth.userId, srcSkillId);
873
+ if (existing) {
874
+ this.notifyAdmins("resource_unshared", "skill", existing.name || srcSkillId, auth.userId);
875
+ }
628
876
  return this.json(res, 200, { ok: true });
629
877
  }
630
878
  // ── Admin: shared tasks & skills management ──
@@ -634,14 +882,51 @@ class HubServer {
634
882
  const tasks = this.opts.store.listAllHubTasks();
635
883
  return this.json(res, 200, { tasks });
636
884
  }
885
+ const hubTaskDetailMatch = req.method === "GET" ? routePath.match(/^\/api\/v1\/hub\/shared-tasks\/([^/]+)\/detail$/) : null;
886
+ if (hubTaskDetailMatch) {
887
+ const taskId = decodeURIComponent(hubTaskDetailMatch[1]);
888
+ const task = this.opts.store.getHubTaskById(taskId);
889
+ if (!task)
890
+ return this.json(res, 404, { error: "not_found" });
891
+ const chunks = this.opts.store.listHubChunksByTaskId(taskId);
892
+ return this.json(res, 200, {
893
+ id: task.id, title: task.title, summary: task.summary,
894
+ startedAt: task.createdAt, endedAt: task.updatedAt,
895
+ chunks: chunks.map(c => ({ role: c.role, content: c.content, summary: c.summary, kind: c.kind, createdAt: c.createdAt })),
896
+ });
897
+ }
898
+ const hubSkillDetailMatch = req.method === "GET" ? routePath.match(/^\/api\/v1\/hub\/shared-skills\/([^/]+)\/detail$/) : null;
899
+ if (hubSkillDetailMatch) {
900
+ const skillId = decodeURIComponent(hubSkillDetailMatch[1]);
901
+ const skill = this.opts.store.getHubSkillById(skillId);
902
+ if (!skill)
903
+ return this.json(res, 404, { error: "not_found" });
904
+ let files = [];
905
+ try {
906
+ const bundle = JSON.parse(skill.bundle || "{}");
907
+ if (Array.isArray(bundle.files)) {
908
+ files = bundle.files.map((f) => ({ path: f.path ?? f.name ?? "unknown", type: f.type ?? "file", size: f.size ?? (f.content ? f.content.length : 0) }));
909
+ }
910
+ }
911
+ catch { /* ignore parse error */ }
912
+ return this.json(res, 200, {
913
+ skill: { id: skill.id, name: skill.name, description: skill.description, version: skill.version, qualityScore: skill.qualityScore, status: "published" },
914
+ files,
915
+ versions: [],
916
+ });
917
+ }
637
918
  const adminTaskDeleteMatch = req.method === "DELETE" ? routePath.match(/^\/api\/v1\/hub\/admin\/shared-tasks\/([^/]+)$/) : null;
638
919
  if (adminTaskDeleteMatch) {
639
920
  if (auth.role !== "admin")
640
921
  return this.json(res, 403, { error: "forbidden" });
641
922
  const taskId = decodeURIComponent(adminTaskDeleteMatch[1]);
923
+ const taskInfo = this.opts.store.getHubTaskById(taskId);
642
924
  const deleted = this.opts.store.deleteHubTaskById(taskId);
643
925
  if (!deleted)
644
926
  return this.json(res, 404, { error: "not_found" });
927
+ if (taskInfo) {
928
+ this.opts.store.insertHubNotification({ id: (0, crypto_1.randomUUID)(), userId: taskInfo.sourceUserId, type: "resource_removed", resource: "task", title: taskInfo.title });
929
+ }
645
930
  return this.json(res, 200, { ok: true });
646
931
  }
647
932
  if (req.method === "GET" && routePath === "/api/v1/hub/admin/shared-skills") {
@@ -655,9 +940,13 @@ class HubServer {
655
940
  if (auth.role !== "admin")
656
941
  return this.json(res, 403, { error: "forbidden" });
657
942
  const skillId = decodeURIComponent(adminSkillDeleteMatch[1]);
943
+ const skillInfo = this.opts.store.getHubSkillById(skillId);
658
944
  const deleted = this.opts.store.deleteHubSkillById(skillId);
659
945
  if (!deleted)
660
946
  return this.json(res, 404, { error: "not_found" });
947
+ if (skillInfo) {
948
+ this.opts.store.insertHubNotification({ id: (0, crypto_1.randomUUID)(), userId: skillInfo.sourceUserId, type: "resource_removed", resource: "skill", title: skillInfo.name });
949
+ }
661
950
  return this.json(res, 200, { ok: true });
662
951
  }
663
952
  if (req.method === "GET" && routePath === "/api/v1/hub/admin/shared-memories") {
@@ -671,9 +960,24 @@ class HubServer {
671
960
  if (auth.role !== "admin")
672
961
  return this.json(res, 403, { error: "forbidden" });
673
962
  const memoryId = decodeURIComponent(adminMemoryDeleteMatch[1]);
963
+ const memInfo = this.opts.store.getHubMemoryById(memoryId);
674
964
  const deleted = this.opts.store.deleteHubMemoryById(memoryId);
675
965
  if (!deleted)
676
966
  return this.json(res, 404, { error: "not_found" });
967
+ if (memInfo) {
968
+ const payload = JSON.stringify({
969
+ memoryId,
970
+ sourceChunkId: memInfo.sourceChunkId,
971
+ });
972
+ this.opts.store.insertHubNotification({
973
+ id: (0, crypto_1.randomUUID)(),
974
+ userId: memInfo.sourceUserId,
975
+ type: "resource_removed",
976
+ resource: "memory",
977
+ title: memInfo.summary || memInfo.id,
978
+ message: payload,
979
+ });
980
+ }
677
981
  return this.json(res, 200, { ok: true });
678
982
  }
679
983
  if (req.method === "POST" && routePath === "/api/v1/hub/memory-detail") {
@@ -698,8 +1002,85 @@ class HubServer {
698
1002
  source: { ts: chunk.createdAt, role: chunk.role },
699
1003
  });
700
1004
  }
1005
+ if (req.method === "GET" && routePath === "/api/v1/hub/notifications") {
1006
+ const unread = (new URL(req.url, `http://${req.headers.host}`)).searchParams.get("unread") === "1";
1007
+ const list = this.opts.store.listHubNotifications(auth.userId, { unreadOnly: unread, limit: 50 });
1008
+ const unreadCount = this.opts.store.countUnreadHubNotifications(auth.userId);
1009
+ return this.json(res, 200, { notifications: list, unreadCount });
1010
+ }
1011
+ if (req.method === "POST" && routePath === "/api/v1/hub/notifications/read") {
1012
+ const body = await this.readJson(req);
1013
+ const ids = Array.isArray(body.ids) ? body.ids : undefined;
1014
+ this.opts.store.markHubNotificationsRead(auth.userId, ids);
1015
+ return this.json(res, 200, { ok: true });
1016
+ }
1017
+ if (req.method === "POST" && routePath === "/api/v1/hub/notifications/clear") {
1018
+ this.opts.store.clearHubNotifications(auth.userId);
1019
+ return this.json(res, 200, { ok: true });
1020
+ }
701
1021
  return this.json(res, 404, { error: "not_found" });
702
1022
  }
1023
+ notifyAdmins(type, resource, title, fromUserId, opts) {
1024
+ try {
1025
+ const admins = this.opts.store.listHubUsers("active").filter(u => u.role === "admin" && u.id !== fromUserId);
1026
+ for (const admin of admins) {
1027
+ if (opts?.dedup && this.opts.store.hasRecentHubNotification(admin.id, type, resource, opts.deduoWindowMs ?? 300_000)) {
1028
+ continue;
1029
+ }
1030
+ this.opts.store.insertHubNotification({ id: (0, crypto_1.randomUUID)(), userId: admin.id, type, resource, title });
1031
+ }
1032
+ }
1033
+ catch { /* best-effort */ }
1034
+ }
1035
+ initOnlineTracking() {
1036
+ try {
1037
+ const ownerId = this.authState.bootstrapAdminUserId || "";
1038
+ const users = this.opts.store.listHubUsers("active");
1039
+ const now = Date.now();
1040
+ for (const u of users) {
1041
+ if (u.id === ownerId)
1042
+ continue;
1043
+ if (u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS) {
1044
+ this.knownOnlineUsers.add(u.id);
1045
+ }
1046
+ }
1047
+ }
1048
+ catch { /* best-effort */ }
1049
+ }
1050
+ checkOfflineUsers() {
1051
+ try {
1052
+ const ownerId = this.authState.bootstrapAdminUserId || "";
1053
+ const users = this.opts.store.listHubUsers("active");
1054
+ const now = Date.now();
1055
+ const currentlyOnline = new Set();
1056
+ for (const u of users) {
1057
+ if (u.id === ownerId)
1058
+ continue;
1059
+ if (u.lastActiveAt && now - u.lastActiveAt < HubServer.OFFLINE_THRESHOLD_MS) {
1060
+ currentlyOnline.add(u.id);
1061
+ }
1062
+ }
1063
+ for (const uid of this.knownOnlineUsers) {
1064
+ if (!currentlyOnline.has(uid)) {
1065
+ const user = users.find(u => u.id === uid);
1066
+ if (user) {
1067
+ this.notifyAdmins("user_offline", "user", user.username, uid);
1068
+ this.opts.log.info(`Hub: user "${user.username}" (${uid}) went offline`);
1069
+ }
1070
+ }
1071
+ }
1072
+ for (const uid of currentlyOnline) {
1073
+ if (!this.knownOnlineUsers.has(uid)) {
1074
+ const user = users.find(u => u.id === uid);
1075
+ if (user) {
1076
+ this.notifyAdmins("user_online", "user", user.username, uid);
1077
+ }
1078
+ }
1079
+ }
1080
+ this.knownOnlineUsers = currentlyOnline;
1081
+ }
1082
+ catch { /* best-effort */ }
1083
+ }
703
1084
  authenticate(req) {
704
1085
  const header = req.headers.authorization;
705
1086
  if (!header || !header.startsWith("Bearer "))
@@ -714,6 +1095,13 @@ class HubServer {
714
1095
  const hash = (0, crypto_1.createHash)("sha256").update(token).digest("hex");
715
1096
  if (user.tokenHash !== hash)
716
1097
  return null;
1098
+ const clientIp = req.headers["x-client-ip"]?.trim()
1099
+ || req.headers["x-forwarded-for"]?.split(",")[0]?.trim()
1100
+ || req.socket.remoteAddress || "";
1101
+ try {
1102
+ this.opts.store.updateHubUserActivity(user.id, clientIp);
1103
+ }
1104
+ catch { /* best-effort */ }
717
1105
  return {
718
1106
  userId: user.id,
719
1107
  username: user.username,