@sleep2agi/commhub-server 0.9.0-preview.73 → 0.9.0-preview.75

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.
@@ -0,0 +1,284 @@
1
+ // 任务(需求卡)的人员权限 —— 「这个人在这个网络里能看见 / 能改 / 能删哪些卡」的唯一实现(RFC-038 §9)。
2
+ //
3
+ // network_members.task_access:
4
+ // 'all' —— 旧语义:网络里的卡全看见;非 viewer 全能改、全能删、能管项目。升级前的成员行全在这里
5
+ // (ALTER … DEFAULT 'all'),所以升级不收窄任何人。
6
+ // 'scoped' —— 只看见 ①负责人是我 ②参与人有我 ③我建的 ④所在项目授权给我 的卡;
7
+ // 能改:负责人是我 / 我建的 / 项目授权 can_edit;能删:负责人是我 / 我建的;不能管项目。
8
+ // viewer 只看 ④,什么都不能改。
9
+ // owner/admin 角色与 Hub 管理员不看这一列。节点令牌(Agent)不经过这里 —— 规则原样不动。
10
+ //
11
+ // 判定是 fail-closed 的:task_access 只有字面 'all' 才放开;role 只有 owner/admin 才豁免。
12
+
13
+ import { db } from "./db.js";
14
+
15
+ // ── 表结构(放在这里而不是 db.ts:db.ts 每多一行,文档里钉着的行号就漂一次) ──
16
+ // ALTER 的 DEFAULT 'all' 让**升级前已有的**成员行全部落在旧语义 —— 升级不收窄任何人;新成员按下面的
17
+ // NEW_MEMBER_TASK_ACCESS 入库。项目授权:行存在 = 可看,can_edit=1 = 可改。
18
+ try { db.exec("ALTER TABLE network_members ADD COLUMN task_access TEXT NOT NULL DEFAULT 'all'"); } catch {}
19
+ db.exec(`
20
+ CREATE TABLE IF NOT EXISTS network_member_project_grants (
21
+ network_id TEXT NOT NULL,
22
+ user_id TEXT NOT NULL,
23
+ project_id TEXT NOT NULL,
24
+ can_edit INTEGER NOT NULL DEFAULT 0,
25
+ created_by TEXT,
26
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
27
+ PRIMARY KEY (network_id, user_id, project_id)
28
+ );
29
+ CREATE INDEX IF NOT EXISTS idx_project_grants_project ON network_member_project_grants(project_id);
30
+ `);
31
+
32
+ /**
33
+ * 新成员(管理员建号 / POST members / 邀请码)默认的任务范围。
34
+ * 暂时是 'all':app ≤0.2.163 没有任务权限界面,从那些 app 加的成员若默认 scoped 会一张卡都看不到,
35
+ * 管理员也没法在那些 app 里放宽。等 owner 的设备都 ≥0.2.164 且他确认后,改回 'scoped'(只改这一行)。
36
+ * 显式 task_access='scoped'(POST members / PUT task-grants)不受这个默认值影响。
37
+ */
38
+ export const NEW_MEMBER_TASK_ACCESS: TaskAccessMode = "all";
39
+
40
+ export type TaskAccessMode = "all" | "scoped";
41
+ export type ProjectGrant = { project_id: string; can_edit: boolean };
42
+
43
+ const UNRESTRICTED_ROLES = new Set(["owner", "admin"]);
44
+
45
+ type MemberRow = { member_role: string | null; task_access: string | null; user_role: string | null };
46
+
47
+ function memberRow(userId: string, networkId: string): MemberRow | null {
48
+ return db.get<MemberRow>(
49
+ `SELECT nm.role AS member_role, nm.task_access, u.role AS user_role
50
+ FROM network_members nm JOIN users u ON u.user_id = nm.user_id
51
+ WHERE nm.network_id = ?1 AND nm.user_id = ?2`,
52
+ networkId, userId,
53
+ );
54
+ }
55
+
56
+ function scopedRow(row: MemberRow | null): boolean {
57
+ if (!row) return false;
58
+ if (row.user_role === "admin") return false;
59
+ if (UNRESTRICTED_ROLES.has(String(row.member_role))) return false;
60
+ return row.task_access !== "all";
61
+ }
62
+
63
+ /** 这个用户在这个网络里是不是「只看相关任务」的成员。非成员返回 false(成员资格由调用方的 scope 管)。 */
64
+ export function isTaskScoped(userId: string, networkId: string | null | undefined): boolean {
65
+ if (!userId || !networkId) return false;
66
+ return scopedRow(memberRow(userId, networkId));
67
+ }
68
+
69
+ /** 该用户所有「只看相关任务」的网络;每个带上他是不是 viewer(viewer 只看授权项目)。 */
70
+ export function taskScopedNetworks(userId: string): Array<{ networkId: string; viewer: boolean }> {
71
+ if (!userId) return [];
72
+ return db.all<MemberRow & { network_id: string }>(
73
+ `SELECT nm.network_id, nm.role AS member_role, nm.task_access, u.role AS user_role
74
+ FROM network_members nm JOIN users u ON u.user_id = nm.user_id
75
+ WHERE nm.user_id = ?1`,
76
+ userId,
77
+ ).filter(scopedRow).map((row) => ({ networkId: row.network_id, viewer: row.member_role === "viewer" }));
78
+ }
79
+
80
+ export function getTaskAccessMode(networkId: string, userId: string): TaskAccessMode | null {
81
+ const row = db.get<{ task_access: string | null }>("SELECT task_access FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId);
82
+ if (!row) return null;
83
+ return row.task_access === "all" ? "all" : "scoped";
84
+ }
85
+
86
+ export function listProjectGrants(networkId: string, userId: string): ProjectGrant[] {
87
+ return db.all<{ project_id: string; can_edit: number }>(
88
+ "SELECT project_id, can_edit FROM network_member_project_grants WHERE network_id = ?1 AND user_id = ?2 ORDER BY project_id",
89
+ networkId, userId,
90
+ ).map((row) => ({ project_id: row.project_id, can_edit: row.can_edit === 1 }));
91
+ }
92
+
93
+ // ── 卡片是不是「我的」 ──
94
+ // owner / participants / created_by_json 存的都是 JSON.stringify({kind, id})(requirements.ts 的 personRef / actorOf),
95
+ // 所以用子串匹配 `"kind":"user","id":"<uid>"`:SQLite 与 PostgreSQL 都支持 LIKE … ESCAPE,不依赖 json_each。
96
+ const likeEscape = (s: string) => s.replace(/[\\%_]/g, (c) => `\\${c}`);
97
+ export const userRefPattern = (userId: string) => `%${likeEscape(`"kind":"user","id":"${userId}"`)}%`;
98
+
99
+ type CardRow = { network_id: string; owner_json: string | null; participants_json: string | null; created_by: string | null; created_by_json: string | null; project_id: string | null };
100
+ const refIs = (json: string | null, userId: string) => !!json && json.includes(`"kind":"user","id":"${userId}"`);
101
+ const ownsCard = (row: CardRow, userId: string) => refIs(row.owner_json, userId);
102
+ const createdCard = (row: CardRow, userId: string) => refIs(row.created_by_json, userId) || (!row.created_by_json && row.created_by === userId);
103
+ const participates = (row: CardRow, userId: string) => refIs(row.participants_json, userId);
104
+
105
+ function projectGrant(networkId: string, userId: string, projectId: string | null): ProjectGrant | null {
106
+ if (!projectId) return null;
107
+ const row = db.get<{ can_edit: number }>(
108
+ "SELECT can_edit FROM network_member_project_grants WHERE network_id = ?1 AND user_id = ?2 AND project_id = ?3",
109
+ networkId, userId, projectId,
110
+ );
111
+ return row ? { project_id: projectId, can_edit: row.can_edit === 1 } : null;
112
+ }
113
+
114
+ export type TaskCaller = { userId: string } | null;
115
+
116
+ /** 看得见这张卡吗。caller=null(节点令牌 / 管理员 / 旧的全局令牌)恒为 true。 */
117
+ export function canSeeTask(caller: TaskCaller, row: CardRow): boolean {
118
+ if (!caller) return true;
119
+ const m = memberRow(caller.userId, row.network_id);
120
+ if (!scopedRow(m)) return true;
121
+ if (projectGrant(row.network_id, caller.userId, row.project_id)) return true;
122
+ if (m?.member_role === "viewer") return false;
123
+ return ownsCard(row, caller.userId) || createdCard(row, caller.userId) || participates(row, caller.userId);
124
+ }
125
+
126
+ /** 能改这张卡吗(PATCH / 勾子任务)。只对看得见的卡调用。 */
127
+ export function canEditTask(caller: TaskCaller, row: CardRow): boolean {
128
+ if (!caller) return true;
129
+ const m = memberRow(caller.userId, row.network_id);
130
+ if (!scopedRow(m)) return true;
131
+ if (m?.member_role === "viewer") return false;
132
+ if (ownsCard(row, caller.userId) || createdCard(row, caller.userId)) return true;
133
+ return projectGrant(row.network_id, caller.userId, row.project_id)?.can_edit === true;
134
+ }
135
+
136
+ /** 能删这张卡吗。scoped 成员只能删负责人是自己或自己建的。 */
137
+ export function canDeleteTask(caller: TaskCaller, row: CardRow): boolean {
138
+ if (!caller) return true;
139
+ const m = memberRow(caller.userId, row.network_id);
140
+ if (!scopedRow(m)) return true;
141
+ if (m?.member_role === "viewer") return false;
142
+ return ownsCard(row, caller.userId) || createdCard(row, caller.userId);
143
+ }
144
+
145
+ /**
146
+ * 给客户端画「只读 / 不能删」用的每卡权限。只对 scoped 调用者返回(其余 null = 与今天一样全能)。
147
+ * 一个请求里按网络缓存成员行与项目授权,列表 500 张卡不会变成 500 × 3 次查询。
148
+ */
149
+ export type TaskPermissions = { edit: boolean; delete: boolean };
150
+ export function taskPermissionsResolver(caller: TaskCaller): (row: CardRow) => TaskPermissions | null {
151
+ if (!caller) return () => null;
152
+ const cache = new Map<string, { scoped: boolean; viewer: boolean; grants: Map<string, boolean> }>();
153
+ const ctxFor = (networkId: string) => {
154
+ let c = cache.get(networkId);
155
+ if (!c) {
156
+ const m = memberRow(caller.userId, networkId);
157
+ const scoped = scopedRow(m);
158
+ const grants = new Map<string, boolean>(scoped ? listProjectGrants(networkId, caller.userId).map((g) => [g.project_id, g.can_edit] as [string, boolean]) : []);
159
+ cache.set(networkId, c = { scoped, viewer: m?.member_role === "viewer", grants });
160
+ }
161
+ return c;
162
+ };
163
+ return (row: CardRow) => {
164
+ const c = ctxFor(row.network_id);
165
+ if (!c.scoped) return null;
166
+ if (c.viewer) return { edit: false, delete: false };
167
+ const mine = ownsCard(row, caller.userId) || createdCard(row, caller.userId);
168
+ return { edit: mine || (row.project_id ? c.grants.get(row.project_id) === true : false), delete: mine };
169
+ };
170
+ }
171
+
172
+ /** 新建 / 挪卡时的目标项目:scoped 成员只能放进自己 can_edit 的项目(null = 不放项目,总是可以)。 */
173
+ export function canUseProject(caller: TaskCaller, networkId: string, projectId: string | null): boolean {
174
+ if (!caller || !projectId) return true;
175
+ if (!isTaskScoped(caller.userId, networkId)) return true;
176
+ return projectGrant(networkId, caller.userId, projectId)?.can_edit === true;
177
+ }
178
+
179
+ /**
180
+ * 列表 / 搜索 / #N 查找的可见性子句。只对调用者 scoped 的网络加条件,别的网络原样放行。
181
+ * 追加在已经按网络过滤过的 `FROM requirements WHERE …` 后面。
182
+ */
183
+ export function addTaskVisibilityScope(sql: string, params: unknown[], caller: TaskCaller): string {
184
+ if (!caller) return sql;
185
+ const scoped = taskScopedNetworks(caller.userId);
186
+ if (!scoped.length) return sql;
187
+ // 参数只在真正用到时才压进去:PostgreSQL 的预编译语句里出现「没被引用的 $n」会直接报错
188
+ // (推不出类型),SQLite 不在乎 —— 只有 viewer 的网络时,「我的卡」那段模式参数就不该出现。
189
+ const uid = params.push(caller.userId);
190
+ const granted = `project_id IN (SELECT g.project_id FROM network_member_project_grants g WHERE g.user_id = ?${uid} AND g.network_id = requirements.network_id)`;
191
+ const members = scoped.filter((s) => !s.viewer).map((s) => s.networkId);
192
+ const viewers = scoped.filter((s) => s.viewer).map((s) => s.networkId);
193
+ const ph = (ids: string[]) => ids.map((id) => `?${params.push(id)}`).join(", ");
194
+ const all = scoped.map((s) => s.networkId);
195
+ const parts = [`network_id NOT IN (${ph(all)})`];
196
+ if (members.length) {
197
+ const pat = params.push(userRefPattern(caller.userId));
198
+ const mine = `owner_json LIKE ?${pat} ESCAPE '\\' OR participants_json LIKE ?${pat} ESCAPE '\\' OR created_by_json LIKE ?${pat} ESCAPE '\\' OR (created_by_json IS NULL AND created_by = ?${uid})`;
199
+ parts.push(`(network_id IN (${ph(members)}) AND (${mine} OR ${granted}))`);
200
+ }
201
+ if (viewers.length) parts.push(`(network_id IN (${ph(viewers)}) AND ${granted})`);
202
+ return `${sql} AND (${parts.join(" OR ")})`;
203
+ }
204
+
205
+ /** 项目列表的可见性:scoped 成员只看授权给自己的项目。 */
206
+ export function addProjectVisibilityScope(sql: string, params: unknown[], caller: TaskCaller, networkId: string): string {
207
+ if (!caller || !isTaskScoped(caller.userId, networkId)) return sql;
208
+ const uid = params.push(caller.userId);
209
+ const net = params.push(networkId);
210
+ return `${sql} AND project_id IN (SELECT project_id FROM network_member_project_grants WHERE user_id = ?${uid} AND network_id = ?${net})`;
211
+ }
212
+
213
+ export type ReplaceTaskGrantsResult =
214
+ | { ok: true; task_access: TaskAccessMode; project_grants: ProjectGrant[] }
215
+ | { ok: false; error: string; status: number; detail?: unknown };
216
+
217
+ const MAX_PROJECT_GRANTS = 500;
218
+
219
+ /**
220
+ * 整体替换某成员的任务授权。字段不传 = 保持原样(旧客户端不会误清)。
221
+ * 项目必须是本网络的;任何一条不合格 ⇒ 整批拒绝,不做部分写入。
222
+ */
223
+ export function replaceTaskGrants(input: { networkId: string; userId: string; taskAccess?: unknown; projectGrants?: unknown; actorUserId: string }): ReplaceTaskGrantsResult {
224
+ if (!db.get("SELECT 1 FROM network_members WHERE network_id = ?1 AND user_id = ?2", input.networkId, input.userId)) {
225
+ return { ok: false, error: "member_not_found", status: 404 };
226
+ }
227
+ let mode: TaskAccessMode | undefined;
228
+ if (input.taskAccess !== undefined) {
229
+ if (input.taskAccess !== "all" && input.taskAccess !== "scoped") return { ok: false, error: "invalid_task_access", status: 400 };
230
+ mode = input.taskAccess;
231
+ }
232
+ let rows: Array<{ project_id: string; can_edit: number }> | undefined;
233
+ if (input.projectGrants !== undefined) {
234
+ if (!Array.isArray(input.projectGrants)) return { ok: false, error: "project_grants_must_be_array", status: 400 };
235
+ if (input.projectGrants.length > MAX_PROJECT_GRANTS) return { ok: false, error: "too_many_grants", status: 400, detail: { limit: MAX_PROJECT_GRANTS } };
236
+ const seen = new Set<string>();
237
+ rows = [];
238
+ for (const [index, raw] of input.projectGrants.entries()) {
239
+ const item = typeof raw === "string" ? { project_id: raw } : (raw && typeof raw === "object" && !Array.isArray(raw) ? raw as { project_id?: unknown; can_edit?: unknown } : {});
240
+ const projectId = typeof item.project_id === "string" ? item.project_id.trim() : "";
241
+ if (!projectId) return { ok: false, error: "project_grant_needs_project_id", status: 400, detail: { index } };
242
+ if (item.can_edit !== undefined && typeof item.can_edit !== "boolean") return { ok: false, error: "can_edit_must_be_boolean", status: 400, detail: { index } };
243
+ if (!db.get("SELECT 1 FROM requirement_projects WHERE project_id = ?1 AND network_id = ?2", projectId, input.networkId)) {
244
+ return { ok: false, error: "project_not_in_network", status: 400, detail: { index, project_id: projectId } };
245
+ }
246
+ if (seen.has(projectId)) continue;
247
+ seen.add(projectId);
248
+ rows.push({ project_id: projectId, can_edit: item.can_edit === true ? 1 : 0 });
249
+ }
250
+ }
251
+ db.transaction(() => {
252
+ if (mode) db.run("UPDATE network_members SET task_access = ?1 WHERE network_id = ?2 AND user_id = ?3", [mode, input.networkId, input.userId]);
253
+ if (rows) {
254
+ db.run("DELETE FROM network_member_project_grants WHERE network_id = ?1 AND user_id = ?2", [input.networkId, input.userId]);
255
+ for (const row of rows) {
256
+ db.run(
257
+ "INSERT INTO network_member_project_grants (network_id, user_id, project_id, can_edit, created_by) VALUES (?1, ?2, ?3, ?4, ?5)",
258
+ [input.networkId, input.userId, row.project_id, row.can_edit, input.actorUserId],
259
+ );
260
+ }
261
+ }
262
+ });
263
+ return { ok: true, task_access: getTaskAccessMode(input.networkId, input.userId) ?? "scoped", project_grants: listProjectGrants(input.networkId, input.userId) };
264
+ }
265
+
266
+ /** 成员被移出网络 / 项目被删除时清掉对应授权。 */
267
+ export function deleteTaskGrantsForMember(networkId: string, userId: string): void {
268
+ db.run("DELETE FROM network_member_project_grants WHERE network_id = ?1 AND user_id = ?2", [networkId, userId]);
269
+ }
270
+ export function deleteTaskGrantsForProject(projectId: string): void {
271
+ db.run("DELETE FROM network_member_project_grants WHERE project_id = ?1", [projectId]);
272
+ }
273
+
274
+ // 被拒的写入审计限频:同一 (用户, 卡) 每小时最多记一条,别让重试刷爆审计表。
275
+ const deniedSeen = new Map<string, number>();
276
+ const DENIED_WINDOW_MS = 60 * 60 * 1000;
277
+ export function shouldAuditDenied(userId: string, requirementId: string, now = Date.now()): boolean {
278
+ const key = `${userId}\u0000${requirementId}`;
279
+ const last = deniedSeen.get(key);
280
+ if (last !== undefined && now - last < DENIED_WINDOW_MS) return false;
281
+ deniedSeen.set(key, now);
282
+ if (deniedSeen.size > 10_000) for (const [k, t] of deniedSeen) if (now - t >= DENIED_WINDOW_MS) deniedSeen.delete(k);
283
+ return true;
284
+ }
package/src/tools.ts CHANGED
@@ -88,6 +88,40 @@ function normalizeMetaJson(meta: unknown): string | null {
88
88
  try { return JSON.stringify(stripHostLocalPathsForCrossHostSafe(meta)); } catch { return null; }
89
89
  }
90
90
 
91
+ /**
92
+ * 定时任务的回复收件人。scheduler 派的任务 from_name='scheduler',回复按 from_name 落进
93
+ * inbox(session_name='scheduler')—— 没有任何人读那一行,于是建排程的人在 app 里看得到回复
94
+ * (会话取自 tasks 表)却永远没有未读红点(unread_by_agent 只数 session_name=用户名 的行)。
95
+ * 排程是「建它的人」的委托(见 scheduled-tasks.ts canMessageAgent),回复送到他的用户名下。
96
+ *
97
+ * 退回 null(= 照旧送 'scheduler')的情形:任务不是 scheduler 派的 / meta 没有排程 id /
98
+ * 排程没有 created_by(外部或旧排程)/ 用户已不存在 / 用户名与本网络某个节点 alias 撞名
99
+ * (那一行会变成那个节点的待办,不是用户的未读 —— 与 server.ts userInboxAliasCollides 同一条规则)。
100
+ * 只改投递地址;任务归属、reply_target_mismatch 等校验仍按原始发送方 'scheduler' 判。
101
+ */
102
+ function scheduledReplyRecipient(taskId: string, networkId: string | null | undefined): string | null {
103
+ const params: any[] = [taskId];
104
+ let sql = "SELECT from_name, meta_json, network_id FROM tasks WHERE task_id = ?1";
105
+ if (networkId) { params.push(networkId); sql += " AND network_id = ?2"; }
106
+ const task = db.get<{ from_name: string; meta_json: string | null; network_id: string | null }>(sql, ...params);
107
+ if (!task || task.from_name !== "scheduler") return null;
108
+ const meta = parseMetaJson(task.meta_json) as { scheduled_task_id?: unknown } | null;
109
+ const scheduleId = meta && typeof meta.scheduled_task_id === "string" ? meta.scheduled_task_id : "";
110
+ if (!scheduleId) return null;
111
+ const owner = db.get<{ username: string | null }>(
112
+ `SELECT u.username AS username FROM scheduled_tasks s JOIN users u ON u.user_id = s.created_by
113
+ WHERE s.schedule_id = ?1 AND s.network_id = ?2`,
114
+ scheduleId, task.network_id,
115
+ );
116
+ const username = owner?.username?.trim();
117
+ if (!username) return null;
118
+ const collides = db.get<{ hit: number }>(
119
+ "SELECT 1 AS hit FROM nodes WHERE alias = ?1 AND network_id = ?2 LIMIT 1",
120
+ username, task.network_id,
121
+ );
122
+ return collides ? null : username;
123
+ }
124
+
91
125
  // ── #1281 — 子节点生命周期工具的参数名统一 ────────────────────────────
92
126
  //
93
127
  // stop_node / start_node / delete_node 历史上用 `child_node_id`(RFC-027),
@@ -1975,12 +2009,18 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1975
2009
 
1976
2010
  const canonicalReplyTarget = resolveCanonicalAlias(effectiveNetId, effectiveAlias);
1977
2011
  const replyTargetAlias = canonicalReplyTarget.alias;
1978
- console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${replyTargetAlias}: ${text.slice(0, 60)}${attachmentsResult.attachments.length ? ` [+${attachmentsResult.attachments.length} attachments]` : ""}${canonicalReplyTarget.renamed ? ` [renamed from ${effectiveAlias}]` : ""}`);
2012
+ // 回复定时任务:收件人换成建排程的用户(见 scheduledReplyRecipient)。replyTargetAlias 仍是
2013
+ // 'scheduler',下面的归属/目标校验照旧对它判;只有 inbox 行与推送走 replyDeliveryAlias。
2014
+ const scheduledRecipient = in_reply_to && replyTargetAlias === "scheduler"
2015
+ ? scheduledReplyRecipient(in_reply_to, effectiveNetId)
2016
+ : null;
2017
+ const replyDeliveryAlias = scheduledRecipient ?? replyTargetAlias;
2018
+ console.log(`[${ts()}] ${from_session} → send_reply (${replyStatus}) → ${replyDeliveryAlias}: ${text.slice(0, 60)}${attachmentsResult.attachments.length ? ` [+${attachmentsResult.attachments.length} attachments]` : ""}${canonicalReplyTarget.renamed ? ` [renamed from ${effectiveAlias}]` : ""}${scheduledRecipient ? " [scheduled task → creator]" : ""}`);
1979
2019
  const id = uuidv4();
1980
- const replyTargetNodeId = resolveNodeIdForAlias(replyTargetAlias, effectiveNetId);
2020
+ const replyTargetNodeId = resolveNodeIdForAlias(replyDeliveryAlias, effectiveNetId);
1981
2021
  // RFC-027 §2.3 inbox-enqueue lifecycle guard (PR1.1 site 3/6).
1982
2022
  {
1983
- const lc = assertNodeActive(replyTargetAlias, effectiveNetId ?? null);
2023
+ const lc = assertNodeActive(replyDeliveryAlias, effectiveNetId ?? null);
1984
2024
  if (!lc.ok) return { content: [{ type: "text" as const, text: JSON.stringify(lc) }] };
1985
2025
  }
1986
2026
  const replyOutcome = db.transaction(() => {
@@ -2073,7 +2113,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2073
2113
  db.run(
2074
2114
  `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, in_reply_to, requires_response, network_id, meta_json)
2075
2115
  VALUES (?1, ?2, ?3, 'reply', 'normal', ?4, ?5, ?6, 'none', ?7, ?8)`,
2076
- [id, replyTargetAlias, replyTargetNodeId, text, from_session, in_reply_to ?? null, effectiveNetId ?? null, metaJson]
2116
+ [id, replyDeliveryAlias, replyTargetNodeId, text, from_session, in_reply_to ?? null, effectiveNetId ?? null, metaJson]
2077
2117
  );
2078
2118
 
2079
2119
  // 更新 tasks 表
@@ -2192,10 +2232,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
2192
2232
  }
2193
2233
  }
2194
2234
 
2195
- const session = scopedSessionStatus(replyTargetAlias, effectiveNetId);
2196
- pushEvent(replyTargetAlias, { type: "new_reply", inbox_count: pendingInboxCount(replyTargetAlias, effectiveNetId), from: from_session, message_id: id, in_reply_to, status: replyStatus }, effectiveNetId);
2235
+ const session = scopedSessionStatus(replyDeliveryAlias, effectiveNetId);
2236
+ pushEvent(replyDeliveryAlias, { type: "new_reply", inbox_count: pendingInboxCount(replyDeliveryAlias, effectiveNetId), from: from_session, message_id: id, in_reply_to, status: replyStatus }, effectiveNetId);
2197
2237
  // #461 network observer summary — ids + routing only, no reply text.
2198
- pushNetworkObserverEvent(effectiveNetId, { type: "new_reply", task_id: in_reply_to ?? null, message_id: id, from: from_session, to: replyTargetAlias, status: replyStatus });
2238
+ pushNetworkObserverEvent(effectiveNetId, { type: "new_reply", task_id: in_reply_to ?? null, message_id: id, from: from_session, to: replyDeliveryAlias, status: replyStatus });
2199
2239
 
2200
2240
  // #498 compatibility tripwire. Legacy send_reply is intentionally kept
2201
2241
  // for old agents and Dashboard callers during rollout, but it is not the
@@ -5785,7 +5825,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5785
5825
 
5786
5826
  server.tool(
5787
5827
  "requirements_list",
5788
- "List requirement tasks in your network. Filters: seq (the per-network short number shown as #N), status (pool/doing/done), project_id ('none' = no project), owner / agent_owner ('user:<id>' / 'node:<id>' / 'none'), updated_since (ISO), external_ref, parent_id / top_level, include_archived; q = search title / description / owner, agent and participant names / project name / tags (space-separated terms are ANDed). Newest first, 500 per page by default (limit ≤ 1000); has_more + next_cursor → pass cursor for the next page. Each task carries children {total, done}.",
5828
+ "List requirement tasks in your network. Filters: seq (the per-network short number shown as #N), status (pool/doing/done), project_id ('none' = no project), owner / agent_owner ('user:<id>' / 'node:<id>' / 'none'), updated_since (ISO), external_ref, parent_id / top_level, include_archived; q = search title / description / owner, agent and participant names / project name / tags (space-separated terms are ANDed). Newest first, 500 per page by default (limit ≤ 1000); has_more + next_cursor → pass cursor for the next page. Each task carries children {total, done}. view='summary' drops description and checklist bodies (adds has_description, checklist_count) — much smaller; use requirements_get for one task's full text. changes=true (needs updated_since) returns only tasks changed since then, archived ones included, plus deleted ids and server_time to pass as the next updated_since.",
5789
5829
  {
5790
5830
  network_id: z.string().max(200).optional(),
5791
5831
  seq: z.number().int().positive().optional().describe("short number (#N) of one task in this network"),
@@ -5798,12 +5838,15 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5798
5838
  include_archived: z.boolean().optional(),
5799
5839
  parent_id: z.string().max(200).optional().describe("children of this requirement ('none' = top level)"),
5800
5840
  top_level: z.boolean().optional(), q: z.string().max(200).optional(), limit: z.number().int().min(1).max(1000).optional(), cursor: z.string().max(600).optional(),
5841
+ view: z.enum(["full", "summary"]).optional().describe("summary = no description / checklist bodies (has_description, checklist_count instead)"),
5842
+ changes: z.boolean().optional().describe("with updated_since: only tasks changed since then (archived included) + deleted ids + server_time"),
5801
5843
  },
5802
5844
  async (args) => {
5803
5845
  const q = new URLSearchParams();
5804
- for (const k of ["seq", "status", "project_id", "owner", "agent_owner", "updated_since", "external_ref", "parent_id", "q", "limit", "cursor"] as const) if (args[k] !== undefined) q.set(k, String(args[k]));
5846
+ for (const k of ["seq", "status", "project_id", "owner", "agent_owner", "updated_since", "external_ref", "parent_id", "q", "limit", "cursor", "view"] as const) if (args[k] !== undefined) q.set(k, String(args[k]));
5805
5847
  if (args.include_archived) q.set("include_archived", "1");
5806
5848
  if (args.top_level) q.set("top_level", "1");
5849
+ if (args.changes) q.set("changes", "1");
5807
5850
  return requirementsCall("GET", `/api/requirements${q.size ? `?${q}` : ""}`, args.network_id);
5808
5851
  },
5809
5852
  );
package/src/uploads.ts CHANGED
@@ -200,9 +200,15 @@ export type UploadIndexEntry = {
200
200
  // "unattributed" are the same on-disk shape and legacy entries written
201
201
  // before #503 are indistinguishable from new unattributed ones.
202
202
  network_id?: string;
203
+ // 私信文件:上传时声明 `?purpose=dm`(App 的私信输入框这么传)。第一次用途就是私信 ——
204
+ // 只有上传者、收到/发出过带它的私信的人和管理员能下载(server.ts authorizeFileDownload)。
205
+ // 不是私信的文件不写这个键;以后被转进私信也不会补写,原来谁能看还是谁能看。
206
+ scope?: typeof DM_FILE_SCOPE;
203
207
  uploaded_at: string; // ISO 8601
204
208
  };
205
209
 
210
+ export const DM_FILE_SCOPE = "dm";
211
+
206
212
  /** Path to the index entry file for a given file_id. Lives in
207
213
  * `<uploadsRoot>/.index/<file_id>.json` so /api/files/:file_id can do
208
214
  * an O(1) lookup without scanning every date bucket. */
@@ -224,6 +230,8 @@ export function validateIndexEntry(entry: unknown): entry is UploadIndexEntry {
224
230
  // rather than writing null. owner_id is deliberately NOT constrained
225
231
  // here — existing entries legitimately carry owner_id=null.
226
232
  if ("network_id" in e && !(typeof e.network_id === "string" && e.network_id.length > 0)) return false;
233
+ // 未知的 scope 值不当成「没有 scope」放行(那等于把私信文件退回全网可见):整条判坏,下载 404。
234
+ if ("scope" in e && e.scope !== DM_FILE_SCOPE) return false;
227
235
  return (
228
236
  typeof e.file_id === "string" && FILE_ID_REGEX.test(e.file_id) &&
229
237
  typeof e.date_bucket === "string" && /^\d{4}-\d{2}-\d{2}$/.test(e.date_bucket) &&