@sleep2agi/commhub-server 0.9.0-preview.69 → 0.9.0-preview.70

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.69",
3
+ "version": "0.9.0-preview.70",
4
4
  "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,250 @@
1
+ // 登录会话:闲置过期 + 「登录设备」列表 + 退出其他设备。
2
+ //
3
+ // 生产上 user-login 令牌一次登录一条、从不过期也不回收,泄漏任何一条就等于那个账号(多为管理员)。
4
+ // 这里的每一条在 main 上都是红的:main 没有闲置过期(旧令牌永远有效)、登录响应不带 token_id、
5
+ // /api/auth/sessions* 端点不存在。正控:在用的会话、显式 API 令牌、节点令牌照常可用。
6
+
7
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
8
+ import { mkdtempSync, rmSync } from "fs";
9
+ import { join } from "path";
10
+ import { tmpdir } from "os";
11
+ import { register, createNetworkTokenForNode, createToken } from "./auth.js";
12
+ import { db, hashToken } from "./db.js";
13
+
14
+ const DIR = mkdtempSync(join(tmpdir(), "anet-sessions-"));
15
+ let BASE = "";
16
+ let hub: any = null;
17
+ const PW = "SessionsPassw0rd!";
18
+ let USER = "", userId = "", NET = "", ntok = "", apiToken = "";
19
+
20
+ const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
21
+ async function call(token: string, method: string, path: string, body?: unknown, extra: Record<string, string> = {}) {
22
+ const res = await fetch(`${BASE}${path}`, {
23
+ method,
24
+ headers: { ...(token ? auth(token) : {}), ...(body === undefined ? {} : { "Content-Type": "application/json" }), ...extra },
25
+ body: body === undefined ? undefined : JSON.stringify(body),
26
+ });
27
+ return { status: res.status, body: await res.json().catch(() => null) as any };
28
+ }
29
+ async function loginAs(label?: string, ua = "anet-test-agent/1.0") {
30
+ const r = await call("", "POST", "/api/auth/login", { username: USER, password: PW, ...(label ? { client_label: label } : {}) }, { "User-Agent": ua });
31
+ expect(r.status).toBe(200);
32
+ return r.body as { token: string; token_id: string };
33
+ }
34
+ // 把某条令牌的「最近使用」拨回 days 天前(created_at 一起拨,模拟一条很早签发、之后没再用过的令牌)。
35
+ function ageToken(token: string, days: number) {
36
+ db.run(
37
+ "UPDATE api_tokens SET last_used_at = datetime('now', ?1), created_at = datetime('now', ?1) WHERE token_hash = ?2",
38
+ [`-${days} days`, hashToken(token)],
39
+ );
40
+ }
41
+ function neverUsed(token: string, days: number) {
42
+ db.run("UPDATE api_tokens SET last_used_at = NULL, created_at = datetime('now', ?1) WHERE token_hash = ?2", [`-${days} days`, hashToken(token)]);
43
+ }
44
+
45
+ beforeAll(async () => {
46
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
47
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
48
+ process.env.HOST = "127.0.0.1";
49
+ // 这里每个用例都真走 POST /api/auth/login 签发会话;默认的每 IP 登录限流会在第 ~20 次开始 429。
50
+ process.env.COMMHUB_LOGIN_IP_MAX = "1000";
51
+ delete process.env.COMMHUB_SESSION_IDLE_DAYS;
52
+ USER = `sess_user_${Date.now()}`;
53
+ const reg = register(USER, PW);
54
+ userId = reg.user!.user_id; NET = reg.network_id!;
55
+ const minted = createNetworkTokenForNode(userId, NET, "sess-node", "node_sess_1");
56
+ expect(minted.ok).toBe(true);
57
+ ntok = minted.token!;
58
+ const api = createToken(userId, "ci-script");
59
+ expect(api.ok).toBe(true);
60
+ apiToken = api.token!;
61
+ const mod: any = await import("./server.js");
62
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
63
+ BASE = `http://127.0.0.1:${hub.port}`;
64
+ }, 30_000);
65
+
66
+ afterAll(() => {
67
+ try { hub?.stop?.(true); } catch {}
68
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
69
+ });
70
+
71
+ describe("login / register identify the issued token", () => {
72
+ test("login returns token_id of the token it returned (and stays backward-compatible)", async () => {
73
+ const r = await call("", "POST", "/api/auth/login", { username: USER, password: PW });
74
+ expect(r.status).toBe(200);
75
+ expect(r.body.ok).toBe(true);
76
+ expect(typeof r.body.token).toBe("string");
77
+ expect(r.body.token_id).toMatch(/^tok_/);
78
+ const row = db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(r.body.token));
79
+ expect(row.token_id).toBe(r.body.token_id);
80
+ });
81
+ test("register returns token_id", async () => {
82
+ const r = await call("", "POST", "/api/auth/register", { username: `sess_reg_${Date.now()}`, password: PW });
83
+ expect(r.status).toBe(200);
84
+ expect(r.body.token_id).toMatch(/^tok_/);
85
+ const row = db.get<any>("SELECT token_id, name FROM api_tokens WHERE token_hash = ?1", hashToken(r.body.token));
86
+ expect(row.token_id).toBe(r.body.token_id);
87
+ expect(row.name).toBe("user-login");
88
+ });
89
+ });
90
+
91
+ describe("sliding idle expiry (default 30 days)", () => {
92
+ test("a login token idle past the window is rejected with token_expired", async () => {
93
+ const s = await loginAs();
94
+ ageToken(s.token, 31);
95
+ const r = await call(s.token, "GET", "/api/auth/me");
96
+ expect(r.status).toBe(401);
97
+ expect(r.body.error).toBe("token_expired");
98
+ // 同一条 401 改写也覆盖走统一 requireAuth 的 REST 端点
99
+ const r2 = await call(s.token, "GET", "/api/sessions");
100
+ expect(r2.status).toBe(401);
101
+ expect(r2.body.error).toBe("token_expired");
102
+ });
103
+ test("a login token that was never used counts idle time from created_at", async () => {
104
+ const s = await loginAs();
105
+ neverUsed(s.token, 45);
106
+ const r = await call(s.token, "GET", "/api/auth/me");
107
+ expect(r.status).toBe(401);
108
+ expect(r.body.error).toBe("token_expired");
109
+ });
110
+ test("an old but recently used login token stays valid (the window slides)", async () => {
111
+ const s = await loginAs();
112
+ // 90 天前签发,2 天前还在用
113
+ db.run("UPDATE api_tokens SET created_at = datetime('now', '-90 days'), last_used_at = datetime('now', '-2 days') WHERE token_hash = ?1", [hashToken(s.token)]);
114
+ const r = await call(s.token, "GET", "/api/auth/me");
115
+ expect(r.status).toBe(200);
116
+ // 使用把 last_used_at 推到现在(超过节流间隔,必须写)
117
+ const row = db.get<any>("SELECT last_used_at >= datetime('now', '-60 seconds') AS fresh FROM api_tokens WHERE token_hash = ?1", hashToken(s.token));
118
+ expect(row.fresh).toBe(1);
119
+ });
120
+ test("last_used_at is written at most once per hour per token", async () => {
121
+ const s = await loginAs();
122
+ db.run("UPDATE api_tokens SET last_used_at = datetime('now', '-10 minutes') WHERE token_hash = ?1", [hashToken(s.token)]);
123
+ const before = db.get<any>("SELECT last_used_at FROM api_tokens WHERE token_hash = ?1", hashToken(s.token)).last_used_at;
124
+ expect((await call(s.token, "GET", "/api/auth/me")).status).toBe(200);
125
+ const after = db.get<any>("SELECT last_used_at FROM api_tokens WHERE token_hash = ?1", hashToken(s.token)).last_used_at;
126
+ expect(after).toBe(before);
127
+ });
128
+ test("a truly invalid token still says invalid, not expired", async () => {
129
+ const r = await call("utok_00000000000000000000000000000000", "GET", "/api/auth/me");
130
+ expect(r.status).toBe(401);
131
+ expect(r.body.error).not.toBe("token_expired");
132
+ });
133
+ test("node tokens and explicit API tokens never idle-expire", async () => {
134
+ neverUsed(ntok, 400);
135
+ neverUsed(apiToken, 400);
136
+ const n = await call(ntok, "GET", "/api/auth/me");
137
+ expect(n.status).toBe(200);
138
+ expect(n.body.credential.kind).toBe("node");
139
+ expect((await call(apiToken, "GET", "/api/auth/me")).status).toBe(200);
140
+ });
141
+ test("COMMHUB_SESSION_IDLE_DAYS configures the window; 0 disables it", async () => {
142
+ const s = await loginAs();
143
+ ageToken(s.token, 10);
144
+ process.env.COMMHUB_SESSION_IDLE_DAYS = "7";
145
+ try {
146
+ expect((await call(s.token, "GET", "/api/auth/me")).body.error).toBe("token_expired");
147
+ process.env.COMMHUB_SESSION_IDLE_DAYS = "0";
148
+ expect((await call(s.token, "GET", "/api/auth/me")).status).toBe(200);
149
+ } finally {
150
+ delete process.env.COMMHUB_SESSION_IDLE_DAYS;
151
+ }
152
+ });
153
+ });
154
+
155
+ describe("GET /api/auth/sessions", () => {
156
+ test("lists my live login sessions with label, user agent and is_current", async () => {
157
+ const a = await loginAs("Android · 0.2.150", "okhttp/4");
158
+ const b = await loginAs("macOS · 0.2.150", "Mozilla/5.0 (Macintosh)");
159
+ const dead = await loginAs("stale");
160
+ ageToken(dead.token, 40);
161
+ const r = await call(b.token, "GET", "/api/auth/sessions");
162
+ expect(r.status).toBe(200);
163
+ expect(r.body.idle_timeout_days).toBe(30);
164
+ expect(r.body.current_token_id).toBe(b.token_id);
165
+ const ids = r.body.sessions.map((x: any) => x.token_id);
166
+ expect(ids).toContain(a.token_id);
167
+ expect(ids).toContain(b.token_id);
168
+ expect(ids).not.toContain(dead.token_id);
169
+ const me = r.body.sessions.find((x: any) => x.token_id === b.token_id);
170
+ expect(me.is_current).toBe(true);
171
+ expect(me.client_label).toBe("macOS · 0.2.150");
172
+ expect(me.user_agent).toBe("Mozilla/5.0 (Macintosh)");
173
+ expect(typeof me.created_at).toBe("string");
174
+ const other = r.body.sessions.find((x: any) => x.token_id === a.token_id);
175
+ expect(other.is_current).toBe(false);
176
+ expect(other.client_label).toBe("Android · 0.2.150");
177
+ // 不泄漏令牌本身或哈希;API 令牌和节点令牌不在列表里
178
+ expect(JSON.stringify(r.body)).not.toContain("token_hash");
179
+ expect(JSON.stringify(r.body)).not.toContain(b.token);
180
+ expect(r.body.sessions.every((x: any) => x.name !== "ci-script" && !String(x.name).startsWith("node:"))).toBe(true);
181
+ });
182
+ test("node tokens get 403 user_token_required", async () => {
183
+ const r = await call(ntok, "GET", "/api/auth/sessions");
184
+ expect(r.status).toBe(403);
185
+ expect(r.body.error).toBe("user_token_required");
186
+ });
187
+ test("no token → 401", async () => {
188
+ expect((await call("", "GET", "/api/auth/sessions")).status).toBe(401);
189
+ });
190
+ });
191
+
192
+ describe("revoking sessions", () => {
193
+ test("revoke-others keeps the current token and leaves API and node tokens alone", async () => {
194
+ const keep = await loginAs("keep");
195
+ const other1 = await loginAs("other1");
196
+ const other2 = await loginAs("other2");
197
+ const r = await call(keep.token, "POST", "/api/auth/sessions/revoke-others");
198
+ expect(r.status).toBe(200);
199
+ expect(r.body.kept_token_id).toBe(keep.token_id);
200
+ expect(r.body.revoked).toBeGreaterThanOrEqual(2);
201
+ expect((await call(keep.token, "GET", "/api/auth/me")).status).toBe(200);
202
+ expect((await call(other1.token, "GET", "/api/auth/me")).status).toBe(401);
203
+ expect((await call(other2.token, "GET", "/api/auth/me")).status).toBe(401);
204
+ expect((await call(ntok, "GET", "/api/auth/me")).status).toBe(200);
205
+ expect((await call(apiToken, "GET", "/api/auth/me")).status).toBe(200);
206
+ const list = await call(keep.token, "GET", "/api/auth/sessions");
207
+ expect(list.body.sessions.map((x: any) => x.token_id)).toEqual([keep.token_id]);
208
+ const audit = db.get<any>("SELECT detail FROM audit_log WHERE action = 'sessions_revoked_others' AND target_id = ?1", keep.token_id);
209
+ expect(audit?.detail).toMatch(/^revoked=\d+$/);
210
+ });
211
+ test("revoke-others refuses node tokens and non-session tokens", async () => {
212
+ const r = await call(ntok, "POST", "/api/auth/sessions/revoke-others");
213
+ expect(r.status).toBe(403);
214
+ expect(r.body.error).toBe("user_token_required");
215
+ const s = await loginAs("survivor");
216
+ const viaApi = await call(apiToken, "POST", "/api/auth/sessions/revoke-others");
217
+ expect(viaApi.status).toBe(403);
218
+ expect(viaApi.body.error).toBe("session_token_required");
219
+ expect((await call(s.token, "GET", "/api/auth/me")).status).toBe(200);
220
+ });
221
+ test("DELETE /api/auth/sessions/:id revokes one other session", async () => {
222
+ const me = await loginAs("me");
223
+ const victim = await loginAs("victim");
224
+ const r = await call(me.token, "DELETE", `/api/auth/sessions/${victim.token_id}`);
225
+ expect(r.status).toBe(200);
226
+ expect(r.body.was_current).toBe(false);
227
+ expect((await call(victim.token, "GET", "/api/auth/me")).status).toBe(401);
228
+ expect((await call(me.token, "GET", "/api/auth/me")).status).toBe(200);
229
+ expect(db.get<any>("SELECT 1 AS x FROM audit_log WHERE action = 'session_revoked' AND target_id = ?1", victim.token_id)?.x).toBe(1);
230
+ });
231
+ test("DELETE of my own session is a logout", async () => {
232
+ const me = await loginAs("logout");
233
+ const r = await call(me.token, "DELETE", `/api/auth/sessions/${me.token_id}`);
234
+ expect(r.status).toBe(200);
235
+ expect(r.body.was_current).toBe(true);
236
+ expect((await call(me.token, "GET", "/api/auth/me")).status).toBe(401);
237
+ });
238
+ test("DELETE cannot reach API tokens, node tokens, or another user's session", async () => {
239
+ const me = await loginAs("guard");
240
+ const apiRow = db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(apiToken));
241
+ const ntokRow = db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(ntok));
242
+ expect((await call(me.token, "DELETE", `/api/auth/sessions/${apiRow.token_id}`)).status).toBe(404);
243
+ expect((await call(me.token, "DELETE", `/api/auth/sessions/${ntokRow.token_id}`)).status).toBe(404);
244
+ const stranger = register(`sess_stranger_${Date.now()}`, PW);
245
+ expect((await call(me.token, "DELETE", `/api/auth/sessions/${stranger.token_id}`)).status).toBe(404);
246
+ expect((await call(stranger.token!, "GET", "/api/auth/me")).status).toBe(200);
247
+ expect((await call(ntok, "DELETE", `/api/auth/sessions/${me.token_id}`)).status).toBe(403);
248
+ expect((await call(me.token, "GET", "/api/auth/me")).status).toBe(200);
249
+ });
250
+ });
package/src/auth.ts CHANGED
@@ -43,6 +43,7 @@ export interface AuthResult {
43
43
  error?: string;
44
44
  user?: AuthUser;
45
45
  token?: string; // user token (utok_)
46
+ token_id?: string; // api_tokens.token_id of `token` — lets a client tell which row in GET /api/auth/sessions is itself
46
47
  network_token?: string; // network token (ntok_) for default network
47
48
  network_id?: string;
48
49
  // #261 P0-2 (2026-06-28): true when the logged-in user still has the
@@ -61,7 +62,7 @@ function validatePasswordStrength(password: string, label = "password"): string
61
62
  return null;
62
63
  }
63
64
 
64
- export function register(username: string, password: string, email?: string, displayName?: string, opts: { issueTokens?: boolean } = {}): AuthResult {
65
+ export function register(username: string, password: string, email?: string, displayName?: string, opts: { issueTokens?: boolean; client?: SessionClientInfo } = {}): AuthResult {
65
66
  if (!username || username.length < 2) return { ok: false, error: "username must be at least 2 characters" };
66
67
  if (username.length > 50) return { ok: false, error: "username too long (max 50)" };
67
68
  if (!/^[a-zA-Z0-9_\-\u4e00-\u9fff]+$/.test(username)) return { ok: false, error: "username contains invalid characters" };
@@ -135,8 +136,8 @@ export function register(username: string, password: string, email?: string, dis
135
136
  const userToken = generateUserToken();
136
137
  const userTokenId = generateId("tok");
137
138
  db.run(
138
- "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
139
- [userTokenId, hashToken(userToken), userId, null, "user-login", "user"]
139
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, user_agent) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
140
+ [userTokenId, hashToken(userToken), userId, null, "user-login", "user", cleanClientField(opts.client?.label, 64), cleanClientField(opts.client?.userAgent, 256)]
140
141
  );
141
142
 
142
143
  // Network token (ntok_) — bound to default network, for agent-node
@@ -151,12 +152,13 @@ export function register(username: string, password: string, email?: string, dis
151
152
  ok: true,
152
153
  user: { user_id: userId, username, display_name: displayName || username, email: email || null, role: isFirstUser ? "admin" : "user" },
153
154
  token: userToken,
155
+ token_id: userTokenId,
154
156
  network_token: networkToken,
155
157
  network_id: networkId,
156
158
  };
157
159
  }
158
160
 
159
- export function login(username: string, password: string): AuthResult {
161
+ export function login(username: string, password: string, client: SessionClientInfo = {}): AuthResult {
160
162
  const user = db.get<any>(
161
163
  "SELECT user_id, username, password_hash, display_name, email, role, must_change_password FROM users WHERE username = ?1",
162
164
  username);
@@ -200,8 +202,8 @@ export function login(username: string, password: string): AuthResult {
200
202
  const userToken = generateUserToken();
201
203
  const tokenId = generateId("tok");
202
204
  db.run(
203
- "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
204
- [tokenId, hashToken(userToken), user.user_id, null, "user-login", "user"]
205
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, user_agent) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
206
+ [tokenId, hashToken(userToken), user.user_id, null, "user-login", "user", cleanClientField(client.label, 64), cleanClientField(client.userAgent, 256)]
205
207
  );
206
208
 
207
209
  // Find default network
@@ -217,6 +219,7 @@ export function login(username: string, password: string): AuthResult {
217
219
  ok: true,
218
220
  user: { user_id: user.user_id, username: user.username, display_name: user.display_name, email: user.email, role: user.role },
219
221
  token,
222
+ token_id: tokenId,
220
223
  network_id: networkId,
221
224
  // #261 P0-2 — only include field when truthy (back-compat; old clients
222
225
  // don't see this field at all unless their account is flagged).
@@ -288,16 +291,64 @@ export function createNetworkTokenForNode(userId: string, networkId: string, nod
288
291
  return { ok: true, token, token_id: tokenId, ...(nodeId ? { node_id: nodeId } : {}) };
289
292
  }
290
293
 
294
+ // ── 登录会话(login session)令牌 ──
295
+ // 「会话令牌」= scope='user' 且不绑网络的 utok_:登录 / 注册签发的 user-login、改密码后换发的
296
+ // password-change、管理员重置签发的 admin-reset。它们代表「某台设备上的一次登录」,
297
+ // 由 GET /api/auth/sessions 列出、可被「退出其他设备」撤销,并且**闲置 N 天自动过期**(滑动窗口,
298
+ // 以 last_used_at 计,从没用过的以 created_at 计)。
299
+ // 不受影响:POST /api/auth/tokens 显式创建的 API 令牌(scope='full')、节点 / 网络令牌(network_id 非空)。
300
+ // 它们是给脚本和节点长期用的凭据,由各自的管理入口撤销,不该因为一段时间没被用就失效。
301
+ const SESSION_TOKEN_SQL = "scope = 'user' AND network_id IS NULL";
302
+ // last_used_at 最多每小时写一次:每个请求都会解析令牌,原来每次都 UPDATE。
303
+ // 以小时为粒度对一个以天计的闲置窗口没有影响。
304
+ const LAST_USED_WRITE_INTERVAL_SECONDS = 3600;
305
+
306
+ export type SessionClientInfo = { label?: unknown; userAgent?: unknown };
307
+
308
+ function cleanClientField(value: unknown, max: number): string | null {
309
+ if (typeof value !== "string") return null;
310
+ const cleaned = value.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, max);
311
+ return cleaned || null;
312
+ }
313
+
314
+ /** 会话令牌闲置多少天后过期;COMMHUB_SESSION_IDLE_DAYS,默认 30,0 = 关闭闲置过期。每次读,测试可以改。 */
315
+ export function sessionIdleDays(): number {
316
+ const raw = process.env.COMMHUB_SESSION_IDLE_DAYS;
317
+ if (raw === undefined || raw.trim() === "") return 30;
318
+ const n = Number(raw);
319
+ return Number.isFinite(n) && n >= 0 ? n : 30;
320
+ }
321
+
322
+ // datetime('now', ?N) 的偏移参数;PgAdapter 把它译成 NOW() + $N::INTERVAL。
323
+ function idleCutoffOffset(): string | null {
324
+ const days = sessionIdleDays();
325
+ return days > 0 ? `-${Math.round(days * 86400)} seconds` : null;
326
+ }
327
+
328
+ // 已按闲置过期的会话令牌:只有 resolveToken 拒绝之后才查,用来把 401 说清楚是「过期」而不是「无效」。
329
+ export function isExpiredSessionToken(token: string): boolean {
330
+ const cutoff = idleCutoffOffset();
331
+ if (!token || !cutoff) return false;
332
+ const row = db.get<any>(
333
+ `SELECT token_id FROM api_tokens
334
+ WHERE token_hash = ?1 AND ${SESSION_TOKEN_SQL} AND revoked_at IS NULL
335
+ AND COALESCE(last_used_at, created_at) < datetime('now', ?2)`,
336
+ hashToken(token), cutoff);
337
+ return !!row;
338
+ }
339
+
291
340
  export function resolveToken(token: string): { user: AuthUser; networkId: string | null; tokenName: string | null; tokenId: string | null } | null {
292
341
  const tHash = hashToken(token);
342
+ const cutoff = idleCutoffOffset();
293
343
  const row = db.get<any>(
294
344
  `SELECT t.token_id, t.user_id, t.network_id, t.scope, t.name AS token_name,
295
345
  u.username, u.display_name, u.email, u.role
296
346
  FROM api_tokens t JOIN users u ON t.user_id = u.user_id
297
347
  WHERE t.token_hash = ?1
298
348
  AND (t.expires_at IS NULL OR t.expires_at > datetime('now'))
299
- AND t.revoked_at IS NULL`,
300
- tHash);
349
+ AND t.revoked_at IS NULL${cutoff ? `
350
+ AND NOT (t.scope = 'user' AND t.network_id IS NULL AND COALESCE(t.last_used_at, t.created_at) < datetime('now', ?2))` : ""}`,
351
+ ...(cutoff ? [tHash, cutoff] : [tHash]));
301
352
 
302
353
  if (!row) return null;
303
354
 
@@ -306,8 +357,11 @@ export function resolveToken(token: string): { user: AuthUser; networkId: string
306
357
  // 在这里拒绝,所有 REST / MCP / SSE 路径一起生效(含升级前就签发、后来才被设为受限的令牌)。
307
358
  if (row.network_id && isAgentRestricted(row.user_id, row.network_id)) return null;
308
359
 
309
- // Update last_used
310
- db.run("UPDATE api_tokens SET last_used_at = datetime('now') WHERE token_hash = ?1", [tHash]);
360
+ // Update last_used(节流:见 LAST_USED_WRITE_INTERVAL_SECONDS)
361
+ db.run(
362
+ "UPDATE api_tokens SET last_used_at = datetime('now') WHERE token_id = ?1 AND (last_used_at IS NULL OR last_used_at < datetime('now', ?2))",
363
+ [row.token_id, `-${LAST_USED_WRITE_INTERVAL_SECONDS} seconds`]
364
+ );
311
365
 
312
366
  return {
313
367
  user: { user_id: row.user_id, username: row.username, display_name: row.display_name, email: row.email, role: row.role },
@@ -432,6 +486,47 @@ export function revokeOtherUserTokens(userId: string, exceptTokenId?: string | n
432
486
  return result.changes;
433
487
  }
434
488
 
489
+ export type LoginSession = {
490
+ token_id: string;
491
+ name: string;
492
+ created_at: string;
493
+ last_used_at: string | null;
494
+ client_label: string | null;
495
+ user_agent: string | null;
496
+ is_current: boolean;
497
+ };
498
+
499
+ /** 我的登录会话(未过期、未撤销),最近使用的在前。 */
500
+ export function listSessions(userId: string, currentTokenId: string | null): LoginSession[] {
501
+ const cutoff = idleCutoffOffset();
502
+ const rows = db.all<any>(
503
+ `SELECT token_id, name, created_at, last_used_at, client_label, user_agent FROM api_tokens
504
+ WHERE user_id = ?1 AND ${SESSION_TOKEN_SQL} AND revoked_at IS NULL${cutoff ? `
505
+ AND COALESCE(last_used_at, created_at) >= datetime('now', ?2)` : ""}
506
+ ORDER BY COALESCE(last_used_at, created_at) DESC, created_at DESC`,
507
+ ...(cutoff ? [userId, cutoff] : [userId]));
508
+ return rows.map((r) => ({
509
+ token_id: r.token_id,
510
+ name: r.name,
511
+ created_at: r.created_at,
512
+ last_used_at: r.last_used_at ?? null,
513
+ client_label: r.client_label ?? null,
514
+ user_agent: r.user_agent ?? null,
515
+ is_current: !!currentTokenId && r.token_id === currentTokenId,
516
+ }));
517
+ }
518
+
519
+ /** 撤销我的一个登录会话;可以是当前这个(= 退出登录)。不是会话令牌(API 令牌 / 节点令牌)时当作不存在。 */
520
+ export function revokeSession(userId: string, tokenId: string): { ok: boolean; error?: string } {
521
+ const result = db.run(`DELETE FROM api_tokens WHERE token_id = ?1 AND user_id = ?2 AND ${SESSION_TOKEN_SQL}`, [tokenId, userId]);
522
+ return result.changes > 0 ? { ok: true } : { ok: false, error: "session_not_found" };
523
+ }
524
+
525
+ /** 退出其他所有设备:撤销我除当前之外的全部登录会话(含已闲置过期的)。API 令牌、节点令牌不动。 */
526
+ export function revokeOtherSessions(userId: string, currentTokenId: string): number {
527
+ return db.run(`DELETE FROM api_tokens WHERE user_id = ?1 AND ${SESSION_TOKEN_SQL} AND token_id != ?2`, [userId, currentTokenId]).changes;
528
+ }
529
+
435
530
  export function changePassword(userId: string, oldPassword: string, newPassword: string, currentTokenId?: string | null): { ok: boolean; error?: string; revoked?: number } {
436
531
  const passwordError = validatePasswordStrength(newPassword, "new password");
437
532
  if (passwordError) return { ok: false, error: passwordError };
package/src/db.ts CHANGED
@@ -640,6 +640,9 @@ for (const ddl of [
640
640
  "ALTER TABLE api_tokens ADD COLUMN revoked_at TEXT",
641
641
  "ALTER TABLE api_tokens ADD COLUMN role TEXT",
642
642
  "ALTER TABLE api_tokens ADD COLUMN bound_node_id TEXT",
643
+ // 登录会话的设备标识(GET /api/auth/sessions 显示用):客户端自报的 client_label + 登录请求的 User-Agent。
644
+ "ALTER TABLE api_tokens ADD COLUMN client_label TEXT",
645
+ "ALTER TABLE api_tokens ADD COLUMN user_agent TEXT",
643
646
  ]) {
644
647
  try { db.exec(ddl); }
645
648
  catch (e: any) {
package/src/server.ts CHANGED
@@ -20,7 +20,7 @@ import { canMessageAgent, isAgentRestricted, listAgentGrants, getAgentAccessMode
20
20
  import { validateAvatarUrl } from "./avatar-validate.js";
21
21
  import { narrowTags, parseStoredTags, validateScalarAttr } from "./node-attrs-validate.js";
22
22
  import { daemonDefaultWorkdirRoot } from "./create-node-validate.js";
23
- import { register, login, resolveToken, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, issueUserToken, listTokens, createToken, revokeToken, getNetworkMembers, getNetworkHumans, getUserNetworkRole, addNetworkMember, adminCreateUser, listUsersWithMemberships, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
23
+ import { register, login, resolveToken, isExpiredSessionToken, sessionIdleDays, listSessions, revokeSession, revokeOtherSessions, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, issueUserToken, listTokens, createToken, revokeToken, getNetworkMembers, getNetworkHumans, getUserNetworkRole, addNetworkMember, adminCreateUser, listUsersWithMemberships, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
24
24
  import { abortRename, cleanupCommittedRenameSessions, commitRename, prepareRename, resolveCanonicalAlias } from "./rename.js";
25
25
  import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
26
26
  import { clientRequestIdFromMeta, idempotentTaskId, idempotentTaskMatches, type StoredIdempotentTask } from "./task-idempotency.js";
@@ -260,6 +260,20 @@ function nodeMayRenameTxn(resolved: { networkId: string | null; tokenName: strin
260
260
  return self === txn.old_alias || self === txn.new_alias;
261
261
  }
262
262
 
263
+ // 登录会话闲置过期(auth.ts sessionIdleDays):resolveToken 把过期令牌当作不存在,各端点于是回各自的
264
+ // 401("invalid token" / "unauthorized" / "auth required" …)。客户端要据此提示「登录已过期」而不是
265
+ // 「令牌无效」,所以在唯一的出口处统一改写:请求带的令牌恰好是一个已闲置过期的会话令牌时,
266
+ // 401 的正文换成 { error: "token_expired" }。状态码不变;只在已经是 401 时才多查一次库。
267
+ function explainExpiredSession(req: Request, res: Response | undefined): Response | undefined {
268
+ if (!res || res.status !== 401) return res;
269
+ const token = requestToken(req);
270
+ if (!token || !isExpiredSessionToken(token)) return res;
271
+ return withCors(req, Response.json(
272
+ { ok: false, error: "token_expired", message: "login session expired after inactivity; sign in again" },
273
+ { status: 401 },
274
+ ));
275
+ }
276
+
263
277
  function userTokenRequired(req: Request): Response {
264
278
  return withCors(req, Response.json({ ok: false, error: "user_token_required", message: "this endpoint acts on a user account; node tokens cannot use it" }, { status: 403 }));
265
279
  }
@@ -848,7 +862,7 @@ return Bun.serve({
848
862
 
849
863
  async fetch(req, server) {
850
864
  // 2026-09-16 —— 所有 JSON/文本响应在客户端要求时 gzip(见 http-gzip.ts);SSE/流式/二进制原样。
851
- return maybeGzipResponse(req, await (async (): Promise<Response | undefined> => {
865
+ return maybeGzipResponse(req, explainExpiredSession(req, await (async (): Promise<Response | undefined> => {
852
866
  const url = new URL(req.url, `http://localhost:${PORT}`);
853
867
 
854
868
  // ── CORS preflight ──
@@ -1134,7 +1148,7 @@ return Bun.serve({
1134
1148
  }
1135
1149
  try {
1136
1150
  const body = await req.json() as any;
1137
- const result = register(body.username, body.password, body.email, body.display_name);
1151
+ const result = register(body.username, body.password, body.email, body.display_name, { client: { label: body.client_label, userAgent: req.headers.get("user-agent") } });
1138
1152
  if (result.ok) logAudit(result.user!.user_id, body.username, "register", "user", result.user!.user_id);
1139
1153
  return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
1140
1154
  } catch (e: any) {
@@ -1162,7 +1176,7 @@ return Bun.serve({
1162
1176
  { status: 429, headers: { "Retry-After": String(Math.ceil((lock.retryAfterMs ?? 1000) / 1000)) } }
1163
1177
  ));
1164
1178
  }
1165
- const result = login(body.username, body.password);
1179
+ const result = login(body.username, body.password, { label: body.client_label, userAgent: req.headers.get("user-agent") });
1166
1180
  if (result.ok) {
1167
1181
  sharedLoginFailureLockout.recordSuccess(body.username);
1168
1182
  logAudit(result.user!.user_id, body.username, "login", "user", result.user!.user_id);
@@ -1354,6 +1368,46 @@ return Bun.serve({
1354
1368
  return withCors(req, Response.json(result, { status: result.ok ? 200 : 404 }));
1355
1369
  }
1356
1370
 
1371
+ // ── 登录设备:列出 / 撤销我的登录会话(见 auth.ts「登录会话令牌」) ──
1372
+ // 只接受用户令牌:节点令牌不是一次登录,也不能替签发它的人管账号(同 #2086)。
1373
+ if (url.pathname === "/api/auth/sessions" && req.method === "GET") {
1374
+ const token = requestToken(req, { allowQueryToken: false });
1375
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1376
+ const resolved = resolveToken(token);
1377
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1378
+ if (isNodeCredential(resolved)) return userTokenRequired(req);
1379
+ const sessions = listSessions(resolved.user.user_id, resolved.tokenId);
1380
+ return withCors(req, Response.json({ ok: true, sessions, current_token_id: resolved.tokenId, idle_timeout_days: sessionIdleDays() }));
1381
+ }
1382
+
1383
+ if (url.pathname === "/api/auth/sessions/revoke-others" && req.method === "POST") {
1384
+ const token = requestToken(req, { allowQueryToken: false });
1385
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1386
+ const resolved = resolveToken(token);
1387
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1388
+ if (isNodeCredential(resolved)) return userTokenRequired(req);
1389
+ // 当前令牌必须是一次登录会话,否则「保留当前这个」无从说起(API 令牌调用会把所有会话都撤掉)。
1390
+ const current = resolved.tokenId ? listSessions(resolved.user.user_id, resolved.tokenId).find((x) => x.is_current) : undefined;
1391
+ if (!current) return withCors(req, Response.json({ ok: false, error: "session_token_required", message: "call this with a login session token" }, { status: 403 }));
1392
+ const revoked = revokeOtherSessions(resolved.user.user_id, current.token_id);
1393
+ logAudit(resolved.user.user_id, resolved.user.username, "sessions_revoked_others", "token", current.token_id, `revoked=${revoked}`);
1394
+ return withCors(req, Response.json({ ok: true, revoked, kept_token_id: current.token_id }));
1395
+ }
1396
+
1397
+ const sessionDeleteMatch = url.pathname.match(/^\/api\/auth\/sessions\/([^/]+)$/);
1398
+ if (sessionDeleteMatch && req.method === "DELETE") {
1399
+ const token = requestToken(req, { allowQueryToken: false });
1400
+ if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1401
+ const resolved = resolveToken(token);
1402
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1403
+ if (isNodeCredential(resolved)) return userTokenRequired(req);
1404
+ const targetId = decodeURIComponent(sessionDeleteMatch[1]);
1405
+ const result = revokeSession(resolved.user.user_id, targetId);
1406
+ const self = targetId === resolved.tokenId;
1407
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "session_revoked", "token", targetId, self ? "self (logout)" : undefined);
1408
+ return withCors(req, Response.json({ ...result, ...(result.ok ? { was_current: self } : {}) }, { status: result.ok ? 200 : 404 }));
1409
+ }
1410
+
1357
1411
  // ── V3: Network management ──
1358
1412
  if (url.pathname === "/api/networks" && req.method === "GET") {
1359
1413
  const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
@@ -3990,7 +4044,7 @@ Security: ${SECURITY_LABEL}
3990
4044
  `,
3991
4045
  { status: 200, headers: { "Content-Type": "text/plain; charset=utf-8" } } // #426
3992
4046
  ));
3993
- })());
4047
+ })()));
3994
4048
  },
3995
4049
 
3996
4050
  // ── WebSocket handler for tmux terminal streaming ──