@sleep2agi/commhub-server 0.9.0-preview.80 → 0.9.0-preview.81

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.80",
3
+ "version": "0.9.0-preview.81",
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,114 @@
1
+ // GET /api/requirements/projects 的每行 viewer_can.edit(app 任务页审计 L13)—— HTTP 集成测试(真实 Bun.serve,私有端口,临时库)。
2
+ //
3
+ // 客户端据此只在新建 / 挪卡的项目下拉里列「能编辑」的项目。判据必须与真写卡时一致,所以这里对每个项目
4
+ // 都真建一张卡,断言「viewer_can.edit === 建卡成功」,而不是另抄一份规则来比。
5
+ // admin —— Hub 管理员 + 网络 owner:未归档项目全 true,归档的 false(projectRef 拒归档)
6
+ // alice —— scoped 成员:P1 只看 → false,P2 可改 → true(只列得到这两个)
7
+ // carol —— task_access='all' 的成员:未归档全 true
8
+ // vic —— scoped viewer,P3 只看:false(viewer 本来就不能写卡)
9
+
10
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
11
+ import { mkdtempSync, rmSync } from "fs";
12
+ import { join } from "path";
13
+ import { tmpdir } from "os";
14
+ import { register } from "./auth.js";
15
+ import { db } from "./db.js";
16
+
17
+ const DIR = mkdtempSync(join(tmpdir(), "anet-project-viewer-can-"));
18
+ let BASE = "";
19
+ let hub: any = null;
20
+ const PW = "ProjViewerCanPassw0rd!x";
21
+ let NET = "";
22
+ const U: Record<string, { token: string; id: string }> = {};
23
+ const P: Record<string, string> = {};
24
+
25
+ type R = { status: number; body: any };
26
+ async function send(token: string, method: string, path: string, payload?: unknown): Promise<R> {
27
+ const res = await fetch(`${BASE}${path}`, {
28
+ method,
29
+ headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), "Content-Type": "application/json" },
30
+ body: payload === undefined ? undefined : JSON.stringify(payload),
31
+ });
32
+ const text = await res.text();
33
+ let body: any = null;
34
+ try { body = JSON.parse(text); } catch {}
35
+ return { status: res.status, body };
36
+ }
37
+ const projects = async (who: string) => {
38
+ const r = await send(U[who].token, "GET", `/api/requirements/projects?network_id=${NET}`);
39
+ expect(r.status).toBe(200);
40
+ return Object.fromEntries((r.body.projects as any[]).map((p) => [p.name, p]));
41
+ };
42
+
43
+ beforeAll(async () => {
44
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
45
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
46
+ process.env.HOST = "127.0.0.1";
47
+ const a = register(`pvc_admin_${Date.now()}`, PW, undefined, "Admin");
48
+ expect(a.ok).toBe(true);
49
+ U.admin = { token: a.token!, id: a.user!.user_id };
50
+ NET = a.network_id!;
51
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [U.admin.id]);
52
+ const mod: any = await import("./server.js");
53
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
54
+ BASE = `http://127.0.0.1:${hub.port}`;
55
+
56
+ const mk = async (username: string, role: string) => {
57
+ expect((await send(U.admin.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role })).status).toBe(200);
58
+ const login = await send("", "POST", "/api/auth/login", { username, password: PW });
59
+ expect(login.status).toBe(200);
60
+ return { token: login.body.token as string, id: login.body.user.user_id as string };
61
+ };
62
+ const stamp = Date.now();
63
+ U.alice = await mk(`pvc_alice_${stamp}`, "member");
64
+ U.carol = await mk(`pvc_carol_${stamp}`, "member");
65
+ U.vic = await mk(`pvc_vic_${stamp}`, "viewer");
66
+ db.run("UPDATE network_members SET task_access = 'all' WHERE network_id = ?1 AND user_id = ?2", [NET, U.carol.id]);
67
+
68
+ for (const name of ["pvc-P1", "pvc-P2", "pvc-P3", "pvc-P4"]) {
69
+ const r = await send(U.admin.token, "POST", "/api/requirements/projects", { network_id: NET, name });
70
+ expect(r.status).toBe(201);
71
+ P[name] = r.body.project.id;
72
+ }
73
+ expect((await send(U.admin.token, "PATCH", `/api/requirements/projects/${P["pvc-P4"]}?network_id=${NET}`, { archived: true })).status).toBe(200);
74
+ expect((await send(U.admin.token, "PUT", `/api/networks/${NET}/members/${U.alice.id}/task-grants`, { task_access: "scoped", project_grants: [{ project_id: P["pvc-P1"] }, { project_id: P["pvc-P2"], can_edit: true }] })).status).toBe(200);
75
+ expect((await send(U.admin.token, "PUT", `/api/networks/${NET}/members/${U.vic.id}/task-grants`, { task_access: "scoped", project_grants: [P["pvc-P3"]] })).status).toBe(200);
76
+ }, 30_000);
77
+
78
+ afterAll(() => {
79
+ try { hub?.stop?.(true); } catch {}
80
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
81
+ });
82
+
83
+ describe("GET /api/requirements/projects 的 viewer_can.edit", () => {
84
+ test("每个调用者的取值", async () => {
85
+ const edit = (rows: Record<string, any>) => Object.fromEntries(Object.entries(rows).map(([n, p]) => [n, p.viewer_can?.edit]));
86
+ expect(edit(await projects("admin"))).toEqual({ "pvc-P1": true, "pvc-P2": true, "pvc-P3": true, "pvc-P4": false });
87
+ expect(edit(await projects("carol"))).toEqual({ "pvc-P1": true, "pvc-P2": true, "pvc-P3": true, "pvc-P4": false });
88
+ expect(edit(await projects("alice"))).toEqual({ "pvc-P1": false, "pvc-P2": true });
89
+ expect(edit(await projects("vic"))).toEqual({ "pvc-P3": false });
90
+ });
91
+
92
+ test("只加字段:其余键与之前逐字相同", async () => {
93
+ const row = (await projects("alice"))["pvc-P2"];
94
+ expect(Object.keys(row).sort()).toEqual(["archived", "color", "createdAt", "id", "name", "sort", "viewer_can"]);
95
+ expect(Object.keys(row.viewer_can)).toEqual(["edit"]);
96
+ });
97
+
98
+ test("与真写卡一致:edit === 往这个项目建卡成功", async () => {
99
+ let checked = 0;
100
+ for (const who of ["admin", "carol", "alice", "vic"]) {
101
+ for (const [name, p] of Object.entries(await projects(who))) {
102
+ const r = await send(U[who].token, "POST", "/api/requirements", { network_id: NET, name: `pvc-card-${who}-${name}`, project_id: p.id });
103
+ expect({ who, name, created: r.status === 201 }).toEqual({ who, name, created: p.viewer_can.edit });
104
+ checked++;
105
+ }
106
+ }
107
+ expect(checked).toBe(11);
108
+ });
109
+
110
+ test("授权改成可编辑后立即反映", async () => {
111
+ expect((await send(U.admin.token, "PUT", `/api/networks/${NET}/members/${U.alice.id}/task-grants`, { project_grants: [{ project_id: P["pvc-P1"], can_edit: true }, { project_id: P["pvc-P2"], can_edit: true }] })).status).toBe(200);
112
+ expect((await projects("alice"))["pvc-P1"].viewer_can.edit).toBe(true);
113
+ });
114
+ });
@@ -9,7 +9,7 @@ import { aggregateStats, parseStatsQuery, type StatsRow } from "./requirements-s
9
9
  import { addHumanNetworkScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, resolveRestWriteNetworkId, type RestNetworkScope } from "./network-scope.js";
10
10
  import { isAgentRestricted, restrictedNetworkIds, visibleAgents } from "./agent-access.js";
11
11
  import {
12
- addProjectVisibilityScope, addTaskVisibilityScope, canDeleteTask, canEditTask, canParticipantEditTask, canSeeTask, canUseProject, deleteTaskGrantsForProject,
12
+ addProjectVisibilityScope, addTaskVisibilityScope, canDeleteTask, canEditTask, canParticipantEditTask, canSeeTask, canUseProject, deleteTaskGrantsForProject, projectUseResolver,
13
13
  isTaskScoped, PARTICIPANT_EDIT_FIELDS, shouldAuditDenied, taskPermissionsResolver, type TaskCaller,
14
14
  } from "./task-access.js";
15
15
  import { notifyParticipantChange } from "./requirement-notify.js";
@@ -278,7 +278,11 @@ async function handleProjects(ctx: RequirementsRequestContext): Promise<Response
278
278
  const projectParams: unknown[] = [networkId];
279
279
  const projectSql = addProjectVisibilityScope(`SELECT ${PROJECT_SELECT} FROM requirement_projects WHERE network_id = ?1`, projectParams, taskCaller(ctx), networkId);
280
280
  const rows = db.all<ProjectRow>(`${projectSql} ORDER BY sort, created_at`, ...projectParams);
281
- return Response.json({ ok: true, projects: rows.map(projectPublic) });
281
+ // viewer_can.edit:调用者能不能把卡建进 / 挪进这个项目 —— 与 POST / PATCH 卡片同一套判据
282
+ // (canWrite + projectRef 拒归档 + canUseProject)。只加字段;客户端据此只列能编辑的项目(app 任务页审计 L13)。
283
+ const writable = canWrite(ctx, networkId);
284
+ const canUse = projectUseResolver(taskCaller(ctx), networkId);
285
+ return Response.json({ ok: true, projects: rows.map((row) => ({ ...projectPublic(row), viewer_can: { edit: writable && !row.archived && canUse(row.project_id) } })) });
282
286
  }
283
287
  if (!canWrite(ctx, networkId)) return jsonError("permission_denied", 403);
284
288
  // 项目管理(建 / 改名 / 归档 / 删)只给不受任务范围限制的人。
@@ -192,6 +192,16 @@ export function taskPermissionsResolver(caller: TaskCaller): (row: CardRow) => T
192
192
  };
193
193
  }
194
194
 
195
+ /**
196
+ * 项目列表的 viewer_can.edit 用:与 canUseProject 同一判据,但一个请求里只读一次授权表(列表最多 200 个项目)。
197
+ * 不受任务范围限制的调用者(节点 / Hub 管理员 / owner / admin / task_access='all')⇒ 每个项目都 true。
198
+ */
199
+ export function projectUseResolver(caller: TaskCaller, networkId: string): (projectId: string) => boolean {
200
+ if (!caller || !isTaskScoped(caller.userId, networkId)) return () => true;
201
+ const editable = new Set(listProjectGrants(networkId, caller.userId).filter((g) => g.can_edit).map((g) => g.project_id));
202
+ return (projectId) => editable.has(projectId);
203
+ }
204
+
195
205
  /** 新建 / 挪卡时的目标项目:scoped 成员只能放进自己 can_edit 的项目(null = 不放项目,总是可以)。 */
196
206
  export function canUseProject(caller: TaskCaller, networkId: string, projectId: string | null): boolean {
197
207
  if (!caller || !projectId) return true;