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

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.
Files changed (202) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +92 -11
  3. package/package.json +5 -5
  4. package/src/ack-create-request-transport.test.ts +159 -0
  5. package/src/ack-create-request.test.ts +5 -5
  6. package/src/admin-delete-network-http.test.ts +228 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/agent-access-batch.test.ts +210 -0
  9. package/src/agent-access.ts +478 -0
  10. package/src/agent-acl-groups-http.test.ts +247 -0
  11. package/src/agent-acl-http.test.ts +520 -0
  12. package/src/agent-acl-slice3-http.test.ts +267 -0
  13. package/src/agent-groups-perf.test.ts +190 -0
  14. package/src/alias-filter.test.ts +72 -0
  15. package/src/alias-filter.ts +32 -0
  16. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  17. package/src/api-host-supervisors-fallback.test.ts +271 -0
  18. package/src/api-nodes-shape.test.ts +26 -10
  19. package/src/auth-sessions-http.test.ts +250 -0
  20. package/src/auth-validate.test.ts +5 -1
  21. package/src/auth.ts +450 -31
  22. package/src/avatar-validate.test.ts +163 -0
  23. package/src/avatar-validate.ts +103 -0
  24. package/src/blocked-exit-on-reply.test.ts +122 -0
  25. package/src/boot-side-effect.test.ts +124 -0
  26. package/src/commhub-bin-args.test.ts +85 -0
  27. package/src/config-apply-sec1.test.ts +107 -0
  28. package/src/config-apply-validate.test.ts +92 -0
  29. package/src/config-apply-validate.ts +81 -3
  30. package/src/create-node-blocked-gate.test.ts +356 -0
  31. package/src/create-node-tool-schema.test.ts +39 -0
  32. package/src/create-node-validate.test.ts +89 -1
  33. package/src/create-node-validate.ts +89 -7
  34. package/src/create-node-workdir.test.ts +255 -0
  35. package/src/create-node.ts +46 -1
  36. package/src/dashboard-slash-routing-http.test.ts +107 -0
  37. package/src/db-adapter-guard.test.ts +148 -0
  38. package/src/db-adapter-translate.test.ts +128 -0
  39. package/src/db-adapter.ts +323 -87
  40. package/src/db-timestamp.test.ts +38 -0
  41. package/src/db-timestamp.ts +15 -0
  42. package/src/db.ts +805 -33
  43. package/src/delivered-without-push.test.ts +140 -0
  44. package/src/desktop-message-seam.test.ts +150 -0
  45. package/src/desktop-message.test.ts +368 -0
  46. package/src/dm-file-scope-http.test.ts +178 -0
  47. package/src/ext-token-shared.test.ts +60 -0
  48. package/src/ext-validation-regression.test.ts +131 -0
  49. package/src/external-schedule-edits-http.test.ts +321 -0
  50. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  51. package/src/external-schedule-edits.ts +327 -0
  52. package/src/file-download-authz-dev-open.test.ts +135 -0
  53. package/src/file-download-authz.test.ts +404 -0
  54. package/src/file-download-date-bucket.test.ts +436 -0
  55. package/src/file-network-scope-dev-open.test.ts +97 -0
  56. package/src/file-network-scope.test.ts +732 -0
  57. package/src/health-redaction.test.ts +642 -0
  58. package/src/http-gzip.test.ts +94 -0
  59. package/src/http-gzip.ts +110 -0
  60. package/src/hub-timestamp.test.ts +38 -0
  61. package/src/hub-timestamp.ts +27 -0
  62. package/src/human-dm.ts +215 -0
  63. package/src/inbox-count-sse.test.ts +310 -0
  64. package/src/inbox-count.ts +29 -0
  65. package/src/index.ts +15 -2331
  66. package/src/lifecycle-guard.ts +50 -0
  67. package/src/mcp-route-label-http.test.ts +120 -0
  68. package/src/mcp-write-network-resolution.test.ts +429 -0
  69. package/src/member-audit-network-id-http.test.ts +93 -0
  70. package/src/member-presence-http.test.ts +206 -0
  71. package/src/messages-ack-by-agent-http.test.ts +142 -0
  72. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  73. package/src/migrate-sqlite-to-pg.ts +263 -0
  74. package/src/network-name-http.test.ts +75 -0
  75. package/src/network-scope.ts +224 -0
  76. package/src/node-attrs-validate.ts +88 -0
  77. package/src/node-attrs.test.ts +217 -0
  78. package/src/node-create-request-status-http.test.ts +92 -0
  79. package/src/node-files-transport.test.ts +344 -0
  80. package/src/node-id-alias.test.ts +188 -0
  81. package/src/node-lifecycle-controllable.test.ts +64 -0
  82. package/src/node-lifecycle-controllable.ts +51 -0
  83. package/src/node-logs-transport.test.ts +263 -0
  84. package/src/node-request-retention.test.ts +281 -0
  85. package/src/node-request-retention.ts +120 -0
  86. package/src/node-skills-transport.test.ts +209 -0
  87. package/src/node-token-privilege-http.test.ts +149 -0
  88. package/src/observer-avatar-http.test.ts +429 -0
  89. package/src/observer-push.test.ts +252 -0
  90. package/src/package-version-consistency.test.ts +25 -0
  91. package/src/peer-reply-atomic.test.ts +455 -0
  92. package/src/peer-reply-capability.test.ts +70 -0
  93. package/src/pg-worker.ts +87 -0
  94. package/src/push.ts +284 -7
  95. package/src/redact-tokens.test.ts +65 -0
  96. package/src/redact-tokens.ts +40 -0
  97. package/src/rename.ts +7 -2
  98. package/src/reply-target-derivation.test.ts +96 -0
  99. package/src/report-status-host-schema.test.ts +211 -0
  100. package/src/report-status-resume-id-handover.test.ts +150 -0
  101. package/src/require-explicit-test-db.ts +9 -0
  102. package/src/requirement-events-http.test.ts +257 -0
  103. package/src/requirement-events.test.ts +45 -0
  104. package/src/requirement-events.ts +143 -0
  105. package/src/requirement-notify.ts +134 -0
  106. package/src/requirement-participant-notify-http.test.ts +309 -0
  107. package/src/requirement-tag-ops-http.test.ts +266 -0
  108. package/src/requirement-tags.ts +89 -0
  109. package/src/requirements-completed-at-migration.test.ts +70 -0
  110. package/src/requirements-etag-http.test.ts +91 -0
  111. package/src/requirements-http.test.ts +709 -0
  112. package/src/requirements-list-slim-http.test.ts +315 -0
  113. package/src/requirements-mcp.test.ts +163 -0
  114. package/src/requirements-migrate.ts +225 -0
  115. package/src/requirements-people-http.test.ts +114 -0
  116. package/src/requirements-priority-lowest.test.ts +114 -0
  117. package/src/requirements-search-http.test.ts +182 -0
  118. package/src/requirements-search.test.ts +32 -0
  119. package/src/requirements-search.ts +138 -0
  120. package/src/requirements-seq-http.test.ts +193 -0
  121. package/src/requirements-seq-migration.test.ts +63 -0
  122. package/src/requirements-start-date.test.ts +125 -0
  123. package/src/requirements-stats-http.test.ts +288 -0
  124. package/src/requirements-stats.ts +164 -0
  125. package/src/requirements-upgrade-e2e.test.ts +126 -0
  126. package/src/requirements-upgrade-migration.test.ts +98 -0
  127. package/src/requirements.ts +1439 -0
  128. package/src/resolve-port.test.ts +57 -0
  129. package/src/resolve-port.ts +44 -0
  130. package/src/response-charset.test.ts +130 -0
  131. package/src/rest-explicit-columns-http.test.ts +216 -0
  132. package/src/rest-identity.test.ts +129 -0
  133. package/src/rest-identity.ts +100 -0
  134. package/src/rest-projections.ts +68 -0
  135. package/src/rest-write-network-resolution.test.ts +133 -0
  136. package/src/restricted-files.ts +73 -0
  137. package/src/retention.ts +15 -3
  138. package/src/route-callers-http.test.ts +120 -0
  139. package/src/route-timing.test.ts +211 -0
  140. package/src/route-timing.ts +258 -0
  141. package/src/rules-file-duplicate-alias.test.ts +234 -0
  142. package/src/rules-file-session-target.test.ts +207 -0
  143. package/src/rules-file-transport.test.ts +237 -0
  144. package/src/runtime-label.test.ts +22 -0
  145. package/src/runtime-label.ts +21 -0
  146. package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
  147. package/src/scheduled-reply-unread-http.test.ts +212 -0
  148. package/src/scheduled-run-terminal.test.ts +268 -0
  149. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  150. package/src/scheduled-tasks-http.test.ts +638 -0
  151. package/src/scheduled-tasks.ts +597 -0
  152. package/src/send-dedup-request-id.test.ts +229 -0
  153. package/src/send-reply-agent-warning.test.ts +202 -0
  154. package/src/send-reply-alias-optional.test.ts +131 -0
  155. package/src/send-reply-attachments.test.ts +458 -0
  156. package/src/send_dedup.test.ts +54 -0
  157. package/src/send_dedup.ts +52 -6
  158. package/src/serve-error.test.ts +38 -0
  159. package/src/serve-error.ts +27 -0
  160. package/src/server.ts +4522 -0
  161. package/src/shared/external-schedule-contract.ts +60 -0
  162. package/src/side-thread-command-transport.test.ts +312 -0
  163. package/src/side-thread-command-transport.ts +318 -0
  164. package/src/side-thread-http-integration.test.ts +233 -0
  165. package/src/side-thread-http.ts +504 -0
  166. package/src/side-thread-production.test.ts +72 -0
  167. package/src/side-thread-production.ts +118 -0
  168. package/src/side-thread.test.ts +1067 -0
  169. package/src/side-thread.ts +2499 -0
  170. package/src/skillhub-http.test.ts +108 -0
  171. package/src/sqlite-close-checkpoint.test.ts +78 -0
  172. package/src/start-node.test.ts +109 -0
  173. package/src/status-alias-filter-http.test.ts +106 -0
  174. package/src/status-node-id-filter-http.test.ts +161 -0
  175. package/src/stop-delete-node.test.ts +1164 -0
  176. package/src/task-access-http.test.ts +379 -0
  177. package/src/task-access.ts +307 -0
  178. package/src/task-auth-origin-http.test.ts +91 -0
  179. package/src/task-auth-origin.test.ts +24 -0
  180. package/src/task-auth-origin.ts +7 -0
  181. package/src/task-consumption.test.ts +482 -0
  182. package/src/task-diagnostic-http.test.ts +149 -0
  183. package/src/task-diagnostic.test.ts +115 -0
  184. package/src/task-diagnostic.ts +118 -0
  185. package/src/task-idempotency-mcp.test.ts +142 -0
  186. package/src/task-idempotency.test.ts +60 -0
  187. package/src/task-idempotency.ts +75 -0
  188. package/src/task-lifecycle-watcher.test.ts +204 -0
  189. package/src/task-lifecycle-watcher.ts +49 -0
  190. package/src/task-network-resolution-http.test.ts +141 -0
  191. package/src/tasks-pagination-http.test.ts +141 -0
  192. package/src/tools-list-json-schema.test.ts +66 -0
  193. package/src/tools.ts +3534 -343
  194. package/src/update-provider.test.ts +65 -0
  195. package/src/upload-early-reject-guard.test.ts +155 -0
  196. package/src/uploads-http.test.ts +97 -15
  197. package/src/uploads.test.ts +66 -0
  198. package/src/uploads.ts +138 -5
  199. package/src/user-inbox-by-agent-http.test.ts +161 -0
  200. package/src/user-inbox-netid-notnull.test.ts +122 -0
  201. package/src/user-inbox-read-http.test.ts +223 -0
  202. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,307 @@
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
+ /**
137
+ * 参与人能改的字段(Vincent 2026-10-01「参与人可以改,但是改了之后需要有个通知」):状态(列)和检查项。
138
+ * 只对 canEditTask 为 false 的 scoped 参与人生效;标题 / 描述 / 负责人 / 参与人 / 项目 / 优先级 / 日期 / 标签等仍只给
139
+ * 负责人 / 创建者 / 可改项目授权。
140
+ */
141
+ export const PARTICIPANT_EDIT_FIELDS: readonly string[] = ["column", "checklist"];
142
+
143
+ /** 这个调用者是不是「只能以参与人身份改」这张卡(canEditTask 为 false、但他是卡上的参与人)。viewer 不算。 */
144
+ export function canParticipantEditTask(caller: TaskCaller, row: CardRow): boolean {
145
+ if (!caller) return false;
146
+ const m = memberRow(caller.userId, row.network_id);
147
+ if (!scopedRow(m) || m?.member_role === "viewer") return false;
148
+ return participates(row, caller.userId);
149
+ }
150
+
151
+ /** 卡上有没有这个用户当参与人(不看权限模式;通知用)。 */
152
+ export function isParticipant(row: Pick<CardRow, "participants_json">, userId: string): boolean {
153
+ return refIs(row.participants_json, userId);
154
+ }
155
+
156
+ /** 能删这张卡吗。scoped 成员只能删负责人是自己或自己建的。 */
157
+ export function canDeleteTask(caller: TaskCaller, row: CardRow): boolean {
158
+ if (!caller) return true;
159
+ const m = memberRow(caller.userId, row.network_id);
160
+ if (!scopedRow(m)) return true;
161
+ if (m?.member_role === "viewer") return false;
162
+ return ownsCard(row, caller.userId) || createdCard(row, caller.userId);
163
+ }
164
+
165
+ /**
166
+ * 给客户端画「只读 / 不能删」用的每卡权限。只对 scoped 调用者返回(其余 null = 与今天一样全能)。
167
+ * 一个请求里按网络缓存成员行与项目授权,列表 500 张卡不会变成 500 × 3 次查询。
168
+ */
169
+ // edit_fields:只在 edit=false、但调用者是这张卡的参与人时出现 —— 他能改的就是这几个字段(旧 App 不认这个键,照旧画只读)。
170
+ export type TaskPermissions = { edit: boolean; delete: boolean; edit_fields?: readonly string[] };
171
+ export function taskPermissionsResolver(caller: TaskCaller): (row: CardRow) => TaskPermissions | null {
172
+ if (!caller) return () => null;
173
+ const cache = new Map<string, { scoped: boolean; viewer: boolean; grants: Map<string, boolean> }>();
174
+ const ctxFor = (networkId: string) => {
175
+ let c = cache.get(networkId);
176
+ if (!c) {
177
+ const m = memberRow(caller.userId, networkId);
178
+ const scoped = scopedRow(m);
179
+ const grants = new Map<string, boolean>(scoped ? listProjectGrants(networkId, caller.userId).map((g) => [g.project_id, g.can_edit] as [string, boolean]) : []);
180
+ cache.set(networkId, c = { scoped, viewer: m?.member_role === "viewer", grants });
181
+ }
182
+ return c;
183
+ };
184
+ return (row: CardRow) => {
185
+ const c = ctxFor(row.network_id);
186
+ if (!c.scoped) return null;
187
+ if (c.viewer) return { edit: false, delete: false };
188
+ const mine = ownsCard(row, caller.userId) || createdCard(row, caller.userId);
189
+ const edit = mine || (row.project_id ? c.grants.get(row.project_id) === true : false);
190
+ if (!edit && participates(row, caller.userId)) return { edit, delete: mine, edit_fields: PARTICIPANT_EDIT_FIELDS };
191
+ return { edit, delete: mine };
192
+ };
193
+ }
194
+
195
+ /** 新建 / 挪卡时的目标项目:scoped 成员只能放进自己 can_edit 的项目(null = 不放项目,总是可以)。 */
196
+ export function canUseProject(caller: TaskCaller, networkId: string, projectId: string | null): boolean {
197
+ if (!caller || !projectId) return true;
198
+ if (!isTaskScoped(caller.userId, networkId)) return true;
199
+ return projectGrant(networkId, caller.userId, projectId)?.can_edit === true;
200
+ }
201
+
202
+ /**
203
+ * 列表 / 搜索 / #N 查找的可见性子句。只对调用者 scoped 的网络加条件,别的网络原样放行。
204
+ * 追加在已经按网络过滤过的 `FROM requirements WHERE …` 后面。
205
+ */
206
+ export function addTaskVisibilityScope(sql: string, params: unknown[], caller: TaskCaller): string {
207
+ if (!caller) return sql;
208
+ const scoped = taskScopedNetworks(caller.userId);
209
+ if (!scoped.length) return sql;
210
+ // 参数只在真正用到时才压进去:PostgreSQL 的预编译语句里出现「没被引用的 $n」会直接报错
211
+ // (推不出类型),SQLite 不在乎 —— 只有 viewer 的网络时,「我的卡」那段模式参数就不该出现。
212
+ const uid = params.push(caller.userId);
213
+ 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)`;
214
+ const members = scoped.filter((s) => !s.viewer).map((s) => s.networkId);
215
+ const viewers = scoped.filter((s) => s.viewer).map((s) => s.networkId);
216
+ const ph = (ids: string[]) => ids.map((id) => `?${params.push(id)}`).join(", ");
217
+ const all = scoped.map((s) => s.networkId);
218
+ const parts = [`network_id NOT IN (${ph(all)})`];
219
+ if (members.length) {
220
+ const pat = params.push(userRefPattern(caller.userId));
221
+ 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})`;
222
+ parts.push(`(network_id IN (${ph(members)}) AND (${mine} OR ${granted}))`);
223
+ }
224
+ if (viewers.length) parts.push(`(network_id IN (${ph(viewers)}) AND ${granted})`);
225
+ return `${sql} AND (${parts.join(" OR ")})`;
226
+ }
227
+
228
+ /** 项目列表的可见性:scoped 成员只看授权给自己的项目。 */
229
+ export function addProjectVisibilityScope(sql: string, params: unknown[], caller: TaskCaller, networkId: string): string {
230
+ if (!caller || !isTaskScoped(caller.userId, networkId)) return sql;
231
+ const uid = params.push(caller.userId);
232
+ const net = params.push(networkId);
233
+ return `${sql} AND project_id IN (SELECT project_id FROM network_member_project_grants WHERE user_id = ?${uid} AND network_id = ?${net})`;
234
+ }
235
+
236
+ export type ReplaceTaskGrantsResult =
237
+ | { ok: true; task_access: TaskAccessMode; project_grants: ProjectGrant[] }
238
+ | { ok: false; error: string; status: number; detail?: unknown };
239
+
240
+ const MAX_PROJECT_GRANTS = 500;
241
+
242
+ /**
243
+ * 整体替换某成员的任务授权。字段不传 = 保持原样(旧客户端不会误清)。
244
+ * 项目必须是本网络的;任何一条不合格 ⇒ 整批拒绝,不做部分写入。
245
+ */
246
+ export function replaceTaskGrants(input: { networkId: string; userId: string; taskAccess?: unknown; projectGrants?: unknown; actorUserId: string }): ReplaceTaskGrantsResult {
247
+ if (!db.get("SELECT 1 FROM network_members WHERE network_id = ?1 AND user_id = ?2", input.networkId, input.userId)) {
248
+ return { ok: false, error: "member_not_found", status: 404 };
249
+ }
250
+ let mode: TaskAccessMode | undefined;
251
+ if (input.taskAccess !== undefined) {
252
+ if (input.taskAccess !== "all" && input.taskAccess !== "scoped") return { ok: false, error: "invalid_task_access", status: 400 };
253
+ mode = input.taskAccess;
254
+ }
255
+ let rows: Array<{ project_id: string; can_edit: number }> | undefined;
256
+ if (input.projectGrants !== undefined) {
257
+ if (!Array.isArray(input.projectGrants)) return { ok: false, error: "project_grants_must_be_array", status: 400 };
258
+ if (input.projectGrants.length > MAX_PROJECT_GRANTS) return { ok: false, error: "too_many_grants", status: 400, detail: { limit: MAX_PROJECT_GRANTS } };
259
+ const seen = new Set<string>();
260
+ rows = [];
261
+ for (const [index, raw] of input.projectGrants.entries()) {
262
+ const item = typeof raw === "string" ? { project_id: raw } : (raw && typeof raw === "object" && !Array.isArray(raw) ? raw as { project_id?: unknown; can_edit?: unknown } : {});
263
+ const projectId = typeof item.project_id === "string" ? item.project_id.trim() : "";
264
+ if (!projectId) return { ok: false, error: "project_grant_needs_project_id", status: 400, detail: { index } };
265
+ if (item.can_edit !== undefined && typeof item.can_edit !== "boolean") return { ok: false, error: "can_edit_must_be_boolean", status: 400, detail: { index } };
266
+ if (!db.get("SELECT 1 FROM requirement_projects WHERE project_id = ?1 AND network_id = ?2", projectId, input.networkId)) {
267
+ return { ok: false, error: "project_not_in_network", status: 400, detail: { index, project_id: projectId } };
268
+ }
269
+ if (seen.has(projectId)) continue;
270
+ seen.add(projectId);
271
+ rows.push({ project_id: projectId, can_edit: item.can_edit === true ? 1 : 0 });
272
+ }
273
+ }
274
+ db.transaction(() => {
275
+ if (mode) db.run("UPDATE network_members SET task_access = ?1 WHERE network_id = ?2 AND user_id = ?3", [mode, input.networkId, input.userId]);
276
+ if (rows) {
277
+ db.run("DELETE FROM network_member_project_grants WHERE network_id = ?1 AND user_id = ?2", [input.networkId, input.userId]);
278
+ for (const row of rows) {
279
+ db.run(
280
+ "INSERT INTO network_member_project_grants (network_id, user_id, project_id, can_edit, created_by) VALUES (?1, ?2, ?3, ?4, ?5)",
281
+ [input.networkId, input.userId, row.project_id, row.can_edit, input.actorUserId],
282
+ );
283
+ }
284
+ }
285
+ });
286
+ return { ok: true, task_access: getTaskAccessMode(input.networkId, input.userId) ?? "scoped", project_grants: listProjectGrants(input.networkId, input.userId) };
287
+ }
288
+
289
+ /** 成员被移出网络 / 项目被删除时清掉对应授权。 */
290
+ export function deleteTaskGrantsForMember(networkId: string, userId: string): void {
291
+ db.run("DELETE FROM network_member_project_grants WHERE network_id = ?1 AND user_id = ?2", [networkId, userId]);
292
+ }
293
+ export function deleteTaskGrantsForProject(projectId: string): void {
294
+ db.run("DELETE FROM network_member_project_grants WHERE project_id = ?1", [projectId]);
295
+ }
296
+
297
+ // 被拒的写入审计限频:同一 (用户, 卡) 每小时最多记一条,别让重试刷爆审计表。
298
+ const deniedSeen = new Map<string, number>();
299
+ const DENIED_WINDOW_MS = 60 * 60 * 1000;
300
+ export function shouldAuditDenied(userId: string, requirementId: string, now = Date.now()): boolean {
301
+ const key = `${userId}\u0000${requirementId}`;
302
+ const last = deniedSeen.get(key);
303
+ if (last !== undefined && now - last < DENIED_WINDOW_MS) return false;
304
+ deniedSeen.set(key, now);
305
+ if (deniedSeen.size > 10_000) for (const [k, t] of deniedSeen) if (now - t >= DENIED_WINDOW_MS) deniedSeen.delete(k);
306
+ return true;
307
+ }
@@ -0,0 +1,91 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "fs";
3
+ import { join } from "path";
4
+ import { tmpdir } from "os";
5
+ import { createNetworkTokenForNode, register } from "./auth.js";
6
+ import { db } from "./db.js";
7
+
8
+ const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-auth-origin-http-"));
9
+ let server: any;
10
+ let base = "";
11
+ let userToken = "";
12
+ let nodeToken = "";
13
+ let networkId = "";
14
+ const TARGET = `auth-origin-http-target-${process.pid}`;
15
+ const TARGET_NODE_ID = `node_auth_origin_target_${process.pid}`;
16
+ const TARGET_RESUME_ID = `resume_auth_origin_target_${process.pid}`;
17
+
18
+ // 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
19
+ // 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
20
+ // (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
21
+ // 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
22
+ beforeAll(async () => {
23
+ process.env.COMMHUB_DB = process.env.COMMHUB_DB || join(PRIVATE_DB_DIR, "hub.db");
24
+ const username = `auth_origin_${Date.now()}`;
25
+ const registered = register(username, "AuthOriginTest123!", undefined, "seed");
26
+ expect(registered.ok).toBe(true);
27
+ userToken = registered.token!;
28
+ networkId = registered.network_id!;
29
+ const userId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", [username])!.user_id;
30
+ const minted = createNetworkTokenForNode(userId, networkId, "auth-origin-node");
31
+ expect(minted.ok).toBe(true);
32
+ nodeToken = minted.token!;
33
+ db.run(
34
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
35
+ VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
36
+ [TARGET_RESUME_ID, TARGET, TARGET_NODE_ID, networkId],
37
+ );
38
+ db.run(
39
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at, lifecycle_state)
40
+ VALUES (?1, ?2, ?2, ?3, datetime('now'), datetime('now'), 'active')`,
41
+ [TARGET_NODE_ID, TARGET, networkId],
42
+ );
43
+ const mod: any = await import("./server.js");
44
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
45
+ base = `http://127.0.0.1:${server.port}`;
46
+ }, 30_000);
47
+
48
+ afterAll(() => {
49
+ try { server?.stop?.(true); } catch {}
50
+ try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
51
+ });
52
+
53
+ async function postTask(token: string, from: string, marker: string) {
54
+ const response = await fetch(`${base}/api/task`, {
55
+ method: "POST",
56
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
57
+ body: JSON.stringify({
58
+ alias: TARGET,
59
+ from,
60
+ network_id: networkId,
61
+ task: marker,
62
+ meta: {
63
+ source: "dashboard-chat",
64
+ client_request_id: `dreq_${"a".repeat(31)}${marker.endsWith("user") ? "1" : "2"}`,
65
+ auth_origin: "user",
66
+ },
67
+ }),
68
+ });
69
+ const body = await response.json() as any;
70
+ expect([200, 202]).toContain(response.status);
71
+ const taskId = body.task_id ?? body.message_id;
72
+ expect(taskId).toBeTruthy();
73
+ const inbox = db.get<{ meta_json: string; task_id: string }>(
74
+ "SELECT meta_json, task_id FROM inbox WHERE id = ?1",
75
+ [taskId],
76
+ )!;
77
+ expect(inbox.task_id).toBe(taskId);
78
+ return JSON.parse(inbox.meta_json);
79
+ }
80
+
81
+ describe("POST /api/task server-authenticated origin", () => {
82
+ test("user token stamps user even when the client supplied another fact", async () => {
83
+ const meta = await postTask(userToken, "dashboard-user", "origin-user");
84
+ expect(meta.auth_origin).toBe("user");
85
+ });
86
+
87
+ test("node token cannot spoof Dashboard trust through REST metadata", async () => {
88
+ const meta = await postTask(nodeToken, "auth-origin-node", "origin-node");
89
+ expect(meta.auth_origin).toBe("node");
90
+ });
91
+ });
@@ -0,0 +1,24 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { stampTaskAuthOrigin } from "./task-auth-origin";
3
+
4
+ describe("stampTaskAuthOrigin", () => {
5
+ test("server auth fact overrides a client-spoofed origin", () => {
6
+ expect(stampTaskAuthOrigin({ source: "dashboard-chat", auth_origin: "user" }, "node")).toEqual({
7
+ source: "dashboard-chat",
8
+ auth_origin: "node",
9
+ });
10
+ });
11
+
12
+ test("preserves existing metadata fields", () => {
13
+ expect(stampTaskAuthOrigin({ client_request_id: "dreq_x", attachments: [] }, "user")).toEqual({
14
+ client_request_id: "dreq_x",
15
+ attachments: [],
16
+ auth_origin: "user",
17
+ });
18
+ });
19
+
20
+ test("does not manufacture metadata for old callers that supplied none", () => {
21
+ expect(stampTaskAuthOrigin(undefined, "legacy")).toBeUndefined();
22
+ expect(stampTaskAuthOrigin(null, "legacy")).toBeNull();
23
+ });
24
+ });
@@ -0,0 +1,7 @@
1
+ export type TaskAuthOrigin = "user" | "node" | "legacy";
2
+
3
+ /** Stamp task metadata from server authentication facts, overriding spoofed input. */
4
+ export function stampTaskAuthOrigin(meta: unknown, origin: TaskAuthOrigin): unknown {
5
+ if (!meta || typeof meta !== "object" || Array.isArray(meta)) return meta;
6
+ return { ...(meta as Record<string, unknown>), auth_origin: origin };
7
+ }