@sleep2agi/commhub-server 0.9.0-preview.91 → 0.9.0-preview.93

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,242 @@
1
+ // RFC-041 第一阶段(board #487):节点(Agent)自己的权限 —— 先记录、后执行。
2
+ //
3
+ // 节点的权限 = 主人的权限 ∩ 节点的模式(nodes.permission_mode:normal 正常 / readonly 只读 / restricted 受限)。
4
+ //
5
+ // • 模式是主人主动设的,**设了就立刻生效**(不看开关):只读的节点写什么都拒(mode_readonly);
6
+ // 受限的节点只碰派给它的任务、派活只给主人授权的 Agent(mode_restricted_not_assigned / agent_not_granted_to_owner)。
7
+ // • 正常模式下的收紧(超出主人可见范围 beyond_owner_visibility、主人没被授权的 Agent agent_not_granted_to_owner、
8
+ // 只有人能做的事 human_only)由 Hub 开关 COMMHUB_NODE_PERMISSIONS 决定:
9
+ // log(默认)—— 照常放行,只把「本来会被拒」按 (节点, 路由, 原因, 小时) 合并记进 node_permission_log;
10
+ // enforce —— 记录并拒绝(REST 403 / MCP 错误 node_permission_denied + reason + hint);
11
+ // off —— 不判也不记(显式模式仍然生效)。
12
+ //
13
+ // 判定的入口:REST(requirements.ts、/api/task、/api/broadcast、/api/nodes/:id 写)、MCP(tools.ts 按工具名包一层)、
14
+ // SSE(/events/:session、/events/network/:id)。只对节点令牌(ntok_)生效;用户令牌一条不变。
15
+ // 只增不改:nodes 多一列 permission_mode(默认 normal),新表 node_permission_log。回滚到上一版安全:旧代码不读它们。
16
+
17
+ import { db } from "./db.js";
18
+ import { canMessageAgent, type AgentRef } from "./agent-access.js";
19
+
20
+ export type NodePermissionMode = "normal" | "readonly" | "restricted";
21
+ export const NODE_PERMISSION_MODES: readonly NodePermissionMode[] = ["normal", "readonly", "restricted"];
22
+ export type NodePermissionsFlag = "log" | "off" | "enforce";
23
+ export type NodePermissionReason =
24
+ | "beyond_owner_visibility"
25
+ | "agent_not_granted_to_owner"
26
+ | "mode_readonly"
27
+ | "mode_restricted_not_assigned"
28
+ | "human_only"
29
+ | "owner_unknown";
30
+
31
+ /** 显式模式的原因:不看开关,永远拒。 */
32
+ const MODE_REASONS: ReadonlySet<NodePermissionReason> = new Set(["mode_readonly", "mode_restricted_not_assigned"]);
33
+
34
+ try { db.exec("ALTER TABLE nodes ADD COLUMN permission_mode TEXT NOT NULL DEFAULT 'normal'"); } catch (e: any) {
35
+ if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
36
+ }
37
+ db.exec(`
38
+ CREATE TABLE IF NOT EXISTS node_permission_log (
39
+ network_id TEXT NOT NULL,
40
+ node_id TEXT NOT NULL,
41
+ route TEXT NOT NULL,
42
+ reason TEXT NOT NULL,
43
+ hour TEXT NOT NULL,
44
+ hits INTEGER NOT NULL DEFAULT 1,
45
+ sample TEXT,
46
+ PRIMARY KEY (network_id, node_id, route, reason, hour)
47
+ );
48
+ CREATE INDEX IF NOT EXISTS idx_node_permission_log_hour ON node_permission_log(network_id, hour);
49
+ `);
50
+
51
+ /** 开关。未设 / 写错 → log(安全的默认:只记不拦)。每次现读:测试和运维改环境变量后无需重启模块。 */
52
+ export function nodePermissionsFlag(): NodePermissionsFlag {
53
+ const raw = (process.env.COMMHUB_NODE_PERMISSIONS ?? "").trim().toLowerCase();
54
+ return raw === "off" || raw === "enforce" ? raw : "log";
55
+ }
56
+
57
+ export type NodeIdentity = {
58
+ networkId: string;
59
+ /** 绑定的节点;老的未绑定令牌 = null(按「正常」判,记录时用 token:<token_id>)。 */
60
+ nodeId: string | null;
61
+ logKey: string;
62
+ aliases: string[];
63
+ ownerUserId: string | null;
64
+ mode: NodePermissionMode;
65
+ };
66
+
67
+ function normalizeMode(raw: unknown): NodePermissionMode {
68
+ return raw === "readonly" || raw === "restricted" ? raw : "normal";
69
+ }
70
+
71
+ /** 节点令牌 → 节点身份(一次带索引的查询)。不是节点令牌 / 查不到 → null。 */
72
+ export function nodeIdentity(tokenId: string | null | undefined, networkId: string | null | undefined): NodeIdentity | null {
73
+ if (!tokenId || !networkId) return null;
74
+ const row = db.get<{ user_id: string | null; bound_node_id: string | null; name: string | null; owner_user_id: string | null; permission_mode: string | null; alias: string | null }>(
75
+ `SELECT t.user_id, t.bound_node_id, t.name, n.owner_user_id, n.permission_mode, n.alias
76
+ FROM api_tokens t LEFT JOIN nodes n ON n.node_id = t.bound_node_id
77
+ WHERE t.token_id = ?1 AND t.network_id = ?2`,
78
+ tokenId, networkId,
79
+ );
80
+ if (!row) return null;
81
+ const aliases = new Set<string>();
82
+ if (row.alias) aliases.add(row.alias);
83
+ if (row.name?.startsWith("node:")) aliases.add(row.name.slice(5));
84
+ return {
85
+ networkId,
86
+ nodeId: row.bound_node_id,
87
+ logKey: row.bound_node_id ?? `token:${tokenId}`,
88
+ aliases: [...aliases],
89
+ ownerUserId: row.owner_user_id || row.user_id || null,
90
+ mode: normalizeMode(row.permission_mode),
91
+ };
92
+ }
93
+
94
+ export type Verdict = { reason: NodePermissionReason; sample?: string } | null;
95
+
96
+ /**
97
+ * 判一次:verdict=null → 放行;否则按原因和开关决定拒不拒,并记一行(off 时正常模式的原因不记)。
98
+ * 返回 true = 拒绝。
99
+ */
100
+ export function nodeDecide(id: NodeIdentity, route: string, verdict: Verdict): boolean {
101
+ if (!verdict) return false;
102
+ const explicit = MODE_REASONS.has(verdict.reason);
103
+ const flag = nodePermissionsFlag();
104
+ if (!explicit && flag === "off") return false;
105
+ recordNodePermission(id, route, verdict.reason, verdict.sample);
106
+ return explicit || flag === "enforce";
107
+ }
108
+
109
+ const HINTS: Record<NodePermissionReason, string> = {
110
+ beyond_owner_visibility: "this node acts with its owner's permissions, and its owner cannot see or edit this; ask the owner for access, or assign the task to this node",
111
+ agent_not_granted_to_owner: "this Agent has not been granted to this node's owner; ask a network admin to grant it",
112
+ mode_readonly: "this node is in read-only mode; the node's owner can switch it to normal in the app",
113
+ mode_restricted_not_assigned: "this node is in restricted mode and only touches tasks assigned to it; assign the task to this node, or have the owner switch it to normal",
114
+ human_only: "only people can do this (members, projects, permissions, providers, secrets, other nodes); use a user token",
115
+ owner_unknown: "this node's owner is unknown; recreate the node token from the owner's account",
116
+ };
117
+
118
+ export function nodePermissionDeniedBody(reason: NodePermissionReason, route: string): { ok: false; error: "node_permission_denied"; reason: NodePermissionReason; route: string; hint: string } {
119
+ return { ok: false, error: "node_permission_denied", reason, route, hint: HINTS[reason] };
120
+ }
121
+
122
+ // ── 记录:按 (网络, 节点, 路由, 原因, 小时) 合并;有上限、会清理 ──
123
+ export const LOG_RETENTION_DAYS = 30;
124
+ export const LOG_MAX_ROWS = 20_000;
125
+ const PRUNE_EVERY_MS = 60 * 60 * 1000;
126
+ let lastPrune = 0;
127
+ let logFull = false;
128
+
129
+ function hourOf(now = new Date()): string {
130
+ return now.toISOString().slice(0, 13); // 2026-10-03T08
131
+ }
132
+
133
+ /** 清掉保留期外的行,并重新判断是否到了上限(到上限后只给已有的行加次数,不再新开行)。 */
134
+ export function pruneNodePermissionLog(now = new Date()): void {
135
+ const cutoff = hourOf(new Date(now.getTime() - LOG_RETENTION_DAYS * 86_400_000));
136
+ db.run("DELETE FROM node_permission_log WHERE hour < ?1", [cutoff]);
137
+ const n = Number(db.get<{ n: number | string }>("SELECT COUNT(*) AS n FROM node_permission_log")?.n ?? 0);
138
+ logFull = n >= LOG_MAX_ROWS;
139
+ lastPrune = now.getTime();
140
+ }
141
+
142
+ export function recordNodePermission(id: NodeIdentity, route: string, reason: NodePermissionReason, sample?: string): void {
143
+ try {
144
+ const now = new Date();
145
+ if (now.getTime() - lastPrune > PRUNE_EVERY_MS) pruneNodePermissionLog(now);
146
+ const hour = hourOf(now);
147
+ const s = sample ? sample.slice(0, 200) : null;
148
+ const updated = db.run(
149
+ "UPDATE node_permission_log SET hits = hits + 1, sample = COALESCE(?6, sample) WHERE network_id = ?1 AND node_id = ?2 AND route = ?3 AND reason = ?4 AND hour = ?5",
150
+ [id.networkId, id.logKey, route.slice(0, 120), reason, hour, s],
151
+ );
152
+ if (Number(updated?.changes ?? 0) > 0 || logFull) return;
153
+ db.run(
154
+ `INSERT INTO node_permission_log (network_id, node_id, route, reason, hour, hits, sample) VALUES (?1, ?2, ?3, ?4, ?5, 1, ?6)
155
+ ON CONFLICT(network_id, node_id, route, reason, hour) DO UPDATE SET hits = node_permission_log.hits + 1`,
156
+ [id.networkId, id.logKey, route.slice(0, 120), reason, hour, s],
157
+ );
158
+ } catch (e) {
159
+ // 记录失败绝不影响请求本身。
160
+ console.error(`[node-permissions] log failed: ${(e as Error).message}`);
161
+ }
162
+ }
163
+
164
+ /** Test-only:让下一次记录先清理一遍,并可改上限判断。 */
165
+ export function __resetNodePermissionLogStateForTest(): void { lastPrune = 0; logFull = false; }
166
+
167
+ // ── 规则 ──
168
+
169
+ /** 派活 / 发消息 / 重派给 target。发给自己永远可以。 */
170
+ export function dispatchVerdict(id: NodeIdentity, target: AgentRef): Verdict {
171
+ if ((target.alias && id.aliases.includes(target.alias)) || (target.nodeId && id.nodeId && target.nodeId === id.nodeId)) return null;
172
+ const sample = target.alias ?? target.nodeId ?? undefined;
173
+ if (id.mode === "readonly") return { reason: "mode_readonly", sample };
174
+ if (!id.ownerUserId) return id.mode === "restricted" ? { reason: "mode_restricted_not_assigned", sample } : { reason: "owner_unknown" };
175
+ if (!canMessageAgent(id.ownerUserId, id.networkId, target)) {
176
+ // 受限是主人设的 → 必拒(模式原因);正常下是开关管的收紧。
177
+ return { reason: id.mode === "restricted" ? "mode_restricted_not_assigned" : "agent_not_granted_to_owner", sample };
178
+ }
179
+ return null;
180
+ }
181
+
182
+ /** 广播:只读 / 受限都不行(会发给没派给它、也未必授权的 Agent)。 */
183
+ export function broadcastVerdict(id: NodeIdentity): Verdict {
184
+ if (id.mode === "readonly") return { reason: "mode_readonly" };
185
+ if (id.mode === "restricted") return { reason: "mode_restricted_not_assigned" };
186
+ if (!id.ownerUserId) return { reason: "owner_unknown" };
187
+ return null;
188
+ }
189
+
190
+ /** 一般的写(管节点、提交技能、探测模型…):只读 / 受限拒;正常放行(由各自的既有规则管)。 */
191
+ export function writeVerdict(id: NodeIdentity, sample?: string): Verdict {
192
+ if (id.mode === "readonly") return { reason: "mode_readonly", sample };
193
+ if (id.mode === "restricted") return { reason: "mode_restricted_not_assigned", sample };
194
+ return null;
195
+ }
196
+
197
+ /** 只有人能做的事。只读 / 受限下报模式原因(那是主人设的、必拒);正常下报 human_only。 */
198
+ export function humanOnlyVerdict(id: NodeIdentity, sample?: string): Verdict {
199
+ return writeVerdict(id, sample) ?? { reason: "human_only", sample };
200
+ }
201
+
202
+ /** 订阅别的会话 / 整个网络的推送流:受限不行;正常按主人能不能看到那个 Agent。 */
203
+ export function streamVerdict(id: NodeIdentity, target: AgentRef | "network"): Verdict {
204
+ if (target !== "network" && ((target.alias && id.aliases.includes(target.alias)) || (target.nodeId && target.nodeId === id.nodeId))) return null;
205
+ if (id.mode === "restricted") return { reason: "mode_restricted_not_assigned", sample: target === "network" ? "network" : target.alias ?? undefined };
206
+ if (!id.ownerUserId) return { reason: "owner_unknown" };
207
+ if (target !== "network" && !canMessageAgent(id.ownerUserId, id.networkId, target)) {
208
+ return { reason: "beyond_owner_visibility", sample: target.alias ?? undefined };
209
+ }
210
+ return null;
211
+ }
212
+
213
+ // ── MCP:每个工具归一类(test 钉住「每个注册的工具都归了类」,新工具忘了归类会红) ──
214
+ export type ToolClass = "always" | "read" | "dispatch" | "broadcast" | "requirements" | "node_write" | "human_only";
215
+ export const NODE_TOOL_CLASS: Readonly<Record<string, ToolClass>> = {
216
+ // 回复 / 上报 / 自己的收件箱 / 生命周期协议(节点自己的请求)—— 任何模式都能做
217
+ report_status: "always", report_completion: "always", get_inbox: "always", ack_inbox: "always",
218
+ mark_tasks_runtime_submitted: "always", mark_tasks_consumed: "always", send_reply: "always", send_peer_reply: "always", send_ack: "always",
219
+ get_config_update: "always", ack_config_update: "always", get_rules_file_request: "always", ack_rules_file_request: "always", get_rules_file_result: "always",
220
+ get_create_request: "always", ack_create_request: "always", get_stop_request: "always", ack_stop_request: "always",
221
+ get_start_request: "always", ack_start_request: "always", get_probe_request: "always", ack_probe_request: "always",
222
+ list_my_pending_create_requests: "always", list_my_pending_lifecycle_requests: "always", list_my_children: "always",
223
+ send_desktop_message: "always", // 发给人的私信,不是派活
224
+ // 读
225
+ get_all_status: "read", get_session_status: "read", get_task: "read", list_tasks: "read", get_completions: "read",
226
+ list_skills: "read", get_skill: "read", list_host_supervisors: "read", list_network_secrets: "read", list_providers: "read",
227
+ get_probe_results: "read", read_node_rules_file: "read", list_node_skills: "read", read_node_skill: "read", list_node_files: "read",
228
+ read_node_file: "read", tail_node_logs: "read",
229
+ // 派活
230
+ send_task: "dispatch", send_message: "dispatch", retry_task: "dispatch", reassign_task: "dispatch", cancel_task: "dispatch",
231
+ broadcast: "broadcast",
232
+ // 任务看板:在 requirements.ts 里按卡判(REST 与 MCP 同一处)
233
+ requirements_list: "requirements", requirements_people: "requirements", requirements_get: "requirements", requirements_create: "requirements",
234
+ requirements_update: "requirements", requirements_checklist_toggle: "requirements", requirements_upsert_by_external_ref: "requirements",
235
+ requirements_events: "requirements", requirements_comment: "requirements", projects_list: "requirements",
236
+ // 写节点 / 技能 / 探测:只读、受限不行;正常由 RFC-036 等既有规则管
237
+ update_node_config: "node_write", write_node_rules_file: "node_write", restart_node: "node_write", create_node: "node_write",
238
+ stop_node: "node_write", delete_node: "node_write", start_node: "node_write", submit_skill: "node_write", probe_provider_model: "node_write",
239
+ // 只有人能做
240
+ upsert_network_secret: "human_only", upsert_provider: "human_only", update_provider: "human_only",
241
+ projects_create: "human_only", projects_update: "human_only", review_skill: "human_only",
242
+ };
@@ -208,7 +208,11 @@ describe("tool descriptions", () => {
208
208
  expect(update.description).toContain("REPLACES the whole list");
209
209
  expect(update.inputSchema.properties.participants.description).toContain("REPLACES");
210
210
  expect(Object.keys(update.inputSchema.properties)).toEqual(expect.arrayContaining(["status", "participants_add", "participants_remove"]));
211
- for (const name of ["projects_list", "projects_create", "projects_update"]) expect(byName.get(name).description).toMatch(/node \(Agent\) tokens can only read projects/i);
211
+ // #478:节点令牌的 tools/list 里本来就没有 projects_create / projects_update(调了也是 403),那两条的说明从人的列表里读。
212
+ expect(byName.has("projects_create")).toBe(false);
213
+ const human = new Map(((await rpc(U.member.token, "tools/list", {})).result.tools as any[]).map(t => [t.name, t]));
214
+ expect(byName.get("projects_list").description).toMatch(/node \(Agent\) tokens can only read projects/i);
215
+ for (const name of ["projects_create", "projects_update"]) expect(human.get(name).description).toMatch(/node \(Agent\) tokens can only read projects/i);
212
216
  const denied = await mcp(U.nodeA.token, "projects_create", { name: "示例项目" });
213
217
  expect(denied.status).toBe(403);
214
218
  expect(denied.error).toBe("user_token_required");
@@ -10,9 +10,10 @@ import { applyTagOp, normalizeTags, parseTagOp, storedTags, type TagOp } from ".
10
10
  import { aggregateStats, parseStatsQuery, type StatsRow } from "./requirements-stats.js";
11
11
  import { addHumanNetworkScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, resolveRestWriteNetworkId, type RestNetworkScope } from "./network-scope.js";
12
12
  import { isAgentRestricted, restrictedNetworkIds, visibleAgents } from "./agent-access.js";
13
+ import { nodeDecide, nodeIdentity, nodePermissionDeniedBody, nodePermissionsFlag, type NodeIdentity, type Verdict } from "./node-permissions.js";
13
14
  import {
14
15
  addProjectVisibilityScope, addTaskVisibilityScope, canDeleteTask, canEditTask, deletesAsHeadOnly, headOnlyEditScope, canParticipantEditTask, canSeeTask, canUseProject, deleteTaskGrantsForProject, projectUseResolver,
15
- isTaskScoped, PARTICIPANT_EDIT_FIELDS, shouldAuditDenied, taskPermissionsResolver, type TaskCaller,
16
+ cardAssignedToNode, isTaskScoped, PARTICIPANT_EDIT_FIELDS, shouldAuditDenied, taskPermissionsResolver, type TaskCaller,
16
17
  } from "./task-access.js";
17
18
  import { notifyLeaderDeleted, notifyParticipantChange } from "./requirement-notify.js";
18
19
  import { departmentCardsFor, departmentSubtree } from "./department-heads.js";
@@ -93,11 +94,67 @@ function jsonError(error: string, status: number, ctx?: ErrorContext): Response
93
94
  }
94
95
 
95
96
  // ── 任务的人员权限(RFC-038 §9,判定在 task-access.ts) ──
96
- // 节点令牌(Agent)、Hub 管理员、没有身份的旧全局令牌:不受任务权限约束(null)。
97
+ // Hub 管理员、没有身份的旧全局令牌:不受任务权限约束(null)。
98
+ // 节点令牌(RFC-041,#487):受限模式 → 按主人的权限且只碰派给它的卡;执行开关为 enforce → 按主人的权限;
99
+ // 其余(默认 log 下的正常 / 只读节点)→ null,与以前一样(只读的写在 nodeTaskGate 里拒)。
97
100
  function taskCaller(ctx: RequirementsRequestContext): TaskCaller {
98
- if (!ctx.auth || ctx.isNodeToken || ctx.isAdmin) return null;
101
+ if (!ctx.auth || ctx.isAdmin) return null;
102
+ if (ctx.isNodeToken) return nodeTaskCaller(ctx);
99
103
  return { userId: ctx.auth.userId };
100
104
  }
105
+
106
+ const nodeIdentities = new WeakMap<RequirementsRequestContext, NodeIdentity | null>();
107
+ function nodeIdent(ctx: RequirementsRequestContext): NodeIdentity | null {
108
+ if (!ctx.isNodeToken) return null;
109
+ if (!nodeIdentities.has(ctx)) nodeIdentities.set(ctx, nodeIdentity(ctx.auth?.tokenId, ctx.auth?.networkId));
110
+ return nodeIdentities.get(ctx) ?? null;
111
+ }
112
+ function nodeTaskCaller(ctx: RequirementsRequestContext): TaskCaller {
113
+ const id = nodeIdent(ctx);
114
+ if (!id?.ownerUserId) return null;
115
+ if (id.mode === "restricted") return { userId: id.ownerUserId, node: { id: id.logKey, only: true } };
116
+ if (nodePermissionsFlag() === "enforce") return { userId: id.ownerUserId, node: { id: id.logKey, only: false } };
117
+ return null;
118
+ }
119
+
120
+ /**
121
+ * RFC-041 —— 节点的一次任务写(建 / 改 / 勾检查项 / 评论 / upsert)先过这里。row=null 是新建(create 给出目标项目、
122
+ * 负责 Agent、父卡)。只读 → 必拒;受限 → 不是派给它的必拒;正常 → 主人看不见 / 改不了时按开关记录或拒。
123
+ */
124
+ function nodeTaskGate(ctx: RequirementsRequestContext, route: string, row: Row | null, create?: { networkId: string; projectId: string | null; agentOwnerJson: string | null; parentId: string | null }): Response | null {
125
+ const id = nodeIdent(ctx);
126
+ if (!id) return null;
127
+ const sample = row ? `#${row.seq ?? "?"} ${row.requirement_id}` : create?.projectId ?? undefined;
128
+ let verdict: Verdict = null;
129
+ if (id.mode === "readonly") verdict = { reason: "mode_readonly", sample };
130
+ else if (id.mode === "restricted") {
131
+ const nodeRef = JSON.stringify({ kind: "node", id: id.logKey });
132
+ const parent = create?.parentId ? db.get<Row>(`SELECT ${SELECT} FROM requirements WHERE requirement_id = ?1`, create.parentId) : undefined;
133
+ const assigned = row ? cardAssignedToNode(row, id.logKey) : create?.agentOwnerJson === nodeRef || (!!parent && cardAssignedToNode(parent, id.logKey));
134
+ if (!assigned) verdict = { reason: "mode_restricted_not_assigned", sample };
135
+ } else if (!id.ownerUserId) verdict = { reason: "owner_unknown", sample };
136
+ else {
137
+ const asOwner: TaskCaller = { userId: id.ownerUserId, node: { id: id.logKey, only: false } };
138
+ const ok = row ? canSeeTask(asOwner, row) && canEditTask(asOwner, row) : canUseProject(asOwner, create!.networkId, create!.projectId);
139
+ if (!ok) verdict = { reason: "beyond_owner_visibility", sample };
140
+ }
141
+ return nodeDecide(id, route, verdict) ? Response.json(nodePermissionDeniedBody(verdict!.reason, route), { status: 403 }) : null;
142
+ }
143
+
144
+ /** 正常模式的节点读到了主人看不见的卡:只记录(执行时 taskCaller 已按主人收窄,读不到)。主人不受任务范围限制时什么都不做。 */
145
+ function nodeReadNote(ctx: RequirementsRequestContext, route: string, row: Row | null): void {
146
+ const id = nodeIdent(ctx);
147
+ if (!id?.ownerUserId || id.mode === "restricted" || nodePermissionsFlag() !== "log" || !isTaskScoped(id.ownerUserId, id.networkId)) return;
148
+ const asOwner: TaskCaller = { userId: id.ownerUserId };
149
+ if (row) {
150
+ if (!canSeeTask(asOwner, row)) nodeDecide(id, route, { reason: "beyond_owner_visibility", sample: `#${row.seq ?? "?"} ${row.requirement_id}` });
151
+ return;
152
+ }
153
+ const all = Number(db.get<{ n: number | string }>("SELECT COUNT(*) AS n FROM requirements WHERE network_id = ?1", id.networkId)?.n ?? 0);
154
+ const params: unknown[] = [id.networkId];
155
+ const seen = Number(db.get<{ n: number | string }>(addTaskVisibilityScope("SELECT COUNT(*) AS n FROM requirements WHERE network_id = ?1", params, asOwner), ...params)?.n ?? 0);
156
+ if (all > seen) nodeDecide(id, route, { reason: "beyond_owner_visibility", sample: `list: ${all - seen} card(s) the owner cannot see` });
157
+ }
101
158
  /**
102
159
  * 看得见但没权限写:403,并按 (用户, 卡) 每小时最多记一条审计。
103
160
  * field:参与人改了参与人不能改的字段 —— 错误码不变(旧 App 照旧认 task_read_only),多带 field + 一句中文 message。
@@ -825,6 +882,8 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
825
882
  const createdAt = new Date().toISOString();
826
883
  let people;
827
884
  try { people = assignments(body, networkId, undefined, hiddenNodeFilter(ctx, networkId), ctx.strictOwner === true); } catch (e) { return errorFrom(e); }
885
+ const nodeDenied = nodeTaskGate(ctx, "POST /api/requirements", null, { networkId, projectId, agentOwnerJson: people.agentOwnerJson, parentId });
886
+ if (nodeDenied) return nodeDenied;
828
887
  const actor = JSON.stringify(actorOf(ctx));
829
888
  try {
830
889
  // 领号、插入、记「新建」这条动态在同一个事务里:插入被唯一索引挡下时,号和动态一起回滚,不留空洞。
@@ -862,6 +921,8 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
862
921
  if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(body, k))) return jsonError("empty_patch", 400, { sent: Object.keys(body), writable: PATCH_FIELDS });
863
922
  const has = (k: string) => Object.prototype.hasOwnProperty.call(body, k);
864
923
  if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
924
+ const nodeDenied = nodeTaskGate(ctx, "PATCH /api/requirements/:id", row);
925
+ if (nodeDenied) return nodeDenied;
865
926
  const caller = taskCaller(ctx);
866
927
  if (!canEditTask(caller, row)) {
867
928
  // 参与人:只放状态和检查项;别的字段点名拒绝。不是参与人 → 与今天一样。
@@ -1299,6 +1360,7 @@ function rememberList(key: string, entry: ListCacheEntry): void {
1299
1360
  function listCacheAcl(ctx: RequirementsRequestContext): string | null {
1300
1361
  const caller = taskCaller(ctx);
1301
1362
  if (!caller) return `open:${ctx.isAdmin ? 1 : 0}:${ctx.isNodeToken ? 1 : 0}`;
1363
+ if (caller.node) return null; // 按节点收窄的列表不缓存(模式随时可改)
1302
1364
  if (restrictedNetworkIds(caller.userId).length > 0) return null;
1303
1365
  const members = db.all<Record<string, unknown>>("SELECT * FROM network_members WHERE user_id = ?1 ORDER BY network_id", caller.userId);
1304
1366
  const networks = members.map(m => String(m.network_id));
@@ -1417,6 +1479,7 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
1417
1479
  if (changes && url.searchParams.get("updated_since") === null) return jsonError("updated_since_required", 400);
1418
1480
  // changes 模式:server_time 取在读表之前,下次拿它当 updated_since,读表期间的写入不会漏(>= 会重复回一次,客户端按 id 覆盖)。
1419
1481
  const serverTime = new Date().toISOString();
1482
+ nodeReadNote(ctx, "GET /api/requirements", null);
1420
1483
  const cacheKey = changes || url.searchParams.get("q") ? null : listCacheKey(ctx);
1421
1484
  const acl = cacheKey ? listCacheAcl(ctx) : null;
1422
1485
  const generation = requirementsGeneration;
@@ -1470,7 +1533,11 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
1470
1533
  return Response.json({ ...data, created: true }, { status: 201 });
1471
1534
  }
1472
1535
  const { external_ref: _ref, network_id: _net, client_id: _client, ...patch } = body;
1473
- if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(patch, k))) return Response.json({ ok: true, requirement: toPublicFor(ctx, existing), created: false });
1536
+ if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(patch, k))) {
1537
+ // 没有要改的字段 = 读回这张卡:受限节点只能读派给它的(与列表同一个可见范围)。
1538
+ if (!canSeeTask(taskCaller(ctx), existing)) return jsonError("upsert_not_allowed", 403);
1539
+ return Response.json({ ok: true, requirement: toPublicFor(ctx, existing), created: false });
1540
+ }
1474
1541
  const res = patchRequirement(ctx, existing, patch);
1475
1542
  if (res.status !== 200) return res;
1476
1543
  const data = await res.json() as Record<string, unknown>;
@@ -1491,6 +1558,8 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
1491
1558
  const row = scopedRow(ctx, decodeURIComponent(commentMatch[1]));
1492
1559
  if (row instanceof Response) return row;
1493
1560
  if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
1561
+ const nodeDenied = nodeTaskGate(ctx, "POST /api/requirements/:id/comments", row);
1562
+ if (nodeDenied) return nodeDenied;
1494
1563
  const at = new Date().toISOString();
1495
1564
  const event = db.transaction(() => {
1496
1565
  recordRequirementEvents(db, row, JSON.stringify(actorOf(ctx)), [{ kind: "comment", field: null, old: null, new: { text } }], at);
@@ -1508,6 +1577,8 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
1508
1577
  const row = scopedRow(ctx, decodeURIComponent(itemMatch[1]));
1509
1578
  if (row instanceof Response) return row;
1510
1579
  if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
1580
+ const nodeDenied = nodeTaskGate(ctx, "PATCH /api/requirements/:id/checklist/:item", row);
1581
+ if (nodeDenied) return nodeDenied;
1511
1582
  if (!canEditTask(taskCaller(ctx), row) && !canParticipantEditTask(taskCaller(ctx), row)) return taskDenied(ctx, row, "task_read_only");
1512
1583
  // 读-改-写在同一个同步段里完成(中间没有 await),同一进程里的两次勾选不会交错。
1513
1584
  const items = storedChecklist(row.checklist_json);
@@ -1533,6 +1604,7 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
1533
1604
  const id = decodeURIComponent(match[1]);
1534
1605
  if (req.method === "GET") {
1535
1606
  const row = scopedRow(ctx, id);
1607
+ if (!(row instanceof Response)) nodeReadNote(ctx, "GET /api/requirements/:id", row);
1536
1608
  return row instanceof Response ? row : Response.json({ ok: true, requirement: toPublicFor(ctx, row) });
1537
1609
  }
1538
1610
  if (req.method === "DELETE") {