@sleep2agi/commhub-server 0.9.0-preview.90 → 0.9.0-preview.92

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
+ };
@@ -159,8 +159,8 @@ describe("#461 GET /events/network/:id — auth", () => {
159
159
  test("membership removal revokes observer access — ntok exploit repro (通信龙 review finding 1)", async () => {
160
160
  // Exact repro of the review's exploit: add user C to network A, mint
161
161
  // an ntok BOUND to A, remove C from A — the same live ntok must lose
162
- // the stream. network_members IS the revocation mechanism because
163
- // removeNetworkMember does not revoke the user's tokens; the old
162
+ // the stream. (Before #488 network_members was the only revocation
163
+ // mechanism — removeNetworkMember did not revoke tokens; the old
164
164
  // `!role && authCtx.networkId !== observedNetId` OR-form let the
165
165
  // token's network binding bypass the membership check (utok would
166
166
  // not catch this: its authCtx.networkId is null, so only ntok
@@ -182,10 +182,12 @@ describe("#461 GET /events/network/:id — auth", () => {
182
182
 
183
183
  const removed = removeNetworkMember(memberNetworkId, outsiderUserId);
184
184
  expect(removed.ok).toBe(true);
185
- // The review's failing probe: /events/network/A with the still-live
186
- // network-bound token → must now be 403, not 200.
185
+ // The review's failing probe: /events/network/A with the network-bound
186
+ // token → must no longer be 200. Since #488 removeNetworkMember revokes
187
+ // the user's tokens for that network and resolveToken rejects a
188
+ // non-member's network token, so the token itself is refused (401).
187
189
  const afterRemoval = await fetch(`${BASE}/events/network/${memberNetworkId}`, { headers: auth(ntok) });
188
- expect(afterRemoval.status).toBe(403);
190
+ expect(afterRemoval.status).toBe(401);
189
191
  // utok of the removed user is denied too.
190
192
  const utokAfter = await fetch(`${BASE}/events/network/${memberNetworkId}`, { headers: auth(outsiderToken) });
191
193
  expect(utokAfter.status).toBe(403);
@@ -132,3 +132,34 @@ export function notifyParticipantChange(input: { before: NoticeCard; after: Noti
132
132
  if (pending.size > 5_000) for (const [k, p] of pending) if (now - p.startedAt >= COALESCE_WINDOW_MS) pending.delete(k);
133
133
  return sent;
134
134
  }
135
+
136
+ /**
137
+ * RFC-040 Q4:部门负责人(只凭负责人身份)删了本部门的一张卡 → 私信卡的负责人。
138
+ * 卡没有人类负责人时,私信负责 Agent 的主人(这张卡正是因为那个 Agent 才算「本部门的卡」)。
139
+ * 删的人就是收件人 / 收件人已不在网络 → 不发。返回收件人(测试用),没发 → null。
140
+ */
141
+ export function notifyLeaderDeleted(input: { card: NoticeCard & { agent_owner_json?: string | null }; actorUserId: string }): string | null {
142
+ const { card, actorUserId } = input;
143
+ let target = userIdOf(parse(card.owner_json));
144
+ if (!target) {
145
+ const agent = parse(card.agent_owner_json ?? null) as { kind?: unknown; id?: unknown } | null;
146
+ if (agent?.kind === "node" && typeof agent.id === "string") {
147
+ target = db.get<{ owner_user_id: string | null }>("SELECT owner_user_id FROM nodes WHERE node_id = ?1 AND network_id = ?2", agent.id, card.network_id)?.owner_user_id ?? null;
148
+ }
149
+ }
150
+ if (!target || target === actorUserId) return null;
151
+ const user = db.get<{ username: string; display_name: string | null }>("SELECT username, display_name FROM users WHERE user_id = ?1", actorUserId);
152
+ if (!user) return null;
153
+ const actorName = user.display_name?.trim() || user.username;
154
+ const number = card.seq ? ` #${card.seq}` : "";
155
+ const sent = sendNoticeDm({
156
+ networkId: card.network_id,
157
+ targetUserId: target,
158
+ sender: { userId: actorUserId, username: user.username },
159
+ title: "任务删除",
160
+ metaJson: JSON.stringify({ task_notice: { requirement_id: card.requirement_id, seq: card.seq ?? null, network_id: card.network_id, deleted: true } }),
161
+ messageId: `dm_task_${uuidv4()}`,
162
+ text: `${actorName}(部门负责人)删除了你负责的任务${number}「${card.title}」`,
163
+ });
164
+ return sent ? target : null;
165
+ }
@@ -48,6 +48,8 @@ const ENTRIES: Record<string, Entry> = {
48
48
  agent_owner_must_be_agent: { field: 'agent_owner', message: 'agent_owner (负责 Agent) must be a node.', hint: "Use agent_owner: {kind:'node', id}. To assign a person, use owner: {kind:'user', id}." },
49
49
  agent_owner_not_granted: { field: 'agent_owner', message: 'The current responsible Agent is one you are not granted, so you cannot replace or clear it.', hint: 'Leave agent_owner out of the patch.' },
50
50
  invalid_participants: { field: 'participants', message: 'participants must be an array of up to 100 persons {kind, id}; it replaces the whole list.', hint: 'Send the full list you want (read it first with requirements_get).' },
51
+ department_scope_denied: { field: 'owner', message: 'As a department head you can hand this task only to people in your department (or yourself) and to their Agents.', hint: 'Pick someone from your department, or ask an owner / admin to move it to another department.' },
52
+ department_not_found: { field: 'department_id', message: 'No department with that id in your network(s).', hint: 'Department ids come from GET /api/networks/{id}/departments.' },
51
53
  invalid_participants_delta: { field: 'participants_add', message: 'participants_add / participants_remove must each be an array of up to 100 persons ({kind, id}, {kind:"user", username} or {kind:"node", alias}).', hint: PERSON_IDS },
52
54
  participants_conflict: { field: 'participants', message: 'participants (replace the whole list) cannot be combined with participants_add / participants_remove.', hint: 'Use participants_add / participants_remove to change a few people, or participants alone to replace the list.' },
53
55
  participants_add_remove_overlap: { field: 'participants_remove', message: 'The same person is in both participants_add and participants_remove.', hint: 'Send each person in only one of the two lists.' },
@@ -10,11 +10,13 @@ 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
- addProjectVisibilityScope, addTaskVisibilityScope, canDeleteTask, canEditTask, canParticipantEditTask, canSeeTask, canUseProject, deleteTaskGrantsForProject, projectUseResolver,
15
- isTaskScoped, PARTICIPANT_EDIT_FIELDS, shouldAuditDenied, taskPermissionsResolver, type TaskCaller,
15
+ addProjectVisibilityScope, addTaskVisibilityScope, canDeleteTask, canEditTask, deletesAsHeadOnly, headOnlyEditScope, canParticipantEditTask, canSeeTask, canUseProject, deleteTaskGrantsForProject, projectUseResolver,
16
+ cardAssignedToNode, isTaskScoped, PARTICIPANT_EDIT_FIELDS, shouldAuditDenied, taskPermissionsResolver, type TaskCaller,
16
17
  } from "./task-access.js";
17
- import { notifyParticipantChange } from "./requirement-notify.js";
18
+ import { notifyLeaderDeleted, notifyParticipantChange } from "./requirement-notify.js";
19
+ import { departmentCardsFor, departmentSubtree } from "./department-heads.js";
18
20
  import { diffRequirement, ensureRequirementEvents, eventPublic, recordRequirementEvents, type EventRow } from "./requirement-events.js";
19
21
  import { ensureNetworkTags, ensureRequirementCompletedAt, ensureRequirementIndexes, ensureRequirementProjects, ensureRequirementSeq, ensureRequirementTombstones, migrateRequirementAgentOwners, migrateRequirementPriorityCheck, nextRequirementSeq } from "./requirements-migrate.js";
20
22
 
@@ -92,11 +94,67 @@ function jsonError(error: string, status: number, ctx?: ErrorContext): Response
92
94
  }
93
95
 
94
96
  // ── 任务的人员权限(RFC-038 §9,判定在 task-access.ts) ──
95
- // 节点令牌(Agent)、Hub 管理员、没有身份的旧全局令牌:不受任务权限约束(null)。
97
+ // Hub 管理员、没有身份的旧全局令牌:不受任务权限约束(null)。
98
+ // 节点令牌(RFC-041,#487):受限模式 → 按主人的权限且只碰派给它的卡;执行开关为 enforce → 按主人的权限;
99
+ // 其余(默认 log 下的正常 / 只读节点)→ null,与以前一样(只读的写在 nodeTaskGate 里拒)。
96
100
  function taskCaller(ctx: RequirementsRequestContext): TaskCaller {
97
- if (!ctx.auth || ctx.isNodeToken || ctx.isAdmin) return null;
101
+ if (!ctx.auth || ctx.isAdmin) return null;
102
+ if (ctx.isNodeToken) return nodeTaskCaller(ctx);
98
103
  return { userId: ctx.auth.userId };
99
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
+ }
100
158
  /**
101
159
  * 看得见但没权限写:403,并按 (用户, 卡) 每小时最多记一条审计。
102
160
  * field:参与人改了参与人不能改的字段 —— 错误码不变(旧 App 照旧认 task_read_only),多带 field + 一句中文 message。
@@ -824,6 +882,8 @@ async function createRequirement(ctx: RequirementsRequestContext, body: Record<s
824
882
  const createdAt = new Date().toISOString();
825
883
  let people;
826
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;
827
887
  const actor = JSON.stringify(actorOf(ctx));
828
888
  try {
829
889
  // 领号、插入、记「新建」这条动态在同一个事务里:插入被唯一索引挡下时,号和动态一起回滚,不留空洞。
@@ -861,6 +921,8 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
861
921
  if (!PATCH_FIELDS.some(k => Object.prototype.hasOwnProperty.call(body, k))) return jsonError("empty_patch", 400, { sent: Object.keys(body), writable: PATCH_FIELDS });
862
922
  const has = (k: string) => Object.prototype.hasOwnProperty.call(body, k);
863
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;
864
926
  const caller = taskCaller(ctx);
865
927
  if (!canEditTask(caller, row)) {
866
928
  // 参与人:只放状态和检查项;别的字段点名拒绝。不是参与人 → 与今天一样。
@@ -910,6 +972,15 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
910
972
  }
911
973
  let people;
912
974
  try { people = assignments(body, row.network_id, row, hiddenNodeFilter(ctx, row.network_id), ctx.strictOwner === true); } catch (e) { return errorFrom(e); }
975
+ // RFC-040:只凭负责人身份改这张卡时,负责人 / 负责 Agent 只能交给本部门的人(或自己)/ 本部门成员的 Agent,
976
+ // 不能把卡推出本部门(推出去等于替别的部门派活)。清空(null)不限制。
977
+ const headOnly = (has("owner") || has("agent_owner")) ? headOnlyEditScope(caller, row) : null;
978
+ if (headOnly) {
979
+ const owner = people.ownerJson ? JSON.parse(people.ownerJson) as PersonRef : null;
980
+ const agent = people.agentOwnerJson ? JSON.parse(people.agentOwnerJson) as PersonRef : null;
981
+ if (has("owner") && owner && owner.id !== caller!.userId && !headOnly.users.has(owner.id)) return jsonError("department_scope_denied", 403, { field: "owner" });
982
+ if (has("agent_owner") && agent && !headOnly.nodes.has(agent.id)) return jsonError("department_scope_denied", 403, { field: "agent_owner" });
983
+ }
913
984
  let projectId = row.project_id;
914
985
  if (has("project_id")) {
915
986
  try { projectId = projectRef(body.project_id, row.network_id); } catch (e) { return errorFrom(e); }
@@ -1043,6 +1114,23 @@ function listFilters(url: URL, sql: string, params: unknown[], ctx?: Requirement
1043
1114
  if (ref !== null) sql += ` AND external_ref = ?${params.push(ref)}`;
1044
1115
  const parent = q.get("parent_id");
1045
1116
  if (parent !== null) sql += parent === "none" ? " AND parent_id IS NULL" : ` AND parent_id = ?${params.push(parent)}`;
1117
+ // RFC-040:department_id= —— 负责人是这个部门(含下级)的成员、或负责 Agent 归这些成员所有的卡。可见范围照旧由上面的
1118
+ // addTaskVisibilityScope 管,这里只再收窄。部门按调用者看得见的网络找(部门 id 只在网络内唯一)。
1119
+ const dept = q.get("department_id");
1120
+ if (dept !== null) {
1121
+ if (!dept || !/^[A-Za-z0-9_-]{1,64}$/.test(dept)) return jsonError("department_not_found", 400);
1122
+ const networks = ctx ? departmentNetworks(ctx, dept) : [];
1123
+ if (!networks.length) return jsonError("department_not_found", 400);
1124
+ const parts: string[] = [];
1125
+ for (const networkId of networks) {
1126
+ const cards = departmentCardsFor(networkId, departmentSubtree(networkId, dept));
1127
+ const ors: string[] = [];
1128
+ if (cards.users.size) ors.push(`owner_json IN (${[...cards.users].map((id) => `?${params.push(JSON.stringify({ kind: "user", id }))}`).join(", ")})`);
1129
+ if (cards.nodes.size) ors.push(`agent_owner_json IN (${[...cards.nodes].map((id) => `?${params.push(JSON.stringify({ kind: "node", id }))}`).join(", ")})`);
1130
+ if (ors.length) parts.push(`(network_id = ?${params.push(networkId)} AND (${ors.join(" OR ")}))`);
1131
+ }
1132
+ sql += parts.length ? ` AND (${parts.join(" OR ")})` : " AND 1=0";
1133
+ }
1046
1134
  if (q.get("top_level") === "1") sql += " AND parent_id IS NULL";
1047
1135
  // Explicit archived-only wins over the legacy include-all switch.
1048
1136
  // changes=1 看得见「被归档」这一改动:默认连归档的一起回(行上 archived: true),客户端据此把它移出看板。
@@ -1051,6 +1139,13 @@ function listFilters(url: URL, sql: string, params: unknown[], ctx?: Requirement
1051
1139
  return sql;
1052
1140
  }
1053
1141
 
1142
+ /** department_id 筛选:调用者作用域里有这个部门的网络。 */
1143
+ function departmentNetworks(ctx: RequirementsRequestContext, deptId: string): string[] {
1144
+ const params: unknown[] = [deptId];
1145
+ const sql = addHumanNetworkScope("SELECT DISTINCT network_id FROM network_departments WHERE department_id = ?1", params, ctx.scope);
1146
+ return db.all<{ network_id: string }>(sql, ...params).map((r) => r.network_id);
1147
+ }
1148
+
1054
1149
  const LIST_ORDER = " ORDER BY created_at DESC, requirement_id DESC";
1055
1150
  type LightRow = { requirement_id: string; seq?: number | null; network_id: string; created_at: string; title: string; description: string | null; assignee: string | null; tags_json: string | null; project_id: string | null; owner_json: string | null; agent_owner_json: string | null; participants_json: string | null };
1056
1151
  const parseRef = (json: string | null) => { try { return json ? JSON.parse(json) : null; } catch { return null; } };
@@ -1160,7 +1255,7 @@ function deletedSince(ctx: RequirementsRequestContext, sinceIso: string): string
1160
1255
  const EVENTS_PAGE_DEFAULT = 200;
1161
1256
  const EVENTS_PAGE_MAX = 500;
1162
1257
  const EVENTS_SCAN_MAX = 5000;
1163
- type VisibilityRow = { network_id: string; owner_json: string | null; participants_json: string | null; created_by: string | null; created_by_json: string | null; project_id: string | null };
1258
+ type VisibilityRow = { network_id: string; owner_json: string | null; participants_json: string | null; created_by: string | null; created_by_json: string | null; project_id: string | null; agent_owner_json?: string | null };
1164
1259
 
1165
1260
  function listEvents(ctx: RequirementsRequestContext): Response {
1166
1261
  const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
@@ -1187,7 +1282,8 @@ function listEvents(ctx: RequirementsRequestContext): Response {
1187
1282
  let ok = seen.get(id);
1188
1283
  if (ok === undefined) {
1189
1284
  const cols = "network_id, owner_json, participants_json, created_by, created_by_json, project_id";
1190
- const row = db.get<VisibilityRow>(`SELECT ${cols} FROM requirements WHERE requirement_id = ?1`, id)
1285
+ // agent_owner_json:负责人按「负责 Agent 归本部门成员」也认得这张卡(RFC-040);墓碑表没有这一列。
1286
+ const row = db.get<VisibilityRow>(`SELECT ${cols}, agent_owner_json FROM requirements WHERE requirement_id = ?1`, id)
1191
1287
  ?? db.get<VisibilityRow>(`SELECT ${cols} FROM requirement_tombstones WHERE requirement_id = ?1`, id);
1192
1288
  seen.set(id, ok = !!row && row.network_id === networkId && canSeeTask(caller, row));
1193
1289
  }
@@ -1264,6 +1360,7 @@ function rememberList(key: string, entry: ListCacheEntry): void {
1264
1360
  function listCacheAcl(ctx: RequirementsRequestContext): string | null {
1265
1361
  const caller = taskCaller(ctx);
1266
1362
  if (!caller) return `open:${ctx.isAdmin ? 1 : 0}:${ctx.isNodeToken ? 1 : 0}`;
1363
+ if (caller.node) return null; // 按节点收窄的列表不缓存(模式随时可改)
1267
1364
  if (restrictedNetworkIds(caller.userId).length > 0) return null;
1268
1365
  const members = db.all<Record<string, unknown>>("SELECT * FROM network_members WHERE user_id = ?1 ORDER BY network_id", caller.userId);
1269
1366
  const networks = members.map(m => String(m.network_id));
@@ -1382,6 +1479,7 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
1382
1479
  if (changes && url.searchParams.get("updated_since") === null) return jsonError("updated_since_required", 400);
1383
1480
  // changes 模式:server_time 取在读表之前,下次拿它当 updated_since,读表期间的写入不会漏(>= 会重复回一次,客户端按 id 覆盖)。
1384
1481
  const serverTime = new Date().toISOString();
1482
+ nodeReadNote(ctx, "GET /api/requirements", null);
1385
1483
  const cacheKey = changes || url.searchParams.get("q") ? null : listCacheKey(ctx);
1386
1484
  const acl = cacheKey ? listCacheAcl(ctx) : null;
1387
1485
  const generation = requirementsGeneration;
@@ -1435,7 +1533,11 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
1435
1533
  return Response.json({ ...data, created: true }, { status: 201 });
1436
1534
  }
1437
1535
  const { external_ref: _ref, network_id: _net, client_id: _client, ...patch } = body;
1438
- 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
+ }
1439
1541
  const res = patchRequirement(ctx, existing, patch);
1440
1542
  if (res.status !== 200) return res;
1441
1543
  const data = await res.json() as Record<string, unknown>;
@@ -1456,6 +1558,8 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
1456
1558
  const row = scopedRow(ctx, decodeURIComponent(commentMatch[1]));
1457
1559
  if (row instanceof Response) return row;
1458
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;
1459
1563
  const at = new Date().toISOString();
1460
1564
  const event = db.transaction(() => {
1461
1565
  recordRequirementEvents(db, row, JSON.stringify(actorOf(ctx)), [{ kind: "comment", field: null, old: null, new: { text } }], at);
@@ -1473,6 +1577,8 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
1473
1577
  const row = scopedRow(ctx, decodeURIComponent(itemMatch[1]));
1474
1578
  if (row instanceof Response) return row;
1475
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;
1476
1582
  if (!canEditTask(taskCaller(ctx), row) && !canParticipantEditTask(taskCaller(ctx), row)) return taskDenied(ctx, row, "task_read_only");
1477
1583
  // 读-改-写在同一个同步段里完成(中间没有 await),同一进程里的两次勾选不会交错。
1478
1584
  const items = storedChecklist(row.checklist_json);
@@ -1498,6 +1604,7 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
1498
1604
  const id = decodeURIComponent(match[1]);
1499
1605
  if (req.method === "GET") {
1500
1606
  const row = scopedRow(ctx, id);
1607
+ if (!(row instanceof Response)) nodeReadNote(ctx, "GET /api/requirements/:id", row);
1501
1608
  return row instanceof Response ? row : Response.json({ ok: true, requirement: toPublicFor(ctx, row) });
1502
1609
  }
1503
1610
  if (req.method === "DELETE") {
@@ -1506,6 +1613,7 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
1506
1613
  if (row instanceof Response) return row;
1507
1614
  if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
1508
1615
  if (!canDeleteTask(taskCaller(ctx), row)) return taskDenied(ctx, row, "task_delete_denied");
1616
+ const byLeader = deletesAsHeadOnly(taskCaller(ctx), row);
1509
1617
  // 子需求不跟着删:先解挂(变成顶层),再删父卡。
1510
1618
  const deletedAt = new Date().toISOString();
1511
1619
  db.transaction(() => {
@@ -1517,6 +1625,11 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
1517
1625
  });
1518
1626
  // 硬删除以前不留痕(RFC-038 §9.1):记下是谁删了哪张(标题 + 短号),卡本身已经没了。
1519
1627
  if (ctx.auth) logAudit(ctx.auth.userId, ctx.auth.username || null, "requirement_deleted", "requirement", row.requirement_id, JSON.stringify({ title: row.title, seq: row.seq ?? null }).slice(0, 1000), undefined, row.network_id);
1628
+ // RFC-040 Q4:只凭部门负责人身份删的,另记一条并私信卡的负责人(人;没有就私信负责 Agent 的主人)。
1629
+ if (byLeader && ctx.auth) {
1630
+ const notified = notifyLeaderDeleted({ card: row, actorUserId: ctx.auth.userId });
1631
+ logAudit(ctx.auth.userId, ctx.auth.username || null, "requirement_deleted_by_leader", "requirement", row.requirement_id, JSON.stringify({ title: row.title, seq: row.seq ?? null, owner: row.owner_json ? JSON.parse(row.owner_json) : null, agent_owner: row.agent_owner_json ? JSON.parse(row.agent_owner_json) : null, notified }).slice(0, 1000), undefined, row.network_id);
1632
+ }
1520
1633
  return Response.json({ ok: true, deleted: row.requirement_id });
1521
1634
  }
1522
1635
  if (req.method !== "PATCH") return jsonError("not_found", 404);
@@ -32,6 +32,8 @@ const GOLDEN_RESPONSE_KEYS = {
32
32
  authMeNetwork: [
33
33
  "network_id", "network_name", "owner_id", "description", "settings",
34
34
  "created_at", "updated_at", "visibility", "max_members", "member_role", "agent_access", "task_access",
35
+ // RFC-040(#455):不是列,是每次请求现算的「我负责的部门 + 全部下级」(department-heads.ts)。
36
+ "managed_department_ids",
35
37
  ],
36
38
  networkDetail: [
37
39
  "network_id", "network_name", "owner_id", "description", "settings",