agent-loft 0.2.21 → 0.2.23

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.
@@ -9,7 +9,19 @@ import { hub } from "../../core/hub.js";
9
9
  import { interactions } from "../../core/interactions.js";
10
10
  import { approve, listApproved, listPending, revoke } from "../../core/pairing.js";
11
11
  import { getConversation, updateConversation } from "../../core/store.js";
12
- import { resolveUploadFile, saveBuffer, sniff } from "../../core/uploads.js";
12
+ import { resolveUploadFile, saveBuffer, sniff, uploadKeyOf } from "../../core/uploads.js";
13
+ import {
14
+ createUser,
15
+ getUser,
16
+ isAdmin,
17
+ legacyOwnerId,
18
+ listUsers,
19
+ removeUser,
20
+ verifyLogin,
21
+ verifyPassword,
22
+ type Role,
23
+ type User,
24
+ } from "../../core/users.js";
13
25
  import type { SavedAttachment } from "../../core/types.js";
14
26
  import { listAllowlist, setAllowlist } from "../../agents/claude/permissions.js";
15
27
  import { agentAvailability, agentHealth, invalidateHealth } from "../../agents/health.js";
@@ -23,16 +35,19 @@ import {
23
35
  clearAttempts,
24
36
  createSession,
25
37
  destroySession,
26
- hasPassword,
27
38
  listSessions as listWebSessions,
28
- takeAttempt,
39
+ needsSetup,
29
40
  revokeSession,
30
- setPassword,
31
- validateSession,
32
- verifyPassword,
41
+ revokeUserSessions,
42
+ sessionUser,
43
+ patchUser,
44
+ setUserPassword,
45
+ setupFirstAdmin,
46
+ takeAttempt,
33
47
  } from "./auth.js";
48
+ import { canSeeConv, pushOwnedBy, sessionVisibleTo } from "./access.js";
34
49
  import { clientIp, cookies, HttpError, isHttps, readBody, readJsonBody, SECURITY_HEADERS, sendJson } from "./http.js";
35
- import { addSubscription, listSubscriptions, pushToAll, removeSubscription, vapidPublicKey } from "./push.js";
50
+ import { addSubscription, listSubscriptions, pushToAll, removeSubscription, removeSubscriptionsOf, vapidPublicKey } from "./push.js";
36
51
 
37
52
  export const COOKIE = "aim_session";
38
53
  const startedAt = Date.now();
@@ -67,9 +82,13 @@ setInterval(() => {
67
82
  */
68
83
  const INLINE_IMAGE = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
69
84
 
70
- function serveUpload(res: ServerResponse, requested: string, download: boolean): void {
85
+ function serveUpload(res: ServerResponse, user: User, requested: string, download: boolean): void {
71
86
  const file = requested ? resolveUploadFile(requested) : undefined;
72
- if (!file) throw new HttpError(404, "文件不存在(可能会话重置时已清理)");
87
+ // 只能看自己会话里的附件。会话已经没了的,只有管理员能看
88
+ const key = file ? uploadKeyOf(file) : undefined;
89
+ const conv = key ? getConversation(key) : undefined;
90
+ const allowed = conv ? canSeeConv(user, conv) : isAdmin(user);
91
+ if (!file || !allowed) throw new HttpError(404, "文件不存在(可能会话重置时已清理)");
73
92
  const head = Buffer.alloc(12);
74
93
  const fd = fs.openSync(file, "r");
75
94
  try {
@@ -153,7 +172,7 @@ function publicConfig(): unknown {
153
172
  const cfg = structuredClone(config()) as AppConfig & { web: { passwordHash?: string } };
154
173
  return {
155
174
  ...cfg,
156
- web: { ...cfg.web, passwordHash: undefined, hasPassword: !!config().web.passwordHash },
175
+ web: { ...cfg.web, passwordHash: undefined },
157
176
  feishu: { ...cfg.feishu, bots: cfg.feishu.bots.map((b) => ({ ...b, appSecret: maskSecret(b.appSecret) })) },
158
177
  };
159
178
  }
@@ -268,7 +287,46 @@ function setCookie(req: IncomingMessage, res: ServerResponse, token: string, max
268
287
  }
269
288
 
270
289
  export function authed(req: IncomingMessage): boolean {
271
- return !!validateSession(cookies(req)[COOKIE]);
290
+ return !!currentUser(req);
291
+ }
292
+
293
+ /** 这个请求是哪个用户的(没登录 / 登录态失效返回 undefined) */
294
+ export function currentUser(req: IncomingMessage): User | undefined {
295
+ return sessionUser(cookies(req)[COOKIE]);
296
+ }
297
+
298
+ function newSession(req: IncomingMessage, res: ServerResponse, userId: string): void {
299
+ setCookie(req, res, createSession(userId, clientIp(req), String(req.headers["user-agent"] ?? "")), 30 * 24 * 3600);
300
+ }
301
+
302
+ /**
303
+ * 只有管理员能用的接口:所有设置(Agent、飞书、配对、总是允许名单、网络、升级、重启)和用户管理。
304
+ * 写在一处,方便核对。普通成员只能用自己的会话、通知、密码和登录设备。
305
+ */
306
+ function adminOnly(p: string): boolean {
307
+ return (
308
+ p === "/api/config" ||
309
+ p === "/api/server/upgrade" ||
310
+ p === "/api/agents/codex/install" ||
311
+ p.startsWith("/api/pairing") ||
312
+ p.startsWith("/api/claude/") ||
313
+ p.startsWith("/api/feishu/") ||
314
+ p.startsWith("/api/system") ||
315
+ p.startsWith("/api/users")
316
+ );
317
+ }
318
+
319
+ function publicMe(user: User) {
320
+ return { id: user.id, name: user.name, role: user.role };
321
+ }
322
+
323
+ /** users.ts 抛的是给人看的错误,转成 400 */
324
+ function userOp<T>(fn: () => T): T {
325
+ try {
326
+ return fn();
327
+ } catch (err) {
328
+ throw new HttpError(400, err instanceof Error ? err.message : String(err));
329
+ }
272
330
  }
273
331
 
274
332
  // ---------- 路由 ----------
@@ -297,32 +355,38 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
297
355
 
298
356
  // ----- 无需登录 -----
299
357
  if (p === "/api/me" && method === "GET") {
300
- sendJson(res, 200, { authed: authed(req), needsSetup: !hasPassword() });
358
+ const user = currentUser(req);
359
+ sendJson(res, 200, {
360
+ authed: !!user,
361
+ needsSetup: needsSetup(),
362
+ // 不止一个人能登录:登录页要填用户名
363
+ multiUser: listUsers().filter((u) => !u.disabled).length > 1,
364
+ ...(user ? { user: publicMe(user) } : {}),
365
+ });
301
366
  return;
302
367
  }
303
368
  if (p === "/api/setup" && method === "POST") {
304
- if (hasPassword()) throw new HttpError(400, "已经设置过密码了");
305
- const body = await readJsonBody<{ token?: string; password?: string }>(req);
369
+ if (!needsSetup()) throw new HttpError(400, "已经设置过了,直接登录");
370
+ const body = await readJsonBody<{ token?: string; name?: string; password?: string }>(req);
306
371
  const left = takeAttempt(ip);
307
372
  if (left) throw new HttpError(429, `尝试次数太多,请 ${Math.ceil(left / 60000)} 分钟后再试`);
308
373
  if (!checkSetupToken(String(body.token ?? ""))) {
309
374
  throw new HttpError(403, "设置码不对。它打印在 agent-loft 的启动日志里");
310
375
  }
311
- setPassword(String(body.password ?? ""));
376
+ const user = userOp(() => setupFirstAdmin(String(body.name ?? ""), String(body.password ?? "")));
312
377
  clearAttempts(ip);
313
- setCookie(req, res, createSession(ip, String(req.headers["user-agent"] ?? "")), 30 * 24 * 3600);
378
+ newSession(req, res, user.id);
314
379
  sendJson(res, 200, { ok: true });
315
380
  return;
316
381
  }
317
382
  if (p === "/api/login" && method === "POST") {
318
- const body = await readJsonBody<{ password?: string }>(req);
383
+ const body = await readJsonBody<{ name?: string; password?: string }>(req);
319
384
  const left = takeAttempt(ip);
320
385
  if (left) throw new HttpError(429, `尝试次数太多,请 ${Math.ceil(left / 60000)} 分钟后再试`);
321
- if (!hasPassword() || !verifyPassword(String(body.password ?? ""), config().web.passwordHash)) {
322
- throw new HttpError(403, "密码不对");
323
- }
386
+ const user = verifyLogin(typeof body.name === "string" ? body.name : undefined, String(body.password ?? ""));
387
+ if (!user) throw new HttpError(403, listUsers().filter((u) => !u.disabled).length > 1 ? "用户名或密码不对" : "密码不对");
324
388
  clearAttempts(ip);
325
- setCookie(req, res, createSession(ip, String(req.headers["user-agent"] ?? "")), 30 * 24 * 3600);
389
+ newSession(req, res, user.id);
326
390
  sendJson(res, 200, { ok: true });
327
391
  return;
328
392
  }
@@ -333,11 +397,15 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
333
397
  return;
334
398
  }
335
399
 
336
- if (!authed(req)) throw new HttpError(401, "请先登录");
400
+ const user = currentUser(req);
401
+ if (!user) throw new HttpError(401, "请先登录");
402
+ if (adminOnly(p) && !isAdmin(user)) throw new HttpError(403, "只有管理员能做这个操作");
403
+ const admin = isAdmin(user);
404
+ const visible = (c: Parameters<typeof canSeeConv>[1]) => canSeeConv(user, c);
337
405
 
338
406
  // ----- 附件预览 / 下载:只给上传目录里的文件 -----
339
407
  if (p === "/api/file" && method === "GET") {
340
- serveUpload(res, url.searchParams.get("path") ?? "", url.searchParams.get("download") === "1");
408
+ serveUpload(res, user, url.searchParams.get("path") ?? "", url.searchParams.get("download") === "1");
341
409
  return;
342
410
  }
343
411
 
@@ -357,11 +425,13 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
357
425
  ...(agentAvailability(id) ?? {}),
358
426
  };
359
427
  }),
360
- bots: config().feishu.bots.map((b) => ({ id: b.id, name: b.name, agents: b.agents, enabled: b.enabled })),
428
+ // 飞书机器人是管理员配的,飞书会话也只给管理员看
429
+ bots: admin ? config().feishu.bots.map((b) => ({ id: b.id, name: b.name, agents: b.agents, enabled: b.enabled })) : [],
361
430
  workspaceRoot: config().workspaceRoot,
362
431
  quotaRefreshMin: config().web.quotaRefreshMin,
363
- conversations: hub.list(),
364
- pending: interactions.listPending(),
432
+ conversations: hub.list(visible),
433
+ pending: interactions.listPending().filter((i) => visible(getConversation(i.convKey))),
434
+ user: publicMe(user),
365
435
  serverTime: Date.now(),
366
436
  });
367
437
  return;
@@ -369,13 +439,13 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
369
439
 
370
440
  // ----- 系统通知(Web Push) -----
371
441
  if (p === "/api/push" && method === "GET") {
372
- sendJson(res, 200, { publicKey: vapidPublicKey(), subscriptions: listSubscriptions() });
442
+ sendJson(res, 200, { publicKey: vapidPublicKey(), subscriptions: listSubscriptions(pushOwnedBy(user.id)) });
373
443
  return;
374
444
  }
375
445
  if (p === "/api/push/subscribe" && method === "POST") {
376
446
  const body = await readJsonBody<{ subscription?: unknown }>(req);
377
447
  try {
378
- const record = addSubscription(body.subscription, String(req.headers["user-agent"] ?? ""));
448
+ const record = addSubscription(body.subscription, String(req.headers["user-agent"] ?? ""), user.id);
379
449
  sendJson(res, 200, { ok: true, label: record.label });
380
450
  } catch (err) {
381
451
  throw new HttpError(400, err instanceof Error ? err.message : String(err));
@@ -384,11 +454,14 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
384
454
  }
385
455
  if (p === "/api/push/unsubscribe" && method === "POST") {
386
456
  const body = await readJsonBody<{ endpoint?: string }>(req);
387
- sendJson(res, 200, { ok: removeSubscription(String(body.endpoint ?? "")) });
457
+ sendJson(res, 200, { ok: removeSubscription(String(body.endpoint ?? ""), pushOwnedBy(user.id)) });
388
458
  return;
389
459
  }
390
460
  if (p === "/api/push/test" && method === "POST") {
391
- const sent = await pushToAll({ title: "Agent Loft", body: "测试通知:能看到这条,说明通知已经通了。", tag: "test", url: "/" });
461
+ const sent = await pushToAll(
462
+ { title: "Agent Loft", body: "测试通知:能看到这条,说明通知已经通了。", tag: "test", url: "/" },
463
+ pushOwnedBy(user.id),
464
+ );
392
465
  if (sent === 0) throw new HttpError(502, "没有推送成功:还没有设备开启通知,或推送服务拒绝了(看服务日志)");
393
466
  sendJson(res, 200, { sent });
394
467
  return;
@@ -404,7 +477,7 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
404
477
 
405
478
  // ----- 会话 -----
406
479
  if (p === "/api/conversations" && method === "GET") {
407
- sendJson(res, 200, hub.list());
480
+ sendJson(res, 200, hub.list(visible));
408
481
  return;
409
482
  }
410
483
  if (p === "/api/conversations" && method === "POST") {
@@ -419,7 +492,7 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
419
492
  if (!cwd) throw new HttpError(400, `目录必须存在,且在 ${config().workspaceRoot} 之内`);
420
493
  }
421
494
  const key = `web:${crypto.randomBytes(6).toString("hex")}`;
422
- hub.ensure({ key, channel: "web", channelId: "web", defaultAgent: agent });
495
+ hub.ensure({ key, channel: "web", channelId: "web", defaultAgent: agent, owner: user.id });
423
496
  if (cwd && cwd !== config().workspaceRoot) updateConversation(key, { cwd, cwdExplicit: true });
424
497
  sendJson(res, 200, hub.info(key));
425
498
  return;
@@ -430,6 +503,8 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
430
503
  const key = decodeURIComponent(convMatch[1]!);
431
504
  const sub = convMatch[2];
432
505
  const conv = conversationOr404(key);
506
+ // 别人的会话:当它不存在
507
+ if (!visible(conv)) throw new HttpError(404, "会话不存在");
433
508
 
434
509
  if (!sub && method === "PATCH") {
435
510
  const body = await readJsonBody<{ title?: string; hidden?: boolean }>(req);
@@ -514,12 +589,13 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
514
589
  if (sub === "fork" && method === "POST") {
515
590
  const body = await readJsonBody<{ anchor?: string }>(req);
516
591
  const anchor = typeof body.anchor === "string" && body.anchor ? body.anchor : undefined;
517
- const newKey = await hub.forkConversation(key, anchor);
592
+ const newKey = await hub.forkConversation(key, anchor, user.id);
518
593
  sendJson(res, 200, { info: hub.info(newKey) });
519
594
  return;
520
595
  }
521
596
  if (sub === "feishu" && method === "POST") {
522
- // 在飞书继续:把选中的飞书聊天挂到这条 Web 会话上
597
+ // 在飞书继续:把选中的飞书聊天挂到这条 Web 会话上。飞书机器人是管理员的
598
+ if (!admin) throw new HttpError(403, "只有管理员能用飞书机器人");
523
599
  if (conv.channel !== "web" || conv.hidden) throw new HttpError(400, "只有 Web 会话能到飞书继续");
524
600
  const body = await readJsonBody<{ botId?: string; chatId?: string }>(req);
525
601
  const bot = feishuBots().find((b) => b.id === body.botId);
@@ -533,7 +609,7 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
533
609
  return;
534
610
  }
535
611
  if (sub === "sessions" && method === "GET") {
536
- sendJson(res, 200, await hub.sessions(key, url.searchParams.get("all") === "1"));
612
+ sendJson(res, 200, await hub.sessions(key, url.searchParams.get("all") === "1", sessionVisibleTo(user)));
537
613
  return;
538
614
  }
539
615
  if (sub === "models" && method === "GET") {
@@ -556,7 +632,9 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
556
632
  text = await hub.fork(key);
557
633
  break;
558
634
  case "resume":
559
- text = await hub.resume(key, value, body.cwd);
635
+ text = (sessionVisibleTo(user)?.(value) ?? true)
636
+ ? await hub.resume(key, value, body.cwd)
637
+ : `找不到会话 ${value}。\n发 /sessions 看看有哪些可恢复的。`;
560
638
  break;
561
639
  case "model":
562
640
  text = await hub.setModel(key, value);
@@ -662,7 +740,8 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
662
740
  sendJson(res, 404, { error: `没有 agent「${m[1]}」` });
663
741
  return;
664
742
  }
665
- const conv = getConversation(url.searchParams.get("key") ?? "");
743
+ const found = getConversation(url.searchParams.get("key") ?? "");
744
+ const conv = found && visible(found) ? found : undefined;
666
745
  const commands = await hub.commandCatalog(m[1]!, {
667
746
  ...(conv ? { cwd: conv.cwd } : {}),
668
747
  allowedAgents: hub.enabledAgents().map((d) => d.id),
@@ -674,25 +753,80 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
674
753
  }
675
754
  }
676
755
  if (p === "/api/password" && method === "POST") {
756
+ // 改自己的密码:自己所有设备下线,当前这台换一个新的登录态
677
757
  const body = await readJsonBody<{ old?: string; password?: string }>(req);
678
758
  const left = takeAttempt(ip);
679
759
  if (left) throw new HttpError(429, `尝试次数太多,请 ${Math.ceil(left / 60000)} 分钟后再试`);
680
- if (!verifyPassword(String(body.old ?? ""), config().web.passwordHash)) throw new HttpError(403, "原密码不对");
760
+ if (!verifyPassword(String(body.old ?? ""), user.passwordHash)) throw new HttpError(403, "原密码不对");
681
761
  clearAttempts(ip);
682
- setPassword(String(body.password ?? ""));
683
- setCookie(req, res, createSession(ip, String(req.headers["user-agent"] ?? "")), 30 * 24 * 3600);
762
+ userOp(() => setUserPassword(user.id, String(body.password ?? "")));
763
+ newSession(req, res, user.id);
684
764
  sendJson(res, 200, { ok: true });
685
765
  return;
686
766
  }
687
767
  if (p === "/api/web-sessions" && method === "GET") {
688
- sendJson(res, 200, listWebSessions());
768
+ // 管理员看所有人的设备,成员只看自己的
769
+ sendJson(res, 200, listWebSessions(admin ? undefined : user.id));
689
770
  return;
690
771
  }
691
772
  if (p === "/api/web-sessions/revoke" && method === "POST") {
692
773
  const body = await readJsonBody<{ id?: string }>(req);
693
- sendJson(res, 200, { ok: revokeSession(String(body.id ?? "")) });
774
+ sendJson(res, 200, { ok: revokeSession(String(body.id ?? ""), admin ? undefined : user.id) });
775
+ return;
776
+ }
777
+
778
+ // ----- 用户管理(管理员) -----
779
+ if (p === "/api/users" && method === "GET") {
780
+ const devices = listWebSessions();
781
+ sendJson(res, 200, {
782
+ users: listUsers().map((u) => ({ ...u, devices: devices.filter((d) => d.userId === u.id).length })),
783
+ me: user.id,
784
+ });
694
785
  return;
695
786
  }
787
+ if (p === "/api/users" && method === "POST") {
788
+ const body = await readJsonBody<{ name?: string; password?: string; role?: Role }>(req);
789
+ sendJson(
790
+ res,
791
+ 200,
792
+ userOp(() => createUser({ name: String(body.name ?? ""), password: String(body.password ?? ""), role: body.role === "admin" ? "admin" : "member" })),
793
+ );
794
+ return;
795
+ }
796
+ const userMatch = /^\/api\/users\/([A-Za-z0-9_]+)$/.exec(p);
797
+ if (userMatch) {
798
+ const id = userMatch[1]!;
799
+ if (!getUser(id)) throw new HttpError(404, "用户不存在");
800
+ if (method === "PATCH") {
801
+ const body = await readJsonBody<{ name?: string; role?: Role; disabled?: boolean; password?: string }>(req);
802
+ if (id === user.id && (body.disabled === true || (body.role !== undefined && body.role !== "admin"))) {
803
+ throw new HttpError(400, "不能停用自己,也不能取消自己的管理员");
804
+ }
805
+ // 一次改完:密码不合格时别的也不改。重置密码 / 停用会让他的设备全部下线
806
+ const updated = userOp(() =>
807
+ patchUser(id, {
808
+ ...(typeof body.name === "string" ? { name: body.name } : {}),
809
+ ...(body.role === "admin" || body.role === "member" ? { role: body.role } : {}),
810
+ ...(typeof body.disabled === "boolean" ? { disabled: body.disabled } : {}),
811
+ ...(typeof body.password === "string" ? { password: body.password } : {}),
812
+ }),
813
+ );
814
+ // 管理员在用户列表里重置了自己的密码:当前这台换个新登录态,别把自己踢出去
815
+ if (id === user.id && typeof body.password === "string") newSession(req, res, user.id);
816
+ sendJson(res, 200, updated);
817
+ return;
818
+ }
819
+ if (method === "DELETE") {
820
+ if (id === user.id) throw new HttpError(400, "不能删除自己");
821
+ // 升级前没记主人的推送订阅算在最早的管理员名下:删人之前先记下是谁
822
+ const legacy = legacyOwnerId();
823
+ const removed = userOp(() => removeUser(id));
824
+ removeSubscriptionsOf((owner) => (owner ?? legacy) === id);
825
+ revokeUserSessions(id);
826
+ sendJson(res, 200, removed);
827
+ return;
828
+ }
829
+ }
696
830
  if (p === "/api/pairing" && method === "GET") {
697
831
  sendJson(res, 200, { pending: listPending(), approved: listApproved() });
698
832
  return;
@@ -1,8 +1,8 @@
1
1
  // Web 登录鉴权。
2
2
  //
3
3
  // 这个页面能让 agent 在这台机器上执行命令,等同于一个远程 shell,所以:
4
- // - 密码用 scrypt 哈希存,不存明文;
5
- // - 首次没有密码时,必须拿终端 / 日志里打印的一次性设置码才能设密码
4
+ // - 密码用 scrypt 哈希存,不存明文(账号见 core/users.ts);
5
+ // - 一个用户都没有时,必须拿终端 / 日志里打印的一次性设置码才能建第一个管理员
6
6
  // (否则谁先打开页面谁就成了主人);
7
7
  // - 登录态是随机 token,服务端只存它的哈希,HttpOnly cookie;
8
8
  // - 同一 IP 连续输错会被锁一段时间。
@@ -11,16 +11,20 @@ import fs from "node:fs";
11
11
  import { EventEmitter } from "node:events";
12
12
  import path from "node:path";
13
13
  import { config, saveConfig } from "../../core/config.js";
14
+ import { createUser, getUser, hasUsers, legacyOwnerId, updateUser, userEvents, type PublicUser, type User } from "../../core/users.js";
14
15
  import { dataDir, readJson, writeJsonAtomic } from "../../home.js";
15
16
 
16
17
  const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
17
18
 
18
- /** 登录态被撤销(退出、改密码、踢设备)时通知 WebSocket 那边立刻断开对应连接 */
19
+ /** 登录态被撤销(退出、改密码、踢设备、用户被停用 / 删除)时通知 WebSocket 那边立刻断开对应连接 */
19
20
  export const authEvents = new EventEmitter<{ revoked: [] }>();
21
+ userEvents.on("changed", () => authEvents.emit("revoked"));
20
22
  const sessionsPath = path.join(dataDir, "web-sessions.json");
21
23
  const setupTokenPath = path.join(dataDir, "web-setup-token");
22
24
 
23
25
  export type WebSession = {
26
+ /** 升级前的登录态没有这个字段:读的时候认给迁移出来的那个管理员(legacyOwnerId) */
27
+ userId?: string;
24
28
  createdAt: number;
25
29
  expiresAt: number;
26
30
  lastSeen: number;
@@ -47,7 +51,19 @@ function loadSessions(): Record<string, WebSession> {
47
51
  loadedMtime = mtime;
48
52
  }
49
53
  const now = Date.now();
50
- for (const [id, s] of Object.entries(sessions)) if (s.expiresAt < now) delete sessions[id];
54
+ let stamped = false;
55
+ for (const [id, s] of Object.entries(sessions)) {
56
+ if (s.expiresAt < now) delete sessions[id];
57
+ else if (!s.userId) {
58
+ // 升级前的登录态:认给迁移出来的管理员,并且记下来,之后那个管理员被删了也不会换人
59
+ const owner = legacyOwnerId();
60
+ if (owner) {
61
+ s.userId = owner;
62
+ stamped = true;
63
+ }
64
+ }
65
+ }
66
+ if (stamped) saveSessions();
51
67
  return sessions;
52
68
  }
53
69
 
@@ -60,43 +76,34 @@ function sha256(value: string): string {
60
76
  return crypto.createHash("sha256").update(value).digest("hex");
61
77
  }
62
78
 
63
- // ---------- 密码 ----------
64
-
65
- export function hashPassword(password: string): string {
66
- const salt = crypto.randomBytes(16);
67
- const key = crypto.scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 });
68
- return `scrypt$${salt.toString("hex")}$${key.toString("hex")}`;
79
+ function ownerOf(s: WebSession): string | undefined {
80
+ return s.userId;
69
81
  }
70
82
 
71
- export function verifyPassword(password: string, stored: string): boolean {
72
- const [scheme, saltHex, keyHex] = stored.split("$");
73
- if (scheme !== "scrypt" || !saltHex || !keyHex) return false;
74
- const expected = Buffer.from(keyHex, "hex");
75
- const actual = crypto.scryptSync(password, Buffer.from(saltHex, "hex"), expected.length, { N: 16384, r: 8, p: 1 });
76
- return crypto.timingSafeEqual(expected, actual);
77
- }
83
+ // ---------- 首次设置 ----------
78
84
 
79
- export function hasPassword(): boolean {
80
- return !!config().web.passwordHash;
85
+ /** 还一个用户都没有:页面上要先建管理员 */
86
+ export function needsSetup(): boolean {
87
+ return !hasUsers();
81
88
  }
82
89
 
83
- export function setPassword(password: string): void {
84
- if (password.length < 8) throw new Error("密码至少 8 位");
85
- const cfg = structuredClone(config());
86
- cfg.web.passwordHash = hashPassword(password);
87
- saveConfig(cfg);
88
- // 改密码 = 所有已登录设备下线
90
+ /**
91
+ * 建第一个管理员(页面上用设置码,或终端里 agent-loft passwd)。
92
+ * 之前残留的登录态全部作废:否则它们没记用户,会被当成升级前的登录态认给这个新管理员。
93
+ */
94
+ export function setupFirstAdmin(name: string, password: string): PublicUser {
95
+ if (hasUsers()) throw new Error("已经设置过了");
96
+ const user = createUser({ name: name.trim() || "admin", password, role: "admin" });
89
97
  sessions = {};
90
98
  saveSessions();
91
99
  fs.rmSync(setupTokenPath, { force: true });
92
100
  authEvents.emit("revoked");
101
+ return user;
93
102
  }
94
103
 
95
- // ---------- 首次设置码 ----------
96
-
97
- /** 没有密码时生成(或沿用)一次性设置码,打印到日志里。 */
104
+ /** 一个用户都没有时生成(或沿用)一次性设置码,打印到日志里。 */
98
105
  export function ensureSetupToken(): string | null {
99
- if (hasPassword()) return null;
106
+ if (hasUsers()) return null;
100
107
  try {
101
108
  const existing = fs.readFileSync(setupTokenPath, "utf8").trim();
102
109
  if (existing) return existing;
@@ -117,26 +124,54 @@ export function checkSetupToken(token: string): boolean {
117
124
  return a.length === b.length && crypto.timingSafeEqual(a, b);
118
125
  }
119
126
 
127
+ // ---------- 密码 ----------
128
+
129
+ /**
130
+ * 改某个用户的密码:他所有已登录的设备下线。
131
+ * 升级前那个全局密码(config.web.passwordHash)也一并清掉,旧密码从此不再有效。
132
+ */
133
+ export function setUserPassword(userId: string, password: string): void {
134
+ patchUser(userId, { password });
135
+ }
136
+
137
+ /**
138
+ * 改用户(一次写盘,要么全改要么全不改),再处理副作用:
139
+ * 改了密码或者停用了,他所有设备下线;改了密码,升级前的全局密码也作废。
140
+ */
141
+ export function patchUser(userId: string, patch: Parameters<typeof updateUser>[1]): PublicUser {
142
+ const updated = updateUser(userId, patch);
143
+ if (patch.password !== undefined || patch.disabled === true) revokeUserSessions(userId);
144
+ if (patch.password !== undefined && config().web.passwordHash) {
145
+ const cfg = structuredClone(config());
146
+ cfg.web.passwordHash = "";
147
+ saveConfig(cfg);
148
+ }
149
+ return updated;
150
+ }
151
+
120
152
  // ---------- 登录态 ----------
121
153
 
122
- export function createSession(ip: string, userAgent: string): string {
154
+ export function createSession(userId: string, ip: string, userAgent: string): string {
123
155
  const token = crypto.randomBytes(32).toString("base64url");
124
156
  const now = Date.now();
125
- loadSessions()[sha256(token)] = { createdAt: now, expiresAt: now + SESSION_TTL_MS, lastSeen: now, ip, userAgent };
157
+ loadSessions()[sha256(token)] = { userId, createdAt: now, expiresAt: now + SESSION_TTL_MS, lastSeen: now, ip, userAgent };
126
158
  saveSessions();
127
159
  return token;
128
160
  }
129
161
 
130
- export function validateSession(token: string | undefined): WebSession | undefined {
131
- if (!token || !hasPassword()) return undefined;
162
+ /** 登录态对应的用户。用户被停用、删掉了也算无效 */
163
+ export function sessionUser(token: string | undefined): User | undefined {
164
+ if (!token || !hasUsers()) return undefined;
132
165
  const s = loadSessions()[sha256(token)];
133
166
  if (!s) return undefined;
167
+ const user = getUser(ownerOf(s));
168
+ if (!user || user.disabled) return undefined;
134
169
  const now = Date.now();
135
170
  if (now - s.lastSeen > 60_000) {
136
171
  s.lastSeen = now;
137
172
  saveSessions();
138
173
  }
139
- return s;
174
+ return user;
140
175
  }
141
176
 
142
177
  export function destroySession(token: string | undefined): void {
@@ -146,13 +181,24 @@ export function destroySession(token: string | undefined): void {
146
181
  authEvents.emit("revoked");
147
182
  }
148
183
 
149
- export function listSessions(): (WebSession & { id: string })[] {
150
- return Object.entries(loadSessions()).map(([id, s]) => ({ id: id.slice(0, 12), ...s }));
184
+ export type SessionView = WebSession & { id: string; userId?: string; userName?: string };
185
+
186
+ /** 已登录的设备。给了 userId 就只列这个用户的 */
187
+ export function listSessions(userId?: string): SessionView[] {
188
+ return Object.entries(loadSessions())
189
+ .filter(([, s]) => !userId || ownerOf(s) === userId)
190
+ .map(([id, s]) => {
191
+ const owner = ownerOf(s);
192
+ const name = getUser(owner)?.name;
193
+ return { ...s, id: id.slice(0, 12), ...(owner ? { userId: owner } : {}), ...(name ? { userName: name } : {}) };
194
+ });
151
195
  }
152
196
 
153
- export function revokeSession(idPrefix: string): boolean {
197
+ /** 踢掉一个设备。给了 userId 就只能踢这个用户自己的 */
198
+ export function revokeSession(idPrefix: string, userId?: string): boolean {
199
+ if (!idPrefix) return false;
154
200
  const all = loadSessions();
155
- const hit = Object.keys(all).find((id) => id.startsWith(idPrefix));
201
+ const hit = Object.keys(all).find((id) => id.startsWith(idPrefix) && (!userId || ownerOf(all[id]!) === userId));
156
202
  if (!hit) return false;
157
203
  delete all[hit];
158
204
  saveSessions();
@@ -160,6 +206,14 @@ export function revokeSession(idPrefix: string): boolean {
160
206
  return true;
161
207
  }
162
208
 
209
+ /** 某个用户的所有设备下线 */
210
+ export function revokeUserSessions(userId: string): void {
211
+ const all = loadSessions();
212
+ for (const [id, s] of Object.entries(all)) if (ownerOf(s) === userId) delete all[id];
213
+ saveSessions();
214
+ authEvents.emit("revoked");
215
+ }
216
+
163
217
  // ---------- 防爆破 ----------
164
218
  //
165
219
  // 额度在「真要验密码的那一刻」同步扣掉,而不是先查后记:后者中间隔着读请求体的