@sleep2agi/commhub-server 0.9.0-preview.40 → 0.9.0-preview.41
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 +1 -1
- package/src/db.ts +32 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +82 -0
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/server.ts +73 -0
- package/src/tools.ts +61 -19
- package/src/user-inbox-read-http.test.ts +220 -0
- package/src/user-inbox-schema.test.ts +53 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
3
|
+
"version": "0.9.0-preview.41",
|
|
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",
|
package/src/db.ts
CHANGED
|
@@ -53,6 +53,38 @@ db.exec(`
|
|
|
53
53
|
CREATE INDEX IF NOT EXISTS idx_inbox_pending
|
|
54
54
|
ON inbox(session_name, acked) WHERE acked = 0;
|
|
55
55
|
|
|
56
|
+
-- #1459 ① — agent→用户的 desktop message 持久化收件箱。
|
|
57
|
+
-- inbox 是 session_name(alias) 寻址的;desktop message 目标是 user_id,不能复用
|
|
58
|
+
-- inbox(否则 /api/messages+inbox_count+SSE gate 一票 alias 域查询会隐性开始返回
|
|
59
|
+
-- 用户消息)。单独一张 user_id 寻址表,镜像 inbox 的 acked/count 语义。
|
|
60
|
+
-- message_id 即主键(dm_<uuid>,与 inbox「消息 id 即主键」同形)→ 写入用
|
|
61
|
+
-- INSERT OR IGNORE,send_desktop_message 重试天然幂等,不会插出重复消息行。
|
|
62
|
+
-- 索引留到 P3:读取查询形状定了再按真实 WHERE 加(SDK马 review:没查询就加索引是猜;
|
|
63
|
+
-- 部分索引 acked=0 若「近期」读含已 ack 行则用不上)。P1 只落表。
|
|
64
|
+
CREATE TABLE IF NOT EXISTS user_inbox (
|
|
65
|
+
message_id TEXT PRIMARY KEY,
|
|
66
|
+
network_id TEXT,
|
|
67
|
+
user_id TEXT NOT NULL,
|
|
68
|
+
from_session TEXT,
|
|
69
|
+
kind TEXT NOT NULL DEFAULT 'info',
|
|
70
|
+
title TEXT,
|
|
71
|
+
content TEXT NOT NULL,
|
|
72
|
+
severity TEXT NOT NULL DEFAULT 'info',
|
|
73
|
+
meta_json TEXT,
|
|
74
|
+
acked INTEGER NOT NULL DEFAULT 0,
|
|
75
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
76
|
+
acked_at TEXT
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
-- #1459 ① P3 —— 现在有真实查询了才建索引(P1 刻意没建:没有查询就加索引是猜)。
|
|
80
|
+
-- 覆盖回读分支的两条:
|
|
81
|
+
-- 列表 SELECT ... WHERE user_id = ? [AND acked = 0] ORDER BY created_at DESC
|
|
82
|
+
-- 未读数 SELECT COUNT(*) WHERE user_id = ? AND acked = 0
|
|
83
|
+
-- 不是部分索引(不写 WHERE acked = 0):列表在 unacked=0 时要读**含已 ack 的**
|
|
84
|
+
-- 近期消息,部分索引那时用不上。
|
|
85
|
+
CREATE INDEX IF NOT EXISTS idx_user_inbox_user_acked
|
|
86
|
+
ON user_inbox(user_id, acked, created_at);
|
|
87
|
+
|
|
56
88
|
CREATE TABLE IF NOT EXISTS completions (
|
|
57
89
|
id TEXT PRIMARY KEY,
|
|
58
90
|
session_name TEXT NOT NULL,
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// #1459 —— P2 写路径与 P3 读路径的**接缝**。
|
|
2
|
+
//
|
|
3
|
+
// 通信龙 在 origin/main 上核出的覆盖缝:desktop-message.test.ts 测真实 send 但
|
|
4
|
+
// 不碰 scope=user 读;user-inbox-read-http.test.ts 测 scope=user 读但用 SQL 直接
|
|
5
|
+
// 播种行。两半各自对着自己的夹具绿,而"真实 send 写进的行、真实 scope=user
|
|
6
|
+
// 读得回来"从没验过。本文件就走这一条:真 send → 真 HTTP 读。
|
|
7
|
+
//
|
|
8
|
+
// 附带钉住 network_id 不为 NULL 的**真实机制**:不是 INSERT 时兜底,而是
|
|
9
|
+
// tools.ts 里 `if (!effectiveNetId) return writeDeniedReply(...)` 在写之前就拒。
|
|
10
|
+
// 见下面 "解析不出 network 时拒发" 那条 —— 把那个 if 拿掉,本文件会红。
|
|
11
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
12
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
16
|
+
|
|
17
|
+
const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-dm-seam-"));
|
|
18
|
+
process.env.COMMHUB_DB ||= join(PRIVATE_DB_DIR, "hub.db");
|
|
19
|
+
|
|
20
|
+
let server: ReturnType<typeof Bun.serve>;
|
|
21
|
+
let base = "";
|
|
22
|
+
let targetToken = "";
|
|
23
|
+
let senderId = "", targetId = "", senderNet = "", secondNet = "";
|
|
24
|
+
let send: (args: any) => Promise<Record<string, any>>;
|
|
25
|
+
|
|
26
|
+
function buildSend(opts: { netId: string | null; userId: string | null; alias: string | null }) {
|
|
27
|
+
const mcp = new McpServer({ name: "test", version: "0" }) as any;
|
|
28
|
+
const tools: Record<string, any> = {};
|
|
29
|
+
const origTool = mcp.tool.bind(mcp);
|
|
30
|
+
mcp.tool = (name: string, _d: string, _s: any, handler: any) => { tools[name] = handler; return origTool(name, _d, _s, handler); };
|
|
31
|
+
const { registerTools } = require("./tools.js");
|
|
32
|
+
registerTools(mcp, undefined, opts.netId, opts.userId, opts.alias, false, "tok_seam_test");
|
|
33
|
+
return async (args: any) => JSON.parse((await tools["send_desktop_message"](args)).content[0].text);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
beforeAll(async () => {
|
|
37
|
+
const { db } = await import("./db.js");
|
|
38
|
+
const { register, addNetworkMember } = await import("./auth.js");
|
|
39
|
+
const stamp = Date.now();
|
|
40
|
+
|
|
41
|
+
// 首个注册用户自动成为全局 admin,而 admin 读侧不走 scope 过滤分支。
|
|
42
|
+
// 收件人必须是普通 user,否则"读得回来"可能只是因为它绕过了过滤。
|
|
43
|
+
expect(register(`seam_admin_${stamp}`, "SeamAdmin-Strong-1!").ok).toBe(true);
|
|
44
|
+
const s = register(`seam_sender_${stamp}`, "SeamSender-Strong-1!");
|
|
45
|
+
const t = register(`seam_target_${stamp}`, "SeamTarget-Strong-1!");
|
|
46
|
+
expect(s.ok && t.ok).toBe(true);
|
|
47
|
+
targetToken = t.token!;
|
|
48
|
+
senderNet = s.network_id!;
|
|
49
|
+
secondNet = t.network_id!;
|
|
50
|
+
senderId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", `seam_sender_${stamp}`)!.user_id;
|
|
51
|
+
targetId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", `seam_target_${stamp}`)!.user_id;
|
|
52
|
+
expect(db.get<{ role: string }>("SELECT role FROM users WHERE user_id = ?1", targetId)?.role).toBe("user");
|
|
53
|
+
|
|
54
|
+
// 收件人进发件人的网(真实形态:同网两成员)。
|
|
55
|
+
expect(addNetworkMember(senderNet, targetId, "member").ok).toBe(true);
|
|
56
|
+
|
|
57
|
+
send = buildSend({ netId: senderNet, userId: senderId, alias: "seam-sender" });
|
|
58
|
+
|
|
59
|
+
const mod = await import("./server.js");
|
|
60
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
61
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterAll(() => {
|
|
65
|
+
try { server?.stop(true); } catch {}
|
|
66
|
+
try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const readOwn = async (token: string, qs = "") => {
|
|
70
|
+
const r = await fetch(`${base}/api/messages?scope=user${qs}`, { headers: { Authorization: `Bearer ${token}` } });
|
|
71
|
+
return { status: r.status, body: await r.json() as any };
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
describe("#1459 P2 写 → P3 读 接缝", () => {
|
|
75
|
+
test("🔴 真实 send_desktop_message 写进的行,真实 GET ?scope=user 读得回来", async () => {
|
|
76
|
+
const sent = await send({ to_user_id: targetId, message: "接缝测试正文", title: "接缝", severity: "warning", kind: "agent_message" });
|
|
77
|
+
expect([sent.ok, sent.persisted]).toEqual([true, true]);
|
|
78
|
+
|
|
79
|
+
const { status, body } = await readOwn(targetToken);
|
|
80
|
+
expect(status).toBe(200);
|
|
81
|
+
const row = body.messages.find((m: any) => m.message_id === sent.message_id);
|
|
82
|
+
expect(row).toBeTruthy(); // 承重:两半接得上
|
|
83
|
+
expect(row.content).toBe("接缝测试正文");
|
|
84
|
+
expect(row.title).toBe("接缝");
|
|
85
|
+
expect(row.severity).toBe("warning");
|
|
86
|
+
expect(row.user_id).toBe(targetId);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("🔴 落库的 network_id 就是授权用的那个网,不是 NULL —— 否则 scoped 读回不来", async () => {
|
|
90
|
+
const { db } = require("./db.js");
|
|
91
|
+
const sent = await send({ to_user_id: targetId, message: "网络归属" });
|
|
92
|
+
const row = db.get<any>("SELECT network_id FROM user_inbox WHERE message_id = ?1", sent.message_id);
|
|
93
|
+
expect(row.network_id).toBe(senderNet);
|
|
94
|
+
|
|
95
|
+
const { body } = await readOwn(targetToken);
|
|
96
|
+
expect(body.messages.map((m: any) => m.message_id)).toContain(sent.message_id);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("unread 随真实 send 增长", async () => {
|
|
100
|
+
const before = (await readOwn(targetToken)).body.unread;
|
|
101
|
+
await send({ to_user_id: targetId, message: "计数 +1" });
|
|
102
|
+
expect((await readOwn(targetToken)).body.unread).toBe(before + 1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("redact-at-read 作用在真实写进去的正文上", async () => {
|
|
106
|
+
const sent = await send({ to_user_id: targetId, message: "凭据 ntok_seamtoken123456 在正文里" });
|
|
107
|
+
const { db } = require("./db.js");
|
|
108
|
+
// 存储侧保留原文,遮蔽发生在**回读**时 —— 这两句一起才说明是 read-time 脱敏。
|
|
109
|
+
expect(db.get<any>("SELECT content FROM user_inbox WHERE message_id = ?1", sent.message_id).content)
|
|
110
|
+
.toContain("ntok_seamtoken123456");
|
|
111
|
+
const row = (await readOwn(targetToken)).body.messages.find((m: any) => m.message_id === sent.message_id);
|
|
112
|
+
expect(row.content).toContain("ntok_***redacted***");
|
|
113
|
+
expect(row.content).not.toContain("ntok_seamtoken123456");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// 🔴 这一条是 `if (!effectiveNetId) return` 这道守卫**唯一**能被触发的场景。
|
|
117
|
+
// 有 auth ctx 时前一道 `canWrite` 就拦了;而 canRestWriteNetwork 对
|
|
118
|
+
// legacy 全局 token / --dev-open(authCtx 为 null)是 `return true`
|
|
119
|
+
// (network-scope.ts:96)—— 那条路上只剩这道守卫挡着 NULL 孤儿行。
|
|
120
|
+
// 把 tools.ts:2320 那行删掉,本条会红(写出一行 network_id=NULL)。
|
|
121
|
+
test("🔴 legacy 无 auth ctx 的调用者同样拒发,不写 NULL 孤儿行", async () => {
|
|
122
|
+
const { db } = require("./db.js");
|
|
123
|
+
const legacy = buildSend({ netId: null, userId: null as any, alias: null as any });
|
|
124
|
+
const before = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM user_inbox")!.n;
|
|
125
|
+
const r = await legacy({ to_user_id: targetId, message: "legacy 无网络上下文" });
|
|
126
|
+
expect(r.ok).toBe(false);
|
|
127
|
+
expect(r.error).toBe("network_id_required");
|
|
128
|
+
expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM user_inbox")!.n).toBe(before);
|
|
129
|
+
expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM user_inbox WHERE network_id IS NULL")!.n).toBe(0);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("🔴 解析不出 network 时**拒发**,而不是写一行 network_id=NULL 的孤儿", async () => {
|
|
133
|
+
const { db } = require("./db.js");
|
|
134
|
+
const { addNetworkMember } = require("./auth.js");
|
|
135
|
+
// 发件人进第二个网 ⇒ 不带 network_id 时 singleNetworkId 解析不出唯一网。
|
|
136
|
+
addNetworkMember(secondNet, senderId, "member");
|
|
137
|
+
const ambiguous = buildSend({ netId: null, userId: senderId, alias: "seam-sender" });
|
|
138
|
+
|
|
139
|
+
const before = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM user_inbox")!.n;
|
|
140
|
+
const r = await ambiguous({ to_user_id: targetId, message: "无法解析网络" });
|
|
141
|
+
|
|
142
|
+
expect(r.ok).toBe(false);
|
|
143
|
+
expect(r.error).toBe("network_id_required");
|
|
144
|
+
// 承重:拒发意味着**一行都不写**。若把 tools.ts 的
|
|
145
|
+
// `if (!effectiveNetId) return writeDeniedReply(...)` 拿掉,这里会多出一行
|
|
146
|
+
// network_id=NULL 的孤儿 —— 写得进去、scoped 读不回来。
|
|
147
|
+
expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM user_inbox")!.n).toBe(before);
|
|
148
|
+
expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM user_inbox WHERE network_id IS NULL")!.n).toBe(0);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -284,3 +284,85 @@ describe("#1459 send_desktop_message 返回值反映真实投递态", () => {
|
|
|
284
284
|
expect(offline.delivered).not.toBe(online.delivered);
|
|
285
285
|
});
|
|
286
286
|
});
|
|
287
|
+
|
|
288
|
+
// #1459 ① P2 —— 写入持久化。
|
|
289
|
+
describe("#1459 ① P2 send_desktop_message 持久化", () => {
|
|
290
|
+
const rowsFor = (mid: string) =>
|
|
291
|
+
db.get<{ n: number }>("SELECT COUNT(*) AS n FROM user_inbox WHERE message_id = ?1", mid)?.n ?? 0;
|
|
292
|
+
|
|
293
|
+
test("🔴 发一条消息 ⇒ user_inbox 落一行,字段与事件一致", async () => {
|
|
294
|
+
const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
|
|
295
|
+
const r = await call(tools.send_desktop_message, {
|
|
296
|
+
to_user_id: TARGET_A, message: "persist me", severity: "warning", title: "T", kind: "agent_message",
|
|
297
|
+
});
|
|
298
|
+
expect(r.ok).toBe(true);
|
|
299
|
+
expect(r.persisted).toBe(true);
|
|
300
|
+
const row = db.get<any>("SELECT * FROM user_inbox WHERE message_id = ?1", r.message_id);
|
|
301
|
+
expect(row).toBeTruthy();
|
|
302
|
+
expect(row.user_id).toBe(TARGET_A);
|
|
303
|
+
expect(row.content).toBe("persist me");
|
|
304
|
+
expect(row.severity).toBe("warning");
|
|
305
|
+
expect(row.title).toBe("T");
|
|
306
|
+
expect(row.from_session).toBe("node-a");
|
|
307
|
+
expect(row.network_id).toBe(NET_A);
|
|
308
|
+
expect(row.acked).toBe(0);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("🔴 没有活订阅者时也要落库 —— 「丢了」变成「待补投」的那一格", async () => {
|
|
312
|
+
const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
|
|
313
|
+
const r = await call(tools.send_desktop_message, { to_user_id: TARGET_A, message: "offline body" });
|
|
314
|
+
expect(r.delivered).toBe(false);
|
|
315
|
+
expect(r.reason).toBe("no_live_subscriber");
|
|
316
|
+
expect(r.persisted).toBe(true);
|
|
317
|
+
expect(rowsFor(r.message_id)).toBe(1);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("🔴 zod 默认值不可依赖:直接调 handler 不传 kind/severity 也必须落库", async () => {
|
|
321
|
+
// 测试与未来的内部调用方绕过 MCP 的 zod .default(),拿到的是 undefined。
|
|
322
|
+
// 列上虽有 DEFAULT,但显式传 NULL 会覆盖它 —— 所以写入侧要自己兜。
|
|
323
|
+
const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
|
|
324
|
+
const r = await call(tools.send_desktop_message, { to_user_id: TARGET_A, message: "no kind given" });
|
|
325
|
+
expect(rowsFor(r.message_id)).toBe(1);
|
|
326
|
+
const row = db.get<any>("SELECT kind, severity FROM user_inbox WHERE message_id = ?1", r.message_id);
|
|
327
|
+
expect(row.kind).toBe("agent_message");
|
|
328
|
+
expect(row.severity).toBe("info");
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("audit_log 与 user_inbox 同生共死(同一事务,条数一致)", async () => {
|
|
332
|
+
const before = auditCount();
|
|
333
|
+
const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
|
|
334
|
+
const r = await call(tools.send_desktop_message, { to_user_id: TARGET_A, message: "paired" });
|
|
335
|
+
expect(auditCount()).toBe(before + 1);
|
|
336
|
+
expect(rowsFor(r.message_id)).toBe(1);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// #1459 ① P2 —— 幂等写法的选择本身要被钉住。
|
|
341
|
+
//
|
|
342
|
+
// 🔴 实现时踩到的:最初用 `INSERT OR IGNORE`,它对「重投同一 message_id」是对的,
|
|
343
|
+
// 但**连 NOT NULL 违规也一起吞掉** —— kind 为 undefined 时整行被静默丢弃,
|
|
344
|
+
// 而工具照样返回 persisted:true。**一条消息凭空消失且报告成功**,正是本
|
|
345
|
+
// issue 要消灭的那个形状。改成 ON CONFLICT(message_id) DO NOTHING:
|
|
346
|
+
// 只赦免主键冲突,别的约束照常抛。
|
|
347
|
+
describe("#1459 ① P2 幂等写法:赦免重复,但不吞约束错误", () => {
|
|
348
|
+
const stmt = `INSERT INTO user_inbox
|
|
349
|
+
(message_id, network_id, user_id, from_session, kind, title, content, severity, meta_json)
|
|
350
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
|
351
|
+
ON CONFLICT(message_id) DO NOTHING`;
|
|
352
|
+
|
|
353
|
+
test("重复的 message_id ⇒ 不抛错、也不产生第二行", () => {
|
|
354
|
+
const mid = `dm_dup_${Date.now()}`;
|
|
355
|
+
const ins = () => db.run(stmt, [mid, NET_A, TARGET_A, "node-a", "info", null, "body", "info", null]);
|
|
356
|
+
ins();
|
|
357
|
+
expect(() => ins()).not.toThrow();
|
|
358
|
+
expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM user_inbox WHERE message_id = ?1", mid)?.n).toBe(1);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test("🔴 NOT NULL 违规必须抛出来 —— 不能像 OR IGNORE 那样静默丢行", () => {
|
|
362
|
+
const mid = `dm_bad_${Date.now()}`;
|
|
363
|
+
expect(() =>
|
|
364
|
+
db.run(stmt, [mid, NET_A, TARGET_A, "node-a", null, null, "body", "info", null]),
|
|
365
|
+
).toThrow();
|
|
366
|
+
expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM user_inbox WHERE message_id = ?1", mid)?.n).toBe(0);
|
|
367
|
+
});
|
|
368
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { redactTokens, redactMessageRow } from "./redact-tokens.js";
|
|
3
|
+
|
|
4
|
+
describe("#1459 ③ redact-at-read", () => {
|
|
5
|
+
test("🔴 各种前缀形状都被遮住,且前缀保留(读者仍知道泄漏的是哪一类)", () => {
|
|
6
|
+
const cases: Array<[string, string]> = [
|
|
7
|
+
["ntok_abcdef123456", "ntok_"],
|
|
8
|
+
["utok_abcdef123456", "utok_"],
|
|
9
|
+
["ghp_" + "A".repeat(24), "ghp_"],
|
|
10
|
+
["github_pat_" + "B".repeat(24), "github_pat_"],
|
|
11
|
+
["xoxb-1234567890-abcdef", "xoxb-"],
|
|
12
|
+
["sk-" + "C".repeat(20), "sk-"],
|
|
13
|
+
];
|
|
14
|
+
for (const [raw, prefix] of cases) {
|
|
15
|
+
const out = redactTokens(`before ${raw} after`);
|
|
16
|
+
expect(out).toContain("***redacted***");
|
|
17
|
+
expect(out).toContain(prefix);
|
|
18
|
+
expect(out).not.toContain(raw); // 原值一定不能整体幸存
|
|
19
|
+
expect(out).toContain("before"); // 周围文本不受影响
|
|
20
|
+
expect(out).toContain("after");
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("🔴 保守:不像凭据的内容一个字都不能改", () => {
|
|
25
|
+
for (const s of [
|
|
26
|
+
"任务完成,磁盘 96%",
|
|
27
|
+
"see https://example.com/a-b_c",
|
|
28
|
+
"sk-", // 太短,不是凭据
|
|
29
|
+
"ntok_", // 光前缀
|
|
30
|
+
"the stock ticker is SKU-12",
|
|
31
|
+
]) {
|
|
32
|
+
expect(redactTokens(s)).toBe(s);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("同一段里的多个 token 全部被遮", () => {
|
|
37
|
+
const out = redactTokens(`a ntok_aaaaaaaaaa b sk-${"z".repeat(20)} c`);
|
|
38
|
+
expect(out.match(/\*\*\*redacted\*\*\*/g)?.length).toBe(2);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("redactMessageRow 只碰自由文本字段,不动结构字段", () => {
|
|
42
|
+
const row = {
|
|
43
|
+
message_id: "dm_ntok_looking_but_is_an_id",
|
|
44
|
+
user_id: "u_1",
|
|
45
|
+
content: "here is ntok_abcdef123456",
|
|
46
|
+
title: "sk-" + "d".repeat(20),
|
|
47
|
+
meta_json: JSON.stringify({ note: "ghp_" + "E".repeat(24) }),
|
|
48
|
+
created_at: "2026-08-30 00:00:00",
|
|
49
|
+
};
|
|
50
|
+
const out = redactMessageRow(row);
|
|
51
|
+
expect(out.content).toContain("***redacted***");
|
|
52
|
+
expect(out.title).toContain("***redacted***");
|
|
53
|
+
expect(out.meta_json).toContain("***redacted***");
|
|
54
|
+
// 结构字段原样 —— 即便 message_id 里恰好有个像前缀的子串
|
|
55
|
+
expect(out.message_id).toBe(row.message_id);
|
|
56
|
+
expect(out.user_id).toBe(row.user_id);
|
|
57
|
+
expect(out.created_at).toBe(row.created_at);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("非字符串原样返回(null/数字不该被改成字符串)", () => {
|
|
61
|
+
expect(redactTokens(null as any)).toBeNull();
|
|
62
|
+
expect(redactTokens(42 as any)).toBe(42);
|
|
63
|
+
expect(redactMessageRow({ content: null, title: 7 } as any)).toEqual({ content: null, title: 7 } as any);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// #1459 ③ —— 读取时的 token 形状脱敏(redact-at-read)。
|
|
2
|
+
//
|
|
3
|
+
// 为什么在**读取**侧而不是写入侧:写入方是 agent,不受信任。存储侧已经走
|
|
4
|
+
// normalizeMetaJson(跨主机路径脱敏,与 inbox 一致),那一层防的是"路径泄漏";
|
|
5
|
+
// 这一层防的是"有人把凭据塞进消息正文或 meta"。两层管的不是同一件事。
|
|
6
|
+
//
|
|
7
|
+
// 🔴 保守优先:宁可漏脱敏一个不像凭据的串,也不要把正常内容改花。所以只匹配
|
|
8
|
+
// 几个**有明确前缀**的形状,不做熵检测、不猜。
|
|
9
|
+
|
|
10
|
+
/** 有明确前缀的凭据形状。
|
|
11
|
+
*
|
|
12
|
+
* 🔴 前缀用**捕获组显式声明**,不靠"切到第一个下划线"之类的推断 ——
|
|
13
|
+
* 第一版就是那么写的,`github_pat_xxx` 被切成了 `github_`,把"这是哪一类
|
|
14
|
+
* 凭据"这个唯一有用的信息也遮掉了。声明比推断短,也不会错。 */
|
|
15
|
+
const TOKEN_PATTERNS: ReadonlyArray<RegExp> = [
|
|
16
|
+
/\b(ntok_|utok_|atok_)[A-Za-z0-9_-]{6,}/g, // 本仓自己的 token
|
|
17
|
+
/\b(github_pat_)[A-Za-z0-9_]{20,}/g, // GitHub fine-grained PAT(必须排在 ghp_ 之前无关,但更长的前缀先声明更清楚)
|
|
18
|
+
/\b(ghp_)[A-Za-z0-9]{20,}/g, // GitHub classic PAT
|
|
19
|
+
/\b(xox[bpoars]-)[A-Za-z0-9-]{10,}/g, // Slack
|
|
20
|
+
/\b(sk-)[A-Za-z0-9_-]{16,}/g, // OpenAI 风格
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/** 对任意文本做 token 形状脱敏。非字符串原样返回。 */
|
|
24
|
+
export function redactTokens<T>(value: T): T {
|
|
25
|
+
if (typeof value !== "string") return value;
|
|
26
|
+
let out: string = value;
|
|
27
|
+
// 保留捕获到的前缀(让读者知道泄漏的是哪一类),其余一律遮掉;不保留尾部 ——
|
|
28
|
+
// 尾部对识别没帮助,却能帮攻击者缩小搜索空间。
|
|
29
|
+
for (const re of TOKEN_PATTERNS) out = out.replace(re, (_m, prefix: string) => `${prefix}***redacted***`);
|
|
30
|
+
return out as unknown as T;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 对一行消息做脱敏:只碰会承载自由文本的字段,不动 id/时间戳等结构字段。 */
|
|
34
|
+
export function redactMessageRow<T extends Record<string, unknown>>(row: T): T {
|
|
35
|
+
const out: Record<string, unknown> = { ...row };
|
|
36
|
+
for (const field of ["content", "title", "meta_json"]) {
|
|
37
|
+
if (typeof out[field] === "string") out[field] = redactTokens(out[field] as string);
|
|
38
|
+
}
|
|
39
|
+
return out as T;
|
|
40
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { redactMessageRow } from "./redact-tokens.js";
|
|
1
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
3
|
import { parseDbTimestampMs } from "./db-timestamp.js";
|
|
3
4
|
import { buildControllableMap, isLifecycleControllable } from "./node-lifecycle-controllable.js";
|
|
@@ -2627,6 +2628,50 @@ return Bun.serve({
|
|
|
2627
2628
|
}
|
|
2628
2629
|
}
|
|
2629
2630
|
|
|
2631
|
+
// ── REST: per-user desktop inbox (#1459 ① P3) ──
|
|
2632
|
+
// 🔴 只在 ?scope=user 时走这条;下面按 alias 寻址的既有分支一行未动
|
|
2633
|
+
// (dashboard 依赖那条 inbox 查询)。
|
|
2634
|
+
if (url.pathname === "/api/messages" && url.searchParams.get("scope") === "user") {
|
|
2635
|
+
// 🔴 收件人取自**鉴权上下文**,不接受 query 指定 —— 否则任何人都能
|
|
2636
|
+
// ?user_id=<别人> 读走别人的私信。
|
|
2637
|
+
const callerUserId = restAuth?.userId ?? null;
|
|
2638
|
+
if (!callerUserId) {
|
|
2639
|
+
return withCors(req, Response.json({ ok: false, error: "auth_required" }, { status: 401 }));
|
|
2640
|
+
}
|
|
2641
|
+
const limit = Math.min(Number(url.searchParams.get("limit")) || 100, 500);
|
|
2642
|
+
const unackedOnly = url.searchParams.get("unacked") === "1";
|
|
2643
|
+
|
|
2644
|
+
const params: any[] = [callerUserId];
|
|
2645
|
+
let sql = `SELECT message_id, network_id, user_id, from_session, kind, title, content, severity,
|
|
2646
|
+
meta_json, acked, created_at, acked_at
|
|
2647
|
+
FROM user_inbox WHERE user_id = ?1`;
|
|
2648
|
+
if (unackedOnly) sql += " AND acked = 0";
|
|
2649
|
+
// 复用 alias 分支同一个 scope 助手:新读路径漏 scope 是已被审计证实的真风险。
|
|
2650
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
2651
|
+
sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
|
|
2652
|
+
params.push(limit);
|
|
2653
|
+
const rows = db.all<any>(sql, ...params);
|
|
2654
|
+
|
|
2655
|
+
// 未读数与列表用同一个 user_id + 同一个 scope 助手算,避免两处口径漂开。
|
|
2656
|
+
const countParams: any[] = [callerUserId];
|
|
2657
|
+
let countSql = "SELECT COUNT(*) AS cnt FROM user_inbox WHERE user_id = ?1 AND acked = 0";
|
|
2658
|
+
countSql = addNetworkScope(countSql, countParams, restScope);
|
|
2659
|
+
const unread = db.get<{ cnt: number }>(countSql, ...countParams)?.cnt ?? 0;
|
|
2660
|
+
|
|
2661
|
+
// 🔴 redact-at-read:写入方是 agent,不受信任。存储侧已做跨主机路径脱敏,
|
|
2662
|
+
// 这一层单独防"有人把凭据塞进正文/标题/meta"。
|
|
2663
|
+
const messages = rows.map(redactMessageRow);
|
|
2664
|
+
|
|
2665
|
+
return withCors(req, Response.json({
|
|
2666
|
+
ok: true,
|
|
2667
|
+
messages,
|
|
2668
|
+
unread,
|
|
2669
|
+
// 同一个数的两个名字:`unread` 给角标读,`pending_count` 与 alias 分支
|
|
2670
|
+
// 的字段名保持一致。**一处计算**,不是两处实现。
|
|
2671
|
+
pending_count: unread,
|
|
2672
|
+
}));
|
|
2673
|
+
}
|
|
2674
|
+
|
|
2630
2675
|
// ── REST: recent messages (for Dashboard communication graph) ──
|
|
2631
2676
|
if (url.pathname === "/api/messages") {
|
|
2632
2677
|
const limit = Math.min(Number(url.searchParams.get("limit")) || 100, 500);
|
|
@@ -2654,6 +2699,34 @@ return Bun.serve({
|
|
|
2654
2699
|
return withCors(req, Response.json({ ok: true, messages: rows, ...(alias ? { pending_count: pendingCount ?? 0 } : {}) }));
|
|
2655
2700
|
}
|
|
2656
2701
|
|
|
2702
|
+
// ── REST: ack per-user desktop messages (#1459 ① P3) ──
|
|
2703
|
+
if (url.pathname === "/api/messages/ack" && req.method === "POST") {
|
|
2704
|
+
const callerUserId = restAuth?.userId ?? null;
|
|
2705
|
+
if (!callerUserId) {
|
|
2706
|
+
return withCors(req, Response.json({ ok: false, error: "auth_required" }, { status: 401 }));
|
|
2707
|
+
}
|
|
2708
|
+
let body: any = null;
|
|
2709
|
+
try { body = await req.json(); } catch { /* 下面按缺参处理 */ }
|
|
2710
|
+
const ids: string[] = Array.isArray(body?.message_ids)
|
|
2711
|
+
? body.message_ids.filter((x: unknown) => typeof x === "string" && x.length > 0)
|
|
2712
|
+
: (typeof body?.message_id === "string" && body.message_id ? [body.message_id] : []);
|
|
2713
|
+
if (ids.length === 0) {
|
|
2714
|
+
return withCors(req, Response.json({ ok: false, error: "message_id_required" }, { status: 400 }));
|
|
2715
|
+
}
|
|
2716
|
+
if (ids.length > 500) {
|
|
2717
|
+
return withCors(req, Response.json({ ok: false, error: "too_many_ids", limit: 500 }, { status: 400 }));
|
|
2718
|
+
}
|
|
2719
|
+
// 🔴 `AND user_id = <ctx>`:只能 ack 自己的。别人的 message_id 传进来
|
|
2720
|
+
// 也匹配不到行,所以既不会改到别人的状态,也不会因此泄漏它是否存在。
|
|
2721
|
+
const params: any[] = [callerUserId, ...ids];
|
|
2722
|
+
const placeholders = ids.map((_, i) => `?${i + 2}`).join(", ");
|
|
2723
|
+
let sql = `UPDATE user_inbox SET acked = 1, acked_at = datetime('now')
|
|
2724
|
+
WHERE user_id = ?1 AND message_id IN (${placeholders}) AND acked = 0`;
|
|
2725
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
2726
|
+
const res = db.run(sql, params);
|
|
2727
|
+
return withCors(req, Response.json({ ok: true, acked: res.changes ?? 0 }));
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2657
2730
|
// ── REST: stats summary ──
|
|
2658
2731
|
if (url.pathname === "/api/stats") {
|
|
2659
2732
|
const taskStatsParams: any[] = [];
|
package/src/tools.ts
CHANGED
|
@@ -2363,25 +2363,61 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2363
2363
|
...(meta && Object.keys(meta).length ? { meta } : {}),
|
|
2364
2364
|
};
|
|
2365
2365
|
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2366
|
+
// #1459 ① P2 —— 审计与收件持久化必须同生共死:只落其中一半,就会出现
|
|
2367
|
+
// 「审计说发过、而用户永远收不到」或者反过来。放进同一个事务。
|
|
2368
|
+
db.transaction(() => {
|
|
2369
|
+
db.run(
|
|
2370
|
+
`INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
|
|
2371
|
+
VALUES (?1, ?2, 'send_desktop_message', 'user', ?3, ?4, ?5)`,
|
|
2372
|
+
[
|
|
2373
|
+
enforceUserId || null,
|
|
2374
|
+
callerAlias || null,
|
|
2375
|
+
target.user_id,
|
|
2376
|
+
JSON.stringify({
|
|
2377
|
+
message_id: messageId,
|
|
2378
|
+
to_username: target.username,
|
|
2379
|
+
from: from_session,
|
|
2380
|
+
severity,
|
|
2381
|
+
kind,
|
|
2382
|
+
title: title || null,
|
|
2383
|
+
meta_keys: meta && typeof meta === "object" ? Object.keys(meta) : [],
|
|
2384
|
+
}),
|
|
2385
|
+
effectiveNetId,
|
|
2386
|
+
],
|
|
2387
|
+
);
|
|
2388
|
+
// 🔴 `ON CONFLICT(message_id) DO NOTHING`,而不是 `INSERT OR IGNORE`:
|
|
2389
|
+
// 两者对"重投同一个 message_id"效果相同(幂等,不产生重复消息),
|
|
2390
|
+
// 但 OR IGNORE 会**连同其它约束错误一起吞掉** —— 我在实现本段时就
|
|
2391
|
+
// 撞上了:`kind` 为 NULL 触发 NOT NULL,OR IGNORE 静默丢弃整行,
|
|
2392
|
+
// 而工具照样返回 `persisted: true`。**一条消息凭空消失且报告成功**,
|
|
2393
|
+
// 正是本 issue 要消灭的那个形状。
|
|
2394
|
+
// DO NOTHING 只针对主键冲突,别的约束照常抛出、事务回滚。
|
|
2395
|
+
//
|
|
2396
|
+
// 另:普通 INSERT 会让重试抛主键冲突(无害重试变 500);
|
|
2397
|
+
// OR REPLACE 会覆盖旧行、把用户已 ack 的状态冲掉。都不取。
|
|
2398
|
+
db.run(
|
|
2399
|
+
`INSERT INTO user_inbox
|
|
2400
|
+
(message_id, network_id, user_id, from_session, kind, title, content, severity, meta_json)
|
|
2401
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
|
2402
|
+
ON CONFLICT(message_id) DO NOTHING`,
|
|
2403
|
+
[
|
|
2404
|
+
messageId,
|
|
2405
|
+
effectiveNetId ?? null,
|
|
2406
|
+
target.user_id,
|
|
2407
|
+
from_session,
|
|
2408
|
+
// 🔴 不依赖 zod 的 .default():直接调用 handler 的调用方(测试、
|
|
2409
|
+
// 未来的内部调用)拿不到 zod 默认值,会把 NULL 写进 NOT NULL 列。
|
|
2410
|
+
// 列上虽有 DEFAULT,但**显式传 NULL 会覆盖列默认值**。
|
|
2411
|
+
kind ?? "agent_message",
|
|
2412
|
+
title || null,
|
|
2413
|
+
message,
|
|
2414
|
+
severity ?? "info",
|
|
2415
|
+
// 存储侧走与 inbox 一致的跨主机脱敏;读取侧另有一道 token 形状
|
|
2416
|
+
// 脱敏(规格 ③ redact-at-read),两层分别防不同的东西。
|
|
2417
|
+
meta && typeof meta === "object" ? normalizeMetaJson(meta) : null,
|
|
2418
|
+
],
|
|
2419
|
+
);
|
|
2420
|
+
});
|
|
2385
2421
|
// #1459 — pushUserEvent 在没有订阅者时静默 return,而这个工具既不写
|
|
2386
2422
|
// inbox、也没有任何 user 级回读路径(/api/messages 只 SELECT FROM inbox)。
|
|
2387
2423
|
// 也就是说用户 dashboard 关着的时候这条消息就永久没了。
|
|
@@ -2395,6 +2431,8 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2395
2431
|
// 变成「已入库、等重连补投」。把值取成 no_live_subscriber 而不是
|
|
2396
2432
|
// "lost"/"dropped",就是为了那天不用改写历史含义 —— 届时按需细分
|
|
2397
2433
|
// (如 lost vs queued)即可,现在不预先把语义焊死。
|
|
2434
|
+
// 🔴 push 必须在事务**提交之后**:放进事务里的话,活订阅者可能先收到
|
|
2435
|
+
// 事件、而行还没提交;一旦回滚,用户就看到了一条数据库里不存在的消息。
|
|
2398
2436
|
const delivered = hasUserSubscribers(effectiveNetId, target.user_id);
|
|
2399
2437
|
pushUserEvent(effectiveNetId, target.user_id, event);
|
|
2400
2438
|
|
|
@@ -2402,6 +2440,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
2402
2440
|
ok: true,
|
|
2403
2441
|
message_id: messageId,
|
|
2404
2442
|
delivered,
|
|
2443
|
+
// #1459 ① P2 —— 有了持久化之后,「此刻没人在听」不再等于「丢了」。
|
|
2444
|
+
// `delivered` 说的是实时投递、`persisted` 说的是它已经入库等重连补投。
|
|
2445
|
+
// `reason` 仍报**观测到的事实**而不是它的后果,所以这个中间态没有说错话。
|
|
2446
|
+
persisted: true,
|
|
2405
2447
|
...(delivered ? {} : { reason: "no_live_subscriber" }),
|
|
2406
2448
|
delivered_to_user_id: target.user_id,
|
|
2407
2449
|
network_id: effectiveNetId,
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// #1459 ① P3 —— per-user 收件回读 + ack + 未读数 + redact-at-read。
|
|
2
|
+
//
|
|
3
|
+
// 承重的是**隔离**:A 只能读到 A 的。所以下面每条隔离断言都额外钉住探针身份 ——
|
|
4
|
+
// 首个注册用户会自动成为全局 admin,而 admin 在别处走的是不过滤的分支;
|
|
5
|
+
// 若探针恰好是 admin,隔离断言会"通过但什么都没证明"(#500/#506 同款)。
|
|
6
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
7
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-user-inbox-read-"));
|
|
12
|
+
process.env.COMMHUB_DB ||= join(PRIVATE_DB_DIR, "hub.db");
|
|
13
|
+
|
|
14
|
+
let server: ReturnType<typeof Bun.serve>;
|
|
15
|
+
let base = "";
|
|
16
|
+
let adminToken = "", aToken = "", bToken = "";
|
|
17
|
+
const MASTER_TOKEN = "legacy-master-token-for-test";
|
|
18
|
+
let aUserId = "", bUserId = "", aNet = "", bNet = "";
|
|
19
|
+
let adminUserId = "";
|
|
20
|
+
|
|
21
|
+
const get = async (path: string, token: string) => {
|
|
22
|
+
const r = await fetch(`${base}${path}`, { headers: { Authorization: `Bearer ${token}` } });
|
|
23
|
+
return { status: r.status, body: await r.json() as any };
|
|
24
|
+
};
|
|
25
|
+
const post = async (path: string, token: string, body: unknown) => {
|
|
26
|
+
const r = await fetch(`${base}${path}`, {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
29
|
+
body: JSON.stringify(body),
|
|
30
|
+
});
|
|
31
|
+
return { status: r.status, body: await r.json() as any };
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function seedMessage(mid: string, userId: string, netId: string, content: string, meta?: string, acked = 0) {
|
|
35
|
+
const { db } = require("./db.js");
|
|
36
|
+
db.run(
|
|
37
|
+
`INSERT INTO user_inbox (message_id, network_id, user_id, from_session, kind, title, content, severity, meta_json, acked, acked_at)
|
|
38
|
+
VALUES (?1, ?2, ?3, 'node-x', 'agent_message', NULL, ?4, 'info', ?5, ?6, ?7)`,
|
|
39
|
+
[mid, netId, userId, content, meta ?? null, acked, acked ? "2026-01-01 00:00:00" : null],
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
beforeAll(async () => {
|
|
44
|
+
// 传统全局 master token:requireAuth 对 GET /api/* 放行,但 resolveRequestAuth
|
|
45
|
+
// 返回 null(没有 user)。这条路径是分支内 `if (!callerUserId)` 唯一能被触发的
|
|
46
|
+
// 场景 —— 必须在 import server.js 之前设,AUTH_TOKEN 是模块加载期读的常量。
|
|
47
|
+
process.env.COMMHUB_AUTH_TOKEN ||= MASTER_TOKEN;
|
|
48
|
+
|
|
49
|
+
const { db } = await import("./db.js");
|
|
50
|
+
const { register, addNetworkMember } = await import("./auth.js");
|
|
51
|
+
const stamp = Date.now();
|
|
52
|
+
|
|
53
|
+
// 首个注册用户 = 全局 admin。**故意先注册一个用不到的 admin**,这样下面
|
|
54
|
+
// 两个探针 A/B 都不是 admin —— 隔离断言才有分辨力。
|
|
55
|
+
const admin = register(`ui_admin_${stamp}`, "UiAdmin-Strong-1!");
|
|
56
|
+
expect(admin.ok).toBe(true);
|
|
57
|
+
adminToken = admin.token!;
|
|
58
|
+
adminUserId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", `ui_admin_${stamp}`)!.user_id;
|
|
59
|
+
|
|
60
|
+
const a = register(`ui_a_${stamp}`, "UiA-Strong-1!");
|
|
61
|
+
const b = register(`ui_b_${stamp}`, "UiB-Strong-1!");
|
|
62
|
+
expect(a.ok && b.ok).toBe(true);
|
|
63
|
+
aToken = a.token!; bToken = b.token!;
|
|
64
|
+
aNet = a.network_id!; bNet = b.network_id!;
|
|
65
|
+
aUserId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", `ui_a_${stamp}`)!.user_id;
|
|
66
|
+
bUserId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", `ui_b_${stamp}`)!.user_id;
|
|
67
|
+
|
|
68
|
+
// 前提断言:探针不是 admin。靠"注册顺序"是隐式前提,会随夹具变化悄悄失效,
|
|
69
|
+
// 所以显式断言。
|
|
70
|
+
// 承重的是 role ≠ "admin";这里钉死具体值(auth.ts:88 非首用户写 "user"),
|
|
71
|
+
// 因为 `not.toBe("admin")` 在 role 为 undefined(查不到行)时也会通过 —— 松判据
|
|
72
|
+
// 会朝"没问题"那边失败。
|
|
73
|
+
for (const [name, id] of [["A", aUserId], ["B", bUserId]] as const) {
|
|
74
|
+
const role = db.get<{ role: string }>("SELECT role FROM users WHERE user_id = ?1", id)?.role;
|
|
75
|
+
expect([name, role]).toEqual([name, "user"]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 🔴 B 必须在**同一个 network** 里也有一条私信。
|
|
79
|
+
// 第一版夹具让 A/B 各处一网,于是 addNetworkScope 顺手挡住了 B 的行 ——
|
|
80
|
+
// 变异掉 `WHERE user_id = ?1` 后隔离断言照样通过(M1/M2/M4 全部存活)。
|
|
81
|
+
// 同网同事之间,user_id 过滤是**唯一**的隔离手段,夹具必须落在那一格。
|
|
82
|
+
expect(addNetworkMember(aNet, bUserId, "member").ok).toBe(true);
|
|
83
|
+
|
|
84
|
+
seedMessage("dm_a1", aUserId, aNet, "A 的第一条");
|
|
85
|
+
seedMessage("dm_a2", aUserId, aNet, "A 的第二条");
|
|
86
|
+
seedMessage("dm_b_same_net", bUserId, aNet, "B 在 A 同网里的私信");
|
|
87
|
+
seedMessage("dm_b1", bUserId, bNet, "B 的私信");
|
|
88
|
+
// A 自己的、但落在 A 不是成员的 network 上的行(例如 A 被移出该网后的遗留)。
|
|
89
|
+
// 它是 addNetworkScope 这次调用的唯一 witnessed-red 对象:user_id 过滤对它无效。
|
|
90
|
+
seedMessage("dm_a_foreign_net", aUserId, bNet, "A 在外网的遗留私信");
|
|
91
|
+
// network_id 为 NULL 的行。**写路径产不出这种行** —— send_desktop_message 有
|
|
92
|
+
// 三道闸(canWrite / `if (!effectiveNetId)` / targetRole),拆掉任意一道都还有
|
|
93
|
+
// 别的挡着,见 desktop-message-seam.test.ts 的两条孤儿断言。这里是手写夹具,
|
|
94
|
+
// 用来钉「万一库里有这种行(历史数据、将来的第二个写入方),谁读得到」。
|
|
95
|
+
seedMessage("dm_a_nonet", aUserId, null as any, "A 的无网络私信");
|
|
96
|
+
seedMessage("dm_admin_nonet", adminUserId, null as any, "admin 的无网络私信");
|
|
97
|
+
seedMessage("dm_a_read", aUserId, aNet, "A 已读的一条", undefined, 1);
|
|
98
|
+
seedMessage("dm_a_secret", aUserId, aNet,
|
|
99
|
+
"凭据在正文里 ntok_abcdef123456", JSON.stringify({ k: "ghp_" + "A".repeat(24) }));
|
|
100
|
+
|
|
101
|
+
const mod = await import("./server.js");
|
|
102
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
103
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
afterAll(() => {
|
|
107
|
+
try { server?.stop(true); } catch {}
|
|
108
|
+
try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("#1459 ① P3 GET /api/messages?scope=user", () => {
|
|
112
|
+
test("🔴 只返回调用者自己的消息 —— A 读不到 B 的", async () => {
|
|
113
|
+
const { status, body } = await get("/api/messages?scope=user", aToken);
|
|
114
|
+
expect(status).toBe(200);
|
|
115
|
+
expect(body.ok).toBe(true);
|
|
116
|
+
const ids = body.messages.map((m: any) => m.message_id);
|
|
117
|
+
expect(ids).toContain("dm_a1");
|
|
118
|
+
expect(ids).toContain("dm_a_read"); // 不加 unacked 时已读也要回来
|
|
119
|
+
expect(ids).not.toContain("dm_b_same_net"); // 承重:同网,只有 user_id 过滤挡得住
|
|
120
|
+
expect(ids).not.toContain("dm_b1"); // 跨网,scope 助手挡
|
|
121
|
+
expect(ids).not.toContain("dm_a_foreign_net"); // 承重:A 自己的行,只有 scope 助手挡得住
|
|
122
|
+
expect(body.messages.every((m: any) => m.user_id === aUserId)).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("🔴 收件人取自 auth ctx —— query 里指定别人的 user_id 无效", async () => {
|
|
126
|
+
const { body } = await get(`/api/messages?scope=user&user_id=${bUserId}`, aToken);
|
|
127
|
+
const ids = body.messages.map((m: any) => m.message_id);
|
|
128
|
+
expect(ids).not.toContain("dm_b_same_net");
|
|
129
|
+
expect(ids).not.toContain("dm_b1");
|
|
130
|
+
expect(body.messages.every((m: any) => m.user_id === aUserId)).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("🔴 redact-at-read:正文与 meta 里的凭据形状被遮住", async () => {
|
|
134
|
+
const { body } = await get("/api/messages?scope=user", aToken);
|
|
135
|
+
const row = body.messages.find((m: any) => m.message_id === "dm_a_secret");
|
|
136
|
+
expect(row).toBeTruthy();
|
|
137
|
+
expect(row.content).toContain("ntok_***redacted***");
|
|
138
|
+
expect(row.content).not.toContain("ntok_abcdef123456");
|
|
139
|
+
expect(row.meta_json).toContain("ghp_***redacted***");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// NULL 网络行的可见性**取决于调用者的 scope,不取决于行本身**:
|
|
143
|
+
// 受网络限制的成员 → addNetworkScope 生成 `network_id IN (...)`,NULL 不匹配 ⇒ 读不到
|
|
144
|
+
// admin(resolveRestNetworkScope 给 networkIds=null,助手是 no-op) ⇒ 读得到
|
|
145
|
+
// 两条一起才说明"NULL 读不到"是 scope 造成的,不是 user_id 过滤造成的。
|
|
146
|
+
test("NULL 网络行:受网络限制的成员读不到", async () => {
|
|
147
|
+
const { body } = await get("/api/messages?scope=user", aToken);
|
|
148
|
+
expect(body.messages.map((m: any) => m.message_id)).not.toContain("dm_a_nonet");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("NULL 网络行:scope 为 no-op 的调用者(admin)读得到自己的那条", async () => {
|
|
152
|
+
const { body } = await get("/api/messages?scope=user", adminToken);
|
|
153
|
+
const ids = body.messages.map((m: any) => m.message_id);
|
|
154
|
+
expect(ids).toContain("dm_admin_nonet");
|
|
155
|
+
expect(ids).not.toContain("dm_a1"); // 仍然只读自己的:admin 不绕过 user_id 过滤
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("unread 是未读数而非总数(A:4 条里 3 条未读)", async () => {
|
|
159
|
+
const { body } = await get("/api/messages?scope=user", aToken);
|
|
160
|
+
expect(body.messages.length).toBe(4);
|
|
161
|
+
expect(body.unread).toBe(3);
|
|
162
|
+
expect(body.pending_count).toBe(body.unread);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("unacked=1 排掉已读那条", async () => {
|
|
166
|
+
const { body } = await get("/api/messages?scope=user&unacked=1", aToken);
|
|
167
|
+
const ids = body.messages.map((m: any) => m.message_id);
|
|
168
|
+
expect(ids).not.toContain("dm_a_read"); // 承重:集合里确实有已读行才有分辨力
|
|
169
|
+
expect(ids.sort()).toEqual(["dm_a1", "dm_a2", "dm_a_secret"]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("未鉴权 ⇒ 401(/api 全局闸)", async () => {
|
|
173
|
+
const r = await fetch(`${base}/api/messages?scope=user`);
|
|
174
|
+
expect(r.status).toBe(401);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("🔴 legacy master token 过得了全局闸但没有 user ⇒ 401,不能回一个空列表", async () => {
|
|
178
|
+
// 这条才是分支内 `if (!callerUserId)` 的 witnessed-red:把它换成
|
|
179
|
+
// `?? ""` 后本条从 401 变成 200 + 空列表 —— "看起来正常"的错误答案。
|
|
180
|
+
const { status, body } = await get("/api/messages?scope=user", MASTER_TOKEN);
|
|
181
|
+
expect([status, body.error]).toEqual([401, "auth_required"]);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("alias 分支未受影响(dashboard 依赖它)", async () => {
|
|
185
|
+
const { status, body } = await get("/api/messages", adminToken);
|
|
186
|
+
expect(status).toBe(200);
|
|
187
|
+
expect(body.ok).toBe(true);
|
|
188
|
+
expect(Array.isArray(body.messages)).toBe(true);
|
|
189
|
+
expect(body.unread).toBeUndefined(); // 老分支不该多出新字段
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("#1459 ① P3 POST /api/messages/ack", () => {
|
|
194
|
+
test("ack 自己的消息 ⇒ acked 置位、未读数下降", async () => {
|
|
195
|
+
const before = (await get("/api/messages?scope=user", aToken)).body.unread;
|
|
196
|
+
const { status, body } = await post("/api/messages/ack", aToken, { message_id: "dm_a1" });
|
|
197
|
+
expect(status).toBe(200);
|
|
198
|
+
expect(body.acked).toBe(1);
|
|
199
|
+
const after = (await get("/api/messages?scope=user", aToken)).body.unread;
|
|
200
|
+
expect(after).toBe(before - 1);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("🔴 ack 不了同网同事的消息 —— 改动行数为 0,且对方仍是未读", async () => {
|
|
204
|
+
const { body } = await post("/api/messages/ack", aToken, { message_id: "dm_b_same_net" });
|
|
205
|
+
expect(body.acked).toBe(0);
|
|
206
|
+
const bView = (await get("/api/messages?scope=user", bToken)).body;
|
|
207
|
+
expect(bView.messages.find((m: any) => m.message_id === "dm_b_same_net").acked).toBe(0);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("ack 跨网的消息同样为 0", async () => {
|
|
211
|
+
const { body } = await post("/api/messages/ack", aToken, { message_id: "dm_b1" });
|
|
212
|
+
expect(body.acked).toBe(0);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("缺参 ⇒ 400;未鉴权 ⇒ 401", async () => {
|
|
216
|
+
expect((await post("/api/messages/ack", aToken, {})).status).toBe(400);
|
|
217
|
+
const r = await fetch(`${base}/api/messages/ack`, { method: "POST", body: "{}" });
|
|
218
|
+
expect(r.status).toBe(401);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// #1459 ① — user_inbox 建表 + 幂等打底(P1)。零行为变化,只落 schema。
|
|
2
|
+
//
|
|
3
|
+
// 承重属性(SDK马 review 要点):message_id 即主键 → send_desktop_message 因 HTTP
|
|
4
|
+
// 响应丢失被重试时,同一 dm_<uuid> 用 INSERT OR IGNORE 不会插出重复行(前端免去重)。
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import { db } from "./db.js";
|
|
8
|
+
|
|
9
|
+
describe("#1459 user_inbox schema (P1)", () => {
|
|
10
|
+
test("table exists with the expected columns", () => {
|
|
11
|
+
const cols = db.all<{ name: string }>("PRAGMA table_info(user_inbox)").map(c => c.name);
|
|
12
|
+
for (const c of ["message_id", "network_id", "user_id", "from_session", "kind", "title", "content", "severity", "meta_json", "acked", "created_at", "acked_at"]) {
|
|
13
|
+
expect(cols).toContain(c);
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// 注:这条钉的是「主键在我们以为的那一列」(SQLite 行为),**不是**「写入路径幂等」——
|
|
18
|
+
// 后者要 P2 驱动真实 send_desktop_message 两次来证(SDK马 review)。这里只保证主键选对。
|
|
19
|
+
test("message_id is PRIMARY KEY → INSERT OR IGNORE dedups on the same id", () => {
|
|
20
|
+
const mid = `dm_test_${Date.now()}`;
|
|
21
|
+
try {
|
|
22
|
+
const ins = () => db.run(
|
|
23
|
+
`INSERT OR IGNORE INTO user_inbox (message_id, network_id, user_id, from_session, content)
|
|
24
|
+
VALUES (?1, ?2, ?3, ?4, ?5)`,
|
|
25
|
+
[mid, "net_test_1459", "u_test_1459", "sender-a", "hello"],
|
|
26
|
+
);
|
|
27
|
+
ins();
|
|
28
|
+
ins(); // 同 message_id 第二次插入应被 IGNORE
|
|
29
|
+
const n = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM user_inbox WHERE message_id = ?1", mid)?.n ?? 0;
|
|
30
|
+
expect(n).toBe(1);
|
|
31
|
+
} finally {
|
|
32
|
+
db.run("DELETE FROM user_inbox WHERE message_id = ?1", mid);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("defaults: acked=0, kind/severity='info'", () => {
|
|
37
|
+
const mid = `dm_def_${Date.now()}`;
|
|
38
|
+
try {
|
|
39
|
+
db.run(
|
|
40
|
+
`INSERT INTO user_inbox (message_id, user_id, content) VALUES (?1, ?2, ?3)`,
|
|
41
|
+
[mid, "u_def_1459", "body"],
|
|
42
|
+
);
|
|
43
|
+
const row = db.get<{ acked: number; kind: string; severity: string }>(
|
|
44
|
+
"SELECT acked, kind, severity FROM user_inbox WHERE message_id = ?1", mid,
|
|
45
|
+
);
|
|
46
|
+
expect(row?.acked).toBe(0);
|
|
47
|
+
expect(row?.kind).toBe("info");
|
|
48
|
+
expect(row?.severity).toBe("info");
|
|
49
|
+
} finally {
|
|
50
|
+
db.run("DELETE FROM user_inbox WHERE message_id = ?1", mid);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
});
|