@sleep2agi/commhub-server 0.9.0-preview.93 → 0.9.0-preview.94
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/auth.ts +2 -2
- package/src/department-groups.ts +83 -7
- package/src/departments.ts +6 -6
- package/src/group-membership-events-http.test.ts +264 -0
- package/src/node-identity-mismatch.test.ts +74 -0
- package/src/requirement-due-reminders-http.test.ts +390 -0
- package/src/requirement-due-reminders.ts +310 -0
- package/src/requirements-errors.ts +2 -0
- package/src/requirements.ts +16 -1
- package/src/server.ts +7 -2
- package/src/tools.ts +28 -5
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.94",
|
|
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/auth.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* V3 Auth module — user registration, login, token management
|
|
3
3
|
*/
|
|
4
|
-
import { db, generateId, hashPassword, verifyPassword, hashToken, generateToken, generateUserToken, generateNetworkToken, uuidv4 } from "./db.js"; import { deleteDepartmentsForNetwork, removeMemberFromChatGroups } from "./departments.js"; // #419: also ensures network_members.department_id exists (same line on purpose: docs pin auth.ts line numbers)
|
|
4
|
+
import { db, generateId, hashPassword, verifyPassword, hashToken, generateToken, generateUserToken, generateNetworkToken, uuidv4 } from "./db.js"; import { deleteDepartmentsForNetwork, groupTx, removeMemberFromChatGroups } from "./departments.js"; // #419: also ensures network_members.department_id exists (same line on purpose: docs pin auth.ts line numbers)
|
|
5
5
|
import { WEAK_PASSWORDS } from "./password-dict.js";
|
|
6
6
|
import { NETWORK_REST_COLUMNS, NETWORK_REST_SELECT, sqlColumns } from "./rest-projections.js";
|
|
7
7
|
import { deleteAgentGrants, isAgentRestricted } from "./agent-access.js"; import { deleteTaskGrantsForMember, NEW_MEMBER_TASK_ACCESS, type TaskAccessMode } from "./task-access.js"; // 一行两个 import:文档钉着 auth.ts 的行号
|
|
@@ -682,7 +682,7 @@ export function removeNetworkMember(networkId: string, userId: string): { ok: bo
|
|
|
682
682
|
const member = db.get<any>("SELECT role FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId);
|
|
683
683
|
if (!member) return { ok: false, error: "not a member" };
|
|
684
684
|
if (member.role === "owner") return { ok: false, error: "cannot remove owner" };
|
|
685
|
-
|
|
685
|
+
groupTx(() => { // RFC-042 §9.3:同 db.transaction,另在提交后推 group_membership_changed(移出网络 → 他所在每个群)
|
|
686
686
|
db.run("DELETE FROM network_members WHERE network_id = ?1 AND user_id = ?2", [networkId, userId]);
|
|
687
687
|
// #488 —— 他在这个网络里的令牌(节点令牌 / 邀请码令牌)一起吊销;resolveToken 也会拒非成员的令牌,这里让库里的状态如实。
|
|
688
688
|
db.run("UPDATE api_tokens SET revoked_at = datetime('now') WHERE network_id = ?1 AND user_id = ?2 AND revoked_at IS NULL", [networkId, userId]);
|
package/src/department-groups.ts
CHANGED
|
@@ -17,9 +17,14 @@
|
|
|
17
17
|
// source='manual' 永远不动(手动拉的人后来进了部门也保持 manual —— 宁可多留一个人,不静默踢人)。
|
|
18
18
|
// 调人 / 建部门(带负责人)/ 改上级 / 换负责人 / 删部门 都在各自的事务里调它一次;移出网络删他在本网络的全部群成员行。
|
|
19
19
|
// - 读一个挂部门的群时顺手对账一次(兜底:修掉第 1 个 PR 上线到第 2 个 PR 上线之间的漂移)。
|
|
20
|
+
//
|
|
21
|
+
// RFC-042 §9.3 补口:入群 / 退群实时事件 group_membership_changed。成员行在事务里变,事件只在**最外层事务提交之后**推:
|
|
22
|
+
// 写路径一律走 groupTx()(代替 db.transaction),事务里每一处增删成员行都 recordMembershipChange() 记一笔;
|
|
23
|
+
// groupTx 正常返回且已回到最外层 → flush;抛错(整个回滚,或内层 savepoint 回滚)→ 丢掉这一层记下的那些。
|
|
20
24
|
|
|
21
25
|
import { db, uuidv4 } from "./db.js";
|
|
22
26
|
import { departmentSubtree, membersIn } from "./department-heads.js";
|
|
27
|
+
import { pushUserEvent } from "./push.js";
|
|
23
28
|
|
|
24
29
|
db.exec(`
|
|
25
30
|
CREATE TABLE IF NOT EXISTS chat_groups (
|
|
@@ -70,6 +75,66 @@ db.exec(`
|
|
|
70
75
|
|
|
71
76
|
export const GROUP_SOURCE_DEPARTMENT = "department";
|
|
72
77
|
export const GROUP_SOURCE_MANUAL = "manual";
|
|
78
|
+
|
|
79
|
+
// ── 入群 / 退群事件(RFC-042 §9.3 补口)──
|
|
80
|
+
|
|
81
|
+
export const GROUP_MEMBERSHIP_EVENT = "group_membership_changed";
|
|
82
|
+
/** 事件里的 source = 这次变化的起因:manual(手动拉 / 移)、department(部门同步,含建群播种)、network_removal(移出网络)。 */
|
|
83
|
+
export type MembershipChangeSource = "manual" | "department" | "network_removal";
|
|
84
|
+
export type MembershipChange = { network_id: string; group_id: string; user_id: string; change: "added" | "removed"; source: MembershipChangeSource; at: string };
|
|
85
|
+
|
|
86
|
+
let txDepth = 0;
|
|
87
|
+
let pending: MembershipChange[] = [];
|
|
88
|
+
|
|
89
|
+
/** 事务里记一笔成员变化。不在 groupTx 里(单条自动提交的写)→ 这条语句已经提交了,立刻推。 */
|
|
90
|
+
function recordMembershipChange(c: Omit<MembershipChange, "at">): void {
|
|
91
|
+
const change = { ...c, at: new Date().toISOString() };
|
|
92
|
+
if (txDepth > 0) pending.push(change);
|
|
93
|
+
else flushMembershipChanges([change]);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 代替 db.transaction:可嵌套(内层是 savepoint)。内层抛错 → 只丢掉内层记下的变化(内层 savepoint 回滚了,外层可能照常提交);
|
|
98
|
+
* 最外层正常返回(= 已提交)→ 推;最外层抛错(= 回滚)→ 一条不推。
|
|
99
|
+
*/
|
|
100
|
+
export function groupTx<T>(fn: () => T): T {
|
|
101
|
+
const mark = pending.length;
|
|
102
|
+
txDepth++;
|
|
103
|
+
let ok = false;
|
|
104
|
+
try {
|
|
105
|
+
const out = db.transaction(fn);
|
|
106
|
+
ok = true;
|
|
107
|
+
return out;
|
|
108
|
+
} finally {
|
|
109
|
+
txDepth--;
|
|
110
|
+
if (!ok) pending.length = mark;
|
|
111
|
+
else if (txDepth === 0) {
|
|
112
|
+
const batch = pending;
|
|
113
|
+
pending = [];
|
|
114
|
+
flushMembershipChanges(batch);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 推 group_membership_changed:每一笔发给「被加 / 被移的那个人」和「提交后这个群的当前成员」。
|
|
121
|
+
* pushUserEvent 的信封里 user_id 是**收件人**(所有用户事件同一口径),所以被加 / 被移的人放在 member_user_id。
|
|
122
|
+
*/
|
|
123
|
+
function flushMembershipChanges(batch: MembershipChange[]): void {
|
|
124
|
+
if (!batch.length) return;
|
|
125
|
+
const membersOf = new Map<string, string[]>();
|
|
126
|
+
for (const c of batch) {
|
|
127
|
+
let current = membersOf.get(c.group_id);
|
|
128
|
+
if (!current) {
|
|
129
|
+
current = db.all<{ user_id: string }>("SELECT user_id FROM chat_group_members WHERE group_id = ?1", c.group_id).map((r) => r.user_id);
|
|
130
|
+
membersOf.set(c.group_id, current);
|
|
131
|
+
}
|
|
132
|
+
const event = { type: GROUP_MEMBERSHIP_EVENT, group_id: c.group_id, member_user_id: c.user_id, change: c.change, source: c.source, at: c.at };
|
|
133
|
+
for (const uid of new Set([c.user_id, ...current])) {
|
|
134
|
+
try { pushUserEvent(c.network_id, uid, event); } catch {}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
73
138
|
const NAME_MAX = 40;
|
|
74
139
|
|
|
75
140
|
type GroupRow = { group_id: string; network_id: string; name: string; department_id: string | null; created_by: string | null; created_at: string; updated_at: string };
|
|
@@ -153,7 +218,7 @@ export function createDepartmentGroup(networkId: string, departmentId: string, a
|
|
|
153
218
|
const groupId = `grp_${uuidv4().replace(/-/g, "").slice(0, 20)}`;
|
|
154
219
|
const roster = departmentGroupRoster(networkId, departmentId);
|
|
155
220
|
try {
|
|
156
|
-
|
|
221
|
+
groupTx(() => {
|
|
157
222
|
db.run(
|
|
158
223
|
"INSERT INTO chat_groups (group_id, network_id, name, department_id, created_by) VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
159
224
|
[groupId, networkId, name, departmentId, actorUserId],
|
|
@@ -163,6 +228,7 @@ export function createDepartmentGroup(networkId: string, departmentId: string, a
|
|
|
163
228
|
"INSERT INTO chat_group_members (group_id, user_id, network_id, source) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(group_id, user_id) DO NOTHING",
|
|
164
229
|
[groupId, uid, networkId, GROUP_SOURCE_DEPARTMENT],
|
|
165
230
|
);
|
|
231
|
+
recordMembershipChange({ network_id: networkId, group_id: groupId, user_id: uid, change: "added", source: "department" });
|
|
166
232
|
}
|
|
167
233
|
});
|
|
168
234
|
} catch (err) {
|
|
@@ -212,7 +278,7 @@ export function listVisibleGroups(networkId: string, userId: string | null, canM
|
|
|
212
278
|
export function readGroup(networkId: string, groupId: string, userId: string | null, canManage: boolean, managedDepartments: ReadonlySet<string> = new Set()): { group: ChatGroup & { viewer_can: GroupViewerCan }; members: ChatGroupMember[]; is_member: boolean } | null {
|
|
213
279
|
const r = groupRow(networkId, groupId);
|
|
214
280
|
if (!r) return null;
|
|
215
|
-
if (r.department_id !== null)
|
|
281
|
+
if (r.department_id !== null) groupTx(() => { syncDepartmentGroups(networkId, groupId); });
|
|
216
282
|
const member = !!userId && isGroupMember(groupId, userId);
|
|
217
283
|
if (!member && !canManageGroup(r, canManage, managedDepartments)) return null;
|
|
218
284
|
return { group: { ...toPublic(r), viewer_can: groupViewerCan(r, member, canManage, managedDepartments) }, members: listGroupMembers(groupId), is_member: member };
|
|
@@ -291,11 +357,13 @@ export function syncDepartmentGroups(networkId: string, onlyGroupId?: string): S
|
|
|
291
357
|
"INSERT INTO chat_group_members (group_id, user_id, network_id, source) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(group_id, user_id) DO NOTHING",
|
|
292
358
|
[g.group_id, uid, networkId, GROUP_SOURCE_DEPARTMENT],
|
|
293
359
|
);
|
|
360
|
+
recordMembershipChange({ network_id: networkId, group_id: g.group_id, user_id: uid, change: "added", source: "department" });
|
|
294
361
|
added++;
|
|
295
362
|
}
|
|
296
363
|
for (const r of current) {
|
|
297
364
|
if (r.source !== GROUP_SOURCE_DEPARTMENT || roster.has(r.user_id)) continue;
|
|
298
365
|
db.run("DELETE FROM chat_group_members WHERE group_id = ?1 AND user_id = ?2 AND source = ?3", [g.group_id, r.user_id, GROUP_SOURCE_DEPARTMENT]);
|
|
366
|
+
recordMembershipChange({ network_id: networkId, group_id: g.group_id, user_id: r.user_id, change: "removed", source: "department" });
|
|
299
367
|
removed++;
|
|
300
368
|
}
|
|
301
369
|
}
|
|
@@ -304,7 +372,9 @@ export function syncDepartmentGroups(networkId: string, onlyGroupId?: string): S
|
|
|
304
372
|
|
|
305
373
|
/** 移出网络时调用(和删 network_members 同一事务):他在本网络所有群里的行都删,不论来源。 */
|
|
306
374
|
export function removeMemberFromChatGroups(networkId: string, userId: string): void {
|
|
375
|
+
const groups = db.all<{ group_id: string }>("SELECT group_id FROM chat_group_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId);
|
|
307
376
|
db.run("DELETE FROM chat_group_members WHERE network_id = ?1 AND user_id = ?2", [networkId, userId]);
|
|
377
|
+
for (const g of groups) recordMembershipChange({ network_id: networkId, group_id: g.group_id, user_id: userId, change: "removed", source: "network_removal" });
|
|
308
378
|
}
|
|
309
379
|
|
|
310
380
|
/**
|
|
@@ -335,10 +405,13 @@ export function addManualMember(networkId: string, groupId: string, userId: unkn
|
|
|
335
405
|
if (typeof userId !== "string" || !userId) return fail(400, "user_id_required");
|
|
336
406
|
if (!db.get("SELECT 1 AS x FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId)) return fail(400, "not_network_member");
|
|
337
407
|
if (isGroupMember(groupId, userId)) return fail(409, "already_group_member");
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
408
|
+
groupTx(() => {
|
|
409
|
+
const r = db.run(
|
|
410
|
+
"INSERT INTO chat_group_members (group_id, user_id, network_id, source) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(group_id, user_id) DO NOTHING",
|
|
411
|
+
[groupId, userId, networkId, GROUP_SOURCE_MANUAL],
|
|
412
|
+
);
|
|
413
|
+
if (r.changes > 0) recordMembershipChange({ network_id: networkId, group_id: groupId, user_id: userId, change: "added", source: "manual" });
|
|
414
|
+
});
|
|
342
415
|
const member = toMember(db.get<MemberRow>(`${MEMBER_SELECT} WHERE m.group_id = ?1 AND m.user_id = ?2`, groupId, userId)!);
|
|
343
416
|
return { ok: true, member };
|
|
344
417
|
}
|
|
@@ -353,6 +426,9 @@ export function removeManualMember(networkId: string, groupId: string, userId: s
|
|
|
353
426
|
const row = db.get<{ source: string }>("SELECT source FROM chat_group_members WHERE group_id = ?1 AND user_id = ?2", groupId, userId);
|
|
354
427
|
if (!row) return fail(404, "group_member_not_found");
|
|
355
428
|
if (row.source === GROUP_SOURCE_DEPARTMENT && group.department_id !== null) return fail(409, "department_member");
|
|
356
|
-
|
|
429
|
+
groupTx(() => {
|
|
430
|
+
const r = db.run("DELETE FROM chat_group_members WHERE group_id = ?1 AND user_id = ?2", [groupId, userId]);
|
|
431
|
+
if (r.changes > 0) recordMembershipChange({ network_id: networkId, group_id: groupId, user_id: userId, change: "removed", source: "manual" });
|
|
432
|
+
});
|
|
357
433
|
return { ok: true, user_id: userId };
|
|
358
434
|
}
|
package/src/departments.ts
CHANGED
|
@@ -12,10 +12,10 @@
|
|
|
12
12
|
|
|
13
13
|
import { db, logAudit } from "./db.js";
|
|
14
14
|
import { deleteDepartmentGrants, deleteDepartmentGrantsForNetwork } from "./department-heads.js";
|
|
15
|
-
import { deleteChatGroupsForNetwork, removeMemberFromChatGroups, syncDepartmentGroups, unlinkDepartmentGroup } from "./department-groups.js";
|
|
15
|
+
import { deleteChatGroupsForNetwork, groupTx, removeMemberFromChatGroups, syncDepartmentGroups, unlinkDepartmentGroup } from "./department-groups.js";
|
|
16
16
|
|
|
17
17
|
// auth.ts 的 removeNetworkMember 从这里拿(auth.ts 只 import departments.js 一行,文档钉着它的行号)。
|
|
18
|
-
export { removeMemberFromChatGroups };
|
|
18
|
+
export { groupTx, removeMemberFromChatGroups };
|
|
19
19
|
|
|
20
20
|
try { db.exec("ALTER TABLE network_members ADD COLUMN department_id TEXT"); } catch {}
|
|
21
21
|
db.exec(`
|
|
@@ -164,7 +164,7 @@ export function createDepartment(networkId: string, actor: string, body: Record<
|
|
|
164
164
|
...(parentId === null ? [networkId] : [networkId, parentId]),
|
|
165
165
|
)?.n ?? 0)
|
|
166
166
|
: body.sort as number;
|
|
167
|
-
|
|
167
|
+
groupTx(() => {
|
|
168
168
|
db.run(
|
|
169
169
|
"INSERT INTO network_departments (network_id, department_id, name, parent_id, leader_user_id, sort, created_by) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
|
170
170
|
[networkId, id, name, parentId, leader, sort, actor],
|
|
@@ -206,7 +206,7 @@ export function updateDepartment(networkId: string, id: string, body: Record<str
|
|
|
206
206
|
}
|
|
207
207
|
if (!touched) return fail(400, "empty_patch");
|
|
208
208
|
if ((name !== cur.name || parentId !== cur.parent_id) && siblingNameTaken(networkId, parentId, name, id)) return fail(409, "department_name_taken");
|
|
209
|
-
|
|
209
|
+
groupTx(() => {
|
|
210
210
|
db.run(
|
|
211
211
|
"UPDATE network_departments SET name = ?3, parent_id = ?4, leader_user_id = ?5, sort = ?6, updated_at = datetime('now') WHERE network_id = ?1 AND department_id = ?2",
|
|
212
212
|
[networkId, id, name, parentId, leader, sort],
|
|
@@ -224,7 +224,7 @@ export function deleteDepartment(networkId: string, id: string): Ok<{ deleted: s
|
|
|
224
224
|
const members = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM network_members WHERE network_id = ?1 AND department_id = ?2", networkId, id)?.n ?? 0;
|
|
225
225
|
if (children || members) return { ...fail(409, "department_not_empty"), children, members };
|
|
226
226
|
// RFC-040:部门的项目授权(network_department_project_grants)和部门同一事务删掉。
|
|
227
|
-
|
|
227
|
+
groupTx(() => {
|
|
228
228
|
db.run("DELETE FROM network_departments WHERE network_id = ?1 AND department_id = ?2", [networkId, id]);
|
|
229
229
|
deleteDepartmentGrants(networkId, id);
|
|
230
230
|
// RFC-042:部门群解除关联(群和成员、以后的聊天记录都留着),不删。
|
|
@@ -240,7 +240,7 @@ export function setMemberDepartment(networkId: string, userId: string, departmen
|
|
|
240
240
|
if (!isMember(networkId, userId)) return fail(404, "member_not_found");
|
|
241
241
|
const dept = departmentId === null || departmentId === "" ? null : departmentId;
|
|
242
242
|
if (dept !== null && (typeof dept !== "string" || !one(networkId, dept))) return fail(400, "department_not_found");
|
|
243
|
-
|
|
243
|
+
groupTx(() => {
|
|
244
244
|
db.run("UPDATE network_members SET department_id = ?3 WHERE network_id = ?1 AND user_id = ?2", [networkId, userId, dept]);
|
|
245
245
|
// RFC-042 §4:调进 / 调出 → 同一事务对账部门群(manual 行不动)。
|
|
246
246
|
syncDepartmentGroups(networkId);
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// RFC-042 §9.3 补口(看板 #457):入群 / 退群实时事件 group_membership_changed。
|
|
2
|
+
// HTTP 集成测试(真实 Bun.serve,私有端口,临时库;PostgreSQL 由 test2123 梯子用 COMMHUB_TEST_PG_URL 跑同一个文件)。
|
|
3
|
+
// 钉住:手动拉人 / 移人、调进 / 调出部门、换负责人、移出网络 → 被加 / 被移的人和提交后的当前群成员各收到一条;
|
|
4
|
+
// 不相干的人收不到;事件只在提交之后推 —— 整个事务回滚一条不推,内层 savepoint 回滚只丢内层那几笔。
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
7
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
8
|
+
import { join } from "path";
|
|
9
|
+
import { tmpdir } from "os";
|
|
10
|
+
import { register } from "./auth.js";
|
|
11
|
+
import { db } from "./db.js";
|
|
12
|
+
import { groupTx, syncDepartmentGroups, isGroupMember } from "./department-groups.js";
|
|
13
|
+
|
|
14
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-group-membership-ev-"));
|
|
15
|
+
let BASE = "";
|
|
16
|
+
let hub: any = null;
|
|
17
|
+
const PW = "GroupMembershipEvPassw0rd!x";
|
|
18
|
+
let NET = "";
|
|
19
|
+
const U: Record<string, { token: string; id: string; username: string }> = {};
|
|
20
|
+
const DEPT: Record<string, string> = {};
|
|
21
|
+
const GRP: Record<string, string> = {};
|
|
22
|
+
const S: Record<string, { events: any[]; close: () => void }> = {};
|
|
23
|
+
|
|
24
|
+
async function send(token: string, method: string, path: string, body?: unknown) {
|
|
25
|
+
const res = await fetch(`${BASE}${path}`, { method, headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
|
|
26
|
+
const text = await res.text();
|
|
27
|
+
let json: any = null;
|
|
28
|
+
try { json = JSON.parse(text); } catch {}
|
|
29
|
+
return { status: res.status, body: json };
|
|
30
|
+
}
|
|
31
|
+
const CG = (suffix = "") => `/api/networks/${NET}/chat-groups${suffix}`;
|
|
32
|
+
const place = async (who: string, dept: string | null) =>
|
|
33
|
+
expect((await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U[who].id}/department`, { department_id: dept })).status).toBe(200);
|
|
34
|
+
|
|
35
|
+
async function subscribe(who: string) {
|
|
36
|
+
const ctrl = new AbortController();
|
|
37
|
+
const res = await fetch(`${BASE}/events/users/me?network_id=${NET}`, { headers: { Authorization: `Bearer ${U[who].token}` }, signal: ctrl.signal });
|
|
38
|
+
expect(res.status).toBe(200);
|
|
39
|
+
const events: any[] = [];
|
|
40
|
+
const reader = res.body!.getReader();
|
|
41
|
+
const dec = new TextDecoder();
|
|
42
|
+
let buf = "";
|
|
43
|
+
void (async () => {
|
|
44
|
+
try {
|
|
45
|
+
for (;;) {
|
|
46
|
+
const { done, value } = await reader.read();
|
|
47
|
+
if (done) break;
|
|
48
|
+
buf += dec.decode(value);
|
|
49
|
+
let i: number;
|
|
50
|
+
while ((i = buf.indexOf("\n\n")) >= 0) {
|
|
51
|
+
const frame = buf.slice(0, i); buf = buf.slice(i + 2);
|
|
52
|
+
for (const line of frame.split("\n")) if (line.startsWith("data: ")) { try { events.push(JSON.parse(line.slice(6))); } catch {} }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
} catch {}
|
|
56
|
+
})();
|
|
57
|
+
await until(() => events.some((e) => e.type === "connected"));
|
|
58
|
+
return { events, close: () => ctrl.abort() };
|
|
59
|
+
}
|
|
60
|
+
const until = async (cond: () => boolean, tries = 150) => { for (let k = 0; k < tries && !cond(); k++) await new Promise((r) => setTimeout(r, 20)); };
|
|
61
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
62
|
+
|
|
63
|
+
/** 只看成员事件;清空所有订阅者已收到的,便于每个用例从零数。 */
|
|
64
|
+
const mev = (who: string) => S[who].events.filter((e) => e.type === "group_membership_changed");
|
|
65
|
+
// 先等上一个用例里没被等的收件人(比如 hank)的事件落地,再清空 —— 否则它会串到下一个用例里。
|
|
66
|
+
const reset = async () => { await sleep(150); for (const k of Object.keys(S)) S[k].events.length = 0; };
|
|
67
|
+
const match = (who: string, group: string, member: string, change: string, source: string) =>
|
|
68
|
+
mev(who).filter((e) => e.group_id === GRP[group] && e.member_user_id === U[member].id && e.change === change && e.source === source);
|
|
69
|
+
|
|
70
|
+
beforeAll(async () => {
|
|
71
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
72
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
73
|
+
process.env.HOST = "127.0.0.1";
|
|
74
|
+
const stamp = Date.now();
|
|
75
|
+
const a = register(`gme_owner_${stamp}`, PW, undefined, "Owner");
|
|
76
|
+
expect(a.ok).toBe(true);
|
|
77
|
+
U.owner = { token: a.token!, id: a.user!.user_id, username: a.user!.username };
|
|
78
|
+
NET = a.network_id!;
|
|
79
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [U.owner.id]);
|
|
80
|
+
const mod: any = await import("./server.js");
|
|
81
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
82
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
83
|
+
const add = async (username: string, role: string) => {
|
|
84
|
+
expect((await send(U.owner.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role })).status).toBe(200);
|
|
85
|
+
const login = await send("", "POST", "/api/auth/login", { username, password: PW });
|
|
86
|
+
expect(login.status).toBe(200);
|
|
87
|
+
return { token: login.body.token as string, id: login.body.user.user_id as string, username };
|
|
88
|
+
};
|
|
89
|
+
U.alice = await add(`gme_alice_${stamp}`, "member"); // 研发部(旁观的当前成员)
|
|
90
|
+
U.carol = await add(`gme_carol_${stamp}`, "member"); // 研发部,之后被调去市场部
|
|
91
|
+
U.dave = await add(`gme_dave_${stamp}`, "member"); // 市场部,被手动拉进 / 移出研发部群
|
|
92
|
+
U.erin = await add(`gme_erin_${stamp}`, "member"); // 未分配,之后调进研发部
|
|
93
|
+
U.frank = await add(`gme_frank_${stamp}`, "member"); // 未分配:回滚用例的对象
|
|
94
|
+
U.gina = await add(`gme_gina_${stamp}`, "member"); // 未分配:不相干的人 / 负责人用例
|
|
95
|
+
U.hank = await add(`gme_hank_${stamp}`, "member"); // 研发部,之后被移出网络
|
|
96
|
+
|
|
97
|
+
const mk = async (name: string, extra: Record<string, unknown> = {}) => {
|
|
98
|
+
const r = await send(U.owner.token, "POST", `/api/networks/${NET}/departments`, { name, ...extra });
|
|
99
|
+
expect(r.status).toBe(201);
|
|
100
|
+
return r.body.department.id as string;
|
|
101
|
+
};
|
|
102
|
+
DEPT.rd = await mk("研发部");
|
|
103
|
+
DEPT.market = await mk("市场部");
|
|
104
|
+
for (const w of ["alice", "carol", "hank"]) await place(w, DEPT.rd);
|
|
105
|
+
await place("dave", DEPT.market);
|
|
106
|
+
for (const key of ["rd", "market"]) {
|
|
107
|
+
const r = await send(U.owner.token, "POST", `/api/networks/${NET}/departments/${DEPT[key]}/group`, {});
|
|
108
|
+
expect(r.status).toBe(201);
|
|
109
|
+
GRP[key] = r.body.group.id;
|
|
110
|
+
}
|
|
111
|
+
for (const w of ["owner", "alice", "carol", "dave", "erin", "frank", "gina", "hank"]) S[w] = await subscribe(w);
|
|
112
|
+
}, 30_000);
|
|
113
|
+
|
|
114
|
+
afterAll(() => {
|
|
115
|
+
for (const s of Object.values(S)) try { s.close(); } catch {}
|
|
116
|
+
try { hub?.stop?.(true); } catch {}
|
|
117
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("manual add / remove", () => {
|
|
121
|
+
test("manual add: the added user and current members get one `added` event; outsiders get nothing", async () => {
|
|
122
|
+
await reset();
|
|
123
|
+
const r = await send(U.owner.token, "POST", CG(`/${GRP.rd}/members`), { user_id: U.dave.id });
|
|
124
|
+
expect(r.status).toBe(201);
|
|
125
|
+
await until(() => ["dave", "alice", "carol"].every((w) => match(w, "rd", "dave", "added", "manual").length > 0));
|
|
126
|
+
expect(match("dave", "rd", "dave", "added", "manual")).toHaveLength(1);
|
|
127
|
+
expect(match("alice", "rd", "dave", "added", "manual")).toHaveLength(1);
|
|
128
|
+
expect(match("carol", "rd", "dave", "added", "manual")).toHaveLength(1);
|
|
129
|
+
const ev = match("dave", "rd", "dave", "added", "manual")[0];
|
|
130
|
+
expect(ev).toMatchObject({ type: "group_membership_changed", network_id: NET, group_id: GRP.rd, member_user_id: U.dave.id, change: "added", source: "manual", scope: "user" });
|
|
131
|
+
expect(ev.user_id).toBe(U.dave.id); // 信封 user_id = 收件人
|
|
132
|
+
expect(match("alice", "rd", "dave", "added", "manual")[0].user_id).toBe(U.alice.id);
|
|
133
|
+
expect(new Date(ev.at).toISOString()).toBe(ev.at);
|
|
134
|
+
await sleep(100);
|
|
135
|
+
expect(mev("gina")).toHaveLength(0);
|
|
136
|
+
expect(mev("erin")).toHaveLength(0);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("manual remove: the removed user and the remaining members get one `removed` event", async () => {
|
|
140
|
+
await reset();
|
|
141
|
+
const r = await send(U.owner.token, "DELETE", CG(`/${GRP.rd}/members/${U.dave.id}`));
|
|
142
|
+
expect(r.status).toBe(200);
|
|
143
|
+
await until(() => ["dave", "alice"].every((w) => match(w, "rd", "dave", "removed", "manual").length > 0));
|
|
144
|
+
expect(match("dave", "rd", "dave", "removed", "manual")).toHaveLength(1);
|
|
145
|
+
expect(match("alice", "rd", "dave", "removed", "manual")).toHaveLength(1);
|
|
146
|
+
await sleep(100);
|
|
147
|
+
expect(mev("gina")).toHaveLength(0);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("a refused write (409 already_group_member) emits nothing", async () => {
|
|
151
|
+
await reset();
|
|
152
|
+
expect((await send(U.owner.token, "POST", CG(`/${GRP.rd}/members`), { user_id: U.alice.id })).status).toBe(409);
|
|
153
|
+
await sleep(150);
|
|
154
|
+
for (const w of Object.keys(S)) expect(mev(w)).toHaveLength(0);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("department sync", () => {
|
|
159
|
+
test("move-in: erin into 研发部 → `added`/department to erin and the 研发部 group's members", async () => {
|
|
160
|
+
await reset();
|
|
161
|
+
await place("erin", DEPT.rd);
|
|
162
|
+
await until(() => ["erin", "alice"].every((w) => match(w, "rd", "erin", "added", "department").length > 0));
|
|
163
|
+
expect(match("erin", "rd", "erin", "added", "department")).toHaveLength(1);
|
|
164
|
+
expect(match("alice", "rd", "erin", "added", "department")).toHaveLength(1);
|
|
165
|
+
await sleep(100);
|
|
166
|
+
expect(mev("dave")).toHaveLength(0); // 市场部群没变
|
|
167
|
+
expect(isGroupMember(GRP.rd, U.erin.id)).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("move-out: carol 研发部 → 市场部 → removed from 研发部 group, added to 市场部 group", async () => {
|
|
171
|
+
await reset();
|
|
172
|
+
await place("carol", DEPT.market);
|
|
173
|
+
await until(() => ["carol", "alice"].every((w) => match(w, "rd", "carol", "removed", "department").length > 0) && ["carol", "dave"].every((w) => match(w, "market", "carol", "added", "department").length > 0));
|
|
174
|
+
expect(match("carol", "rd", "carol", "removed", "department")).toHaveLength(1);
|
|
175
|
+
expect(match("alice", "rd", "carol", "removed", "department")).toHaveLength(1);
|
|
176
|
+
expect(match("carol", "market", "carol", "added", "department")).toHaveLength(1);
|
|
177
|
+
expect(match("dave", "market", "carol", "added", "department")).toHaveLength(1);
|
|
178
|
+
// alice 不在市场部群:不该收到市场部群的变化
|
|
179
|
+
await sleep(100);
|
|
180
|
+
expect(mev("alice").filter((e) => e.group_id === GRP.market)).toHaveLength(0);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("head change: gina made 研发部 leader joins the group; unsetting removes her", async () => {
|
|
184
|
+
await reset();
|
|
185
|
+
expect((await send(U.owner.token, "PATCH", `/api/networks/${NET}/departments/${DEPT.rd}`, { leader_user_id: U.gina.id })).status).toBe(200);
|
|
186
|
+
await until(() => ["gina", "alice"].every((w) => match(w, "rd", "gina", "added", "department").length > 0));
|
|
187
|
+
expect(match("gina", "rd", "gina", "added", "department")).toHaveLength(1);
|
|
188
|
+
expect(match("alice", "rd", "gina", "added", "department")).toHaveLength(1);
|
|
189
|
+
// 部门写会踢掉负责人的用户流(RFC-040,权限变了要重连重新鉴权)—— App 会重连;这里同样重订阅一次。
|
|
190
|
+
S.gina.close();
|
|
191
|
+
S.gina = await subscribe("gina");
|
|
192
|
+
await reset();
|
|
193
|
+
expect((await send(U.owner.token, "PATCH", `/api/networks/${NET}/departments/${DEPT.rd}`, { leader_user_id: null })).status).toBe(200);
|
|
194
|
+
await until(() => ["gina", "alice"].every((w) => match(w, "rd", "gina", "removed", "department").length > 0));
|
|
195
|
+
expect(match("gina", "rd", "gina", "removed", "department")).toHaveLength(1);
|
|
196
|
+
expect(match("alice", "rd", "gina", "removed", "department")).toHaveLength(1);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("rename-only PATCH (no roster change) emits nothing", async () => {
|
|
200
|
+
await reset();
|
|
201
|
+
expect((await send(U.owner.token, "PATCH", `/api/networks/${NET}/departments/${DEPT.rd}`, { name: "研发中心" })).status).toBe(200);
|
|
202
|
+
await sleep(150);
|
|
203
|
+
for (const w of Object.keys(S)) expect(mev(w)).toHaveLength(0);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("post-commit only", () => {
|
|
208
|
+
test("a rolled-back write emits nothing and changes nothing", async () => {
|
|
209
|
+
await reset();
|
|
210
|
+
expect(() => groupTx(() => {
|
|
211
|
+
db.run("UPDATE network_members SET department_id = ?3 WHERE network_id = ?1 AND user_id = ?2", [NET, U.frank.id, DEPT.rd]);
|
|
212
|
+
syncDepartmentGroups(NET);
|
|
213
|
+
expect(isGroupMember(GRP.rd, U.frank.id)).toBe(true); // 事务里确实对过账
|
|
214
|
+
throw new Error("boom");
|
|
215
|
+
})).toThrow("boom");
|
|
216
|
+
expect(isGroupMember(GRP.rd, U.frank.id)).toBe(false);
|
|
217
|
+
await sleep(200);
|
|
218
|
+
for (const w of Object.keys(S)) expect(mev(w)).toHaveLength(0);
|
|
219
|
+
// 下一次提交的写也不会把回滚掉的那笔捎带出去
|
|
220
|
+
await place("erin", DEPT.rd); // 无变化的调人(erin 已在研发部)
|
|
221
|
+
await sleep(150);
|
|
222
|
+
for (const w of Object.keys(S)) expect(mev(w).filter((e) => e.member_user_id === U.frank.id)).toHaveLength(0);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("an inner savepoint rollback drops only its own changes; the outer commit still emits", async () => {
|
|
226
|
+
await reset();
|
|
227
|
+
groupTx(() => {
|
|
228
|
+
db.run("UPDATE network_members SET department_id = ?3 WHERE network_id = ?1 AND user_id = ?2", [NET, U.gina.id, DEPT.rd]);
|
|
229
|
+
syncDepartmentGroups(NET);
|
|
230
|
+
try {
|
|
231
|
+
groupTx(() => {
|
|
232
|
+
db.run("UPDATE network_members SET department_id = ?3 WHERE network_id = ?1 AND user_id = ?2", [NET, U.frank.id, DEPT.rd]);
|
|
233
|
+
syncDepartmentGroups(NET);
|
|
234
|
+
throw new Error("inner");
|
|
235
|
+
});
|
|
236
|
+
} catch {}
|
|
237
|
+
// 事务还没提交:这时一条都不该推出去
|
|
238
|
+
expect(mev("alice")).toHaveLength(0);
|
|
239
|
+
});
|
|
240
|
+
expect(isGroupMember(GRP.rd, U.gina.id)).toBe(true);
|
|
241
|
+
expect(isGroupMember(GRP.rd, U.frank.id)).toBe(false);
|
|
242
|
+
await until(() => match("alice", "rd", "gina", "added", "department").length > 0);
|
|
243
|
+
expect(match("alice", "rd", "gina", "added", "department")).toHaveLength(1);
|
|
244
|
+
await sleep(100);
|
|
245
|
+
for (const w of Object.keys(S)) expect(mev(w).filter((e) => e.member_user_id === U.frank.id)).toHaveLength(0);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe("network member removal", () => {
|
|
250
|
+
test("removing hank from the network → remaining 研发部 members get `removed`/network_removal", async () => {
|
|
251
|
+
// 调部门 / 换负责人会踢掉当事人的用户流(RFC-040,App 会重连):erin、gina 先重订阅。
|
|
252
|
+
for (const w of ["erin", "gina"]) { S[w].close(); S[w] = await subscribe(w); }
|
|
253
|
+
await reset();
|
|
254
|
+
const r = await send(U.owner.token, "DELETE", `/api/networks/${NET}/members/${U.hank.id}`);
|
|
255
|
+
expect(r.status).toBe(200);
|
|
256
|
+
await until(() => ["alice", "erin", "gina"].every((w) => match(w, "rd", "hank", "removed", "network_removal").length > 0));
|
|
257
|
+
expect(match("alice", "rd", "hank", "removed", "network_removal")).toHaveLength(1);
|
|
258
|
+
expect(match("erin", "rd", "hank", "removed", "network_removal")).toHaveLength(1);
|
|
259
|
+
expect(match("gina", "rd", "hank", "removed", "network_removal")).toHaveLength(1);
|
|
260
|
+
// 不能再有一条 department 来源的重复移出(移出网络已经删了所有行,对账没东西可删)
|
|
261
|
+
expect(match("alice", "rd", "hank", "removed", "department")).toHaveLength(0);
|
|
262
|
+
expect(isGroupMember(GRP.rd, U.hank.id)).toBe(false);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// report_status must not relabel a nodes row that belongs to another node.
|
|
2
|
+
//
|
|
3
|
+
// Shape seen in production: node A's config carries a node_id that another
|
|
4
|
+
// node B reported first (a copied config / a colliding hand-backfilled id).
|
|
5
|
+
// The row is B's alias + B's config_path, unowned (pre-RFC-036). When A starts
|
|
6
|
+
// reporting node_id, upsertNodeWithSec1Guard's COALESCE would overwrite the
|
|
7
|
+
// alias with A's — B's row silently becomes A's. identity_mismatch refuses it.
|
|
8
|
+
//
|
|
9
|
+
// Run with: COMMHUB_DB=/tmp/identity-mismatch.db bun test src/node-identity-mismatch.test.ts
|
|
10
|
+
|
|
11
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
12
|
+
import { db } from "./db.js";
|
|
13
|
+
import { upsertNodeWithSec1Guard } from "./tools.js";
|
|
14
|
+
|
|
15
|
+
const NET = "net_identity";
|
|
16
|
+
|
|
17
|
+
function row(node_id: string, alias: string, config_path: string | null, owner: string | null = null) {
|
|
18
|
+
db.run(
|
|
19
|
+
`INSERT INTO nodes (node_id, node_name, alias, runtime, config_path, network_id, owner_user_id) VALUES (?1, ?2, ?2, 'codex-app-server-sdk', ?3, ?4, ?5)`,
|
|
20
|
+
[node_id, alias, config_path, NET, owner],
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
const read = (id: string) => db.get<{ alias: string; runtime: string | null; config_path: string | null }>(
|
|
24
|
+
"SELECT alias, runtime, config_path FROM nodes WHERE node_id = ?1", id,
|
|
25
|
+
)!;
|
|
26
|
+
|
|
27
|
+
beforeEach(() => { db.run("DELETE FROM nodes"); });
|
|
28
|
+
|
|
29
|
+
describe("upsertNodeWithSec1Guard — identity_mismatch", () => {
|
|
30
|
+
test("different alias + different config_path ⇒ refused, row untouched", () => {
|
|
31
|
+
row("n_shared01", "node-b", "/work/b/.anet/nodes/node-b/config.json");
|
|
32
|
+
const out = upsertNodeWithSec1Guard({
|
|
33
|
+
node_id: "n_shared01", callerNetworkId: NET, alias: "node-a", runtime: "claude-code",
|
|
34
|
+
config_path: "/work/a/.anet/nodes/node-a/config.json",
|
|
35
|
+
});
|
|
36
|
+
expect(out).toMatchObject({ result: "refused", reason: "identity_mismatch" });
|
|
37
|
+
expect(read("n_shared01")).toEqual({ alias: "node-b", runtime: "codex-app-server-sdk", config_path: "/work/b/.anet/nodes/node-b/config.json" });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("same alias, different config_path (the node moved dirs) ⇒ updated", () => {
|
|
41
|
+
row("n_moved01", "node-a", "/old/.anet/nodes/node-a/config.json");
|
|
42
|
+
const out = upsertNodeWithSec1Guard({ node_id: "n_moved01", callerNetworkId: NET, alias: "node-a", config_path: "/new/.anet/nodes/node-a/config.json" });
|
|
43
|
+
expect(out.result).toBe("updated");
|
|
44
|
+
expect(read("n_moved01").config_path).toBe("/new/.anet/nodes/node-a/config.json");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("different alias, same config_path ⇒ updated (not the collision shape)", () => {
|
|
48
|
+
row("n_same01", "old-name", "/w/.anet/nodes/x/config.json");
|
|
49
|
+
expect(upsertNodeWithSec1Guard({ node_id: "n_same01", callerNetworkId: NET, alias: "new-name", config_path: "/w/.anet/nodes/x/config.json" }).result).toBe("updated");
|
|
50
|
+
expect(read("n_same01").alias).toBe("new-name");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("row without config_path (token-mint row) ⇒ first reporter fills it in", () => {
|
|
54
|
+
row("n_mint01", "node-a", null);
|
|
55
|
+
expect(upsertNodeWithSec1Guard({ node_id: "n_mint01", callerNetworkId: NET, alias: "node-a", config_path: "/w/a/config.json" }).result).toBe("updated");
|
|
56
|
+
expect(read("n_mint01").config_path).toBe("/w/a/config.json");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("reporter without config_path ⇒ no evidence, legacy behaviour kept", () => {
|
|
60
|
+
row("n_nocfg01", "node-b", "/w/b/config.json");
|
|
61
|
+
expect(upsertNodeWithSec1Guard({ node_id: "n_nocfg01", callerNetworkId: NET, alias: "node-a" }).result).toBe("updated");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("new node_id ⇒ inserted", () => {
|
|
65
|
+
expect(upsertNodeWithSec1Guard({ node_id: "n_fresh01", callerNetworkId: NET, alias: "node-a", config_path: "/w/a/config.json" }).result).toBe("inserted");
|
|
66
|
+
expect(read("n_fresh01").alias).toBe("node-a");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("owner mismatch still wins first (existing gate unchanged)", () => {
|
|
70
|
+
row("n_owned01", "node-b", "/w/b/config.json", "usr_b");
|
|
71
|
+
expect(upsertNodeWithSec1Guard({ node_id: "n_owned01", callerNetworkId: NET, callerUserId: "usr_a", alias: "node-a", config_path: "/w/a/config.json" }))
|
|
72
|
+
.toMatchObject({ result: "refused", reason: "owner_mismatch" });
|
|
73
|
+
});
|
|
74
|
+
});
|