@sleep2agi/commhub-server 0.9.0-preview.7 → 0.9.0-preview.71

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 (170) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +10 -2
  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 +331 -0
  6. package/src/admin-delete-network-http.test.ts +177 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/agent-access.ts +461 -0
  9. package/src/agent-acl-groups-http.test.ts +247 -0
  10. package/src/agent-acl-http.test.ts +510 -0
  11. package/src/agent-acl-slice3-http.test.ts +267 -0
  12. package/src/agent-groups-perf.test.ts +167 -0
  13. package/src/alias-filter.test.ts +72 -0
  14. package/src/alias-filter.ts +32 -0
  15. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  16. package/src/api-host-supervisors-fallback.test.ts +271 -0
  17. package/src/api-nodes-shape.test.ts +26 -10
  18. package/src/auth-sessions-http.test.ts +250 -0
  19. package/src/auth-validate.test.ts +5 -1
  20. package/src/auth.ts +423 -29
  21. package/src/avatar-validate.test.ts +163 -0
  22. package/src/avatar-validate.ts +103 -0
  23. package/src/blocked-exit-on-reply.test.ts +122 -0
  24. package/src/boot-side-effect.test.ts +124 -0
  25. package/src/config-apply-sec1.test.ts +107 -0
  26. package/src/config-apply-validate.test.ts +92 -0
  27. package/src/config-apply-validate.ts +81 -3
  28. package/src/create-node-blocked-gate.test.ts +356 -0
  29. package/src/create-node-tool-schema.test.ts +39 -0
  30. package/src/create-node-validate.test.ts +89 -1
  31. package/src/create-node-validate.ts +89 -7
  32. package/src/create-node-workdir.test.ts +255 -0
  33. package/src/create-node.ts +51 -1
  34. package/src/dashboard-slash-routing-http.test.ts +107 -0
  35. package/src/db-adapter-guard.test.ts +85 -0
  36. package/src/db-adapter-translate.test.ts +112 -0
  37. package/src/db-adapter.ts +246 -83
  38. package/src/db-timestamp.test.ts +38 -0
  39. package/src/db-timestamp.ts +15 -0
  40. package/src/db.ts +799 -33
  41. package/src/delivered-without-push.test.ts +140 -0
  42. package/src/desktop-message-seam.test.ts +150 -0
  43. package/src/desktop-message.test.ts +368 -0
  44. package/src/ext-token-shared.test.ts +60 -0
  45. package/src/ext-validation-regression.test.ts +131 -0
  46. package/src/external-schedule-edits-http.test.ts +321 -0
  47. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  48. package/src/external-schedule-edits.ts +327 -0
  49. package/src/file-download-authz-dev-open.test.ts +135 -0
  50. package/src/file-download-authz.test.ts +404 -0
  51. package/src/file-download-date-bucket.test.ts +436 -0
  52. package/src/file-network-scope-dev-open.test.ts +97 -0
  53. package/src/file-network-scope.test.ts +732 -0
  54. package/src/health-redaction.test.ts +642 -0
  55. package/src/http-gzip.test.ts +73 -0
  56. package/src/http-gzip.ts +60 -0
  57. package/src/hub-timestamp.test.ts +38 -0
  58. package/src/hub-timestamp.ts +27 -0
  59. package/src/human-dm.ts +149 -0
  60. package/src/inbox-count-sse.test.ts +310 -0
  61. package/src/inbox-count.ts +29 -0
  62. package/src/index.ts +15 -2331
  63. package/src/lifecycle-guard.ts +50 -0
  64. package/src/mcp-write-network-resolution.test.ts +429 -0
  65. package/src/messages-ack-by-agent-http.test.ts +142 -0
  66. package/src/network-name-http.test.ts +75 -0
  67. package/src/network-scope.ts +224 -0
  68. package/src/node-attrs-validate.ts +88 -0
  69. package/src/node-attrs.test.ts +217 -0
  70. package/src/node-create-request-status-http.test.ts +92 -0
  71. package/src/node-files-transport.test.ts +344 -0
  72. package/src/node-id-alias.test.ts +188 -0
  73. package/src/node-lifecycle-controllable.test.ts +64 -0
  74. package/src/node-lifecycle-controllable.ts +51 -0
  75. package/src/node-logs-transport.test.ts +263 -0
  76. package/src/node-request-retention.test.ts +281 -0
  77. package/src/node-request-retention.ts +120 -0
  78. package/src/node-skills-transport.test.ts +209 -0
  79. package/src/node-token-privilege-http.test.ts +149 -0
  80. package/src/observer-avatar-http.test.ts +429 -0
  81. package/src/observer-push.test.ts +252 -0
  82. package/src/package-version-consistency.test.ts +25 -0
  83. package/src/peer-reply-atomic.test.ts +455 -0
  84. package/src/peer-reply-capability.test.ts +70 -0
  85. package/src/pg-worker.ts +87 -0
  86. package/src/push.ts +204 -7
  87. package/src/redact-tokens.test.ts +65 -0
  88. package/src/redact-tokens.ts +40 -0
  89. package/src/rename.ts +7 -2
  90. package/src/reply-target-derivation.test.ts +96 -0
  91. package/src/report-status-host-schema.test.ts +211 -0
  92. package/src/report-status-resume-id-handover.test.ts +150 -0
  93. package/src/require-explicit-test-db.ts +9 -0
  94. package/src/requirement-tags.ts +16 -0
  95. package/src/requirements-etag-http.test.ts +91 -0
  96. package/src/requirements-http.test.ts +708 -0
  97. package/src/requirements-mcp.test.ts +142 -0
  98. package/src/requirements-migrate.ts +83 -0
  99. package/src/requirements-priority-lowest.test.ts +114 -0
  100. package/src/requirements-start-date.test.ts +125 -0
  101. package/src/requirements-upgrade-e2e.test.ts +126 -0
  102. package/src/requirements-upgrade-migration.test.ts +85 -0
  103. package/src/requirements.ts +899 -0
  104. package/src/resolve-port.test.ts +57 -0
  105. package/src/resolve-port.ts +44 -0
  106. package/src/response-charset.test.ts +130 -0
  107. package/src/rest-explicit-columns-http.test.ts +216 -0
  108. package/src/rest-identity.test.ts +129 -0
  109. package/src/rest-identity.ts +100 -0
  110. package/src/rest-projections.ts +68 -0
  111. package/src/rest-write-network-resolution.test.ts +133 -0
  112. package/src/restricted-files.ts +73 -0
  113. package/src/retention.ts +10 -1
  114. package/src/rules-file-duplicate-alias.test.ts +234 -0
  115. package/src/rules-file-session-target.test.ts +207 -0
  116. package/src/rules-file-transport.test.ts +237 -0
  117. package/src/runtime-label.test.ts +22 -0
  118. package/src/runtime-label.ts +21 -0
  119. package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
  120. package/src/scheduled-run-terminal.test.ts +268 -0
  121. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  122. package/src/scheduled-tasks-http.test.ts +625 -0
  123. package/src/scheduled-tasks.ts +597 -0
  124. package/src/send-dedup-request-id.test.ts +229 -0
  125. package/src/send-reply-agent-warning.test.ts +202 -0
  126. package/src/send-reply-alias-optional.test.ts +131 -0
  127. package/src/send-reply-attachments.test.ts +458 -0
  128. package/src/send_dedup.test.ts +54 -0
  129. package/src/send_dedup.ts +52 -6
  130. package/src/serve-error.test.ts +38 -0
  131. package/src/serve-error.ts +27 -0
  132. package/src/server.ts +4385 -0
  133. package/src/shared/external-schedule-contract.ts +60 -0
  134. package/src/side-thread-command-transport.test.ts +302 -0
  135. package/src/side-thread-command-transport.ts +318 -0
  136. package/src/side-thread-http-integration.test.ts +233 -0
  137. package/src/side-thread-http.ts +504 -0
  138. package/src/side-thread-production.test.ts +72 -0
  139. package/src/side-thread-production.ts +118 -0
  140. package/src/side-thread.test.ts +1067 -0
  141. package/src/side-thread.ts +2495 -0
  142. package/src/skillhub-http.test.ts +92 -0
  143. package/src/start-node.test.ts +109 -0
  144. package/src/status-alias-filter-http.test.ts +106 -0
  145. package/src/stop-delete-node.test.ts +1164 -0
  146. package/src/task-auth-origin-http.test.ts +91 -0
  147. package/src/task-auth-origin.test.ts +24 -0
  148. package/src/task-auth-origin.ts +7 -0
  149. package/src/task-consumption.test.ts +482 -0
  150. package/src/task-diagnostic-http.test.ts +149 -0
  151. package/src/task-diagnostic.test.ts +115 -0
  152. package/src/task-diagnostic.ts +118 -0
  153. package/src/task-idempotency-mcp.test.ts +142 -0
  154. package/src/task-idempotency.test.ts +60 -0
  155. package/src/task-idempotency.ts +75 -0
  156. package/src/task-lifecycle-watcher.test.ts +204 -0
  157. package/src/task-lifecycle-watcher.ts +49 -0
  158. package/src/task-network-resolution-http.test.ts +141 -0
  159. package/src/tasks-pagination-http.test.ts +141 -0
  160. package/src/tools-list-json-schema.test.ts +66 -0
  161. package/src/tools.ts +3446 -257
  162. package/src/update-provider.test.ts +65 -0
  163. package/src/upload-early-reject-guard.test.ts +155 -0
  164. package/src/uploads-http.test.ts +97 -15
  165. package/src/uploads.test.ts +66 -0
  166. package/src/uploads.ts +130 -5
  167. package/src/user-inbox-by-agent-http.test.ts +161 -0
  168. package/src/user-inbox-netid-notnull.test.ts +122 -0
  169. package/src/user-inbox-read-http.test.ts +223 -0
  170. package/src/user-inbox-schema.test.ts +54 -0
package/src/auth.ts CHANGED
@@ -3,6 +3,8 @@
3
3
  */
4
4
  import { db, generateId, hashPassword, verifyPassword, hashToken, generateToken, generateUserToken, generateNetworkToken, uuidv4 } from "./db.js";
5
5
  import { WEAK_PASSWORDS } from "./password-dict.js";
6
+ import { NETWORK_REST_COLUMNS, NETWORK_REST_SELECT, sqlColumns } from "./rest-projections.js";
7
+ import { deleteAgentGrants, isAgentRestricted } from "./agent-access.js";
6
8
 
7
9
  // Round-6 A1 hardening — dummy hash for username-enumeration timing
8
10
  // close. We compute ONE scrypt hash of a throwaway password and reuse
@@ -41,6 +43,7 @@ export interface AuthResult {
41
43
  error?: string;
42
44
  user?: AuthUser;
43
45
  token?: string; // user token (utok_)
46
+ token_id?: string; // api_tokens.token_id of `token` — lets a client tell which row in GET /api/auth/sessions is itself
44
47
  network_token?: string; // network token (ntok_) for default network
45
48
  network_id?: string;
46
49
  // #261 P0-2 (2026-06-28): true when the logged-in user still has the
@@ -59,7 +62,7 @@ function validatePasswordStrength(password: string, label = "password"): string
59
62
  return null;
60
63
  }
61
64
 
62
- export function register(username: string, password: string, email?: string, displayName?: string): AuthResult {
65
+ export function register(username: string, password: string, email?: string, displayName?: string, opts: { issueTokens?: boolean; client?: SessionClientInfo } = {}): AuthResult {
63
66
  if (!username || username.length < 2) return { ok: false, error: "username must be at least 2 characters" };
64
67
  if (username.length > 50) return { ok: false, error: "username too long (max 50)" };
65
68
  if (!/^[a-zA-Z0-9_\-\u4e00-\u9fff]+$/.test(username)) return { ok: false, error: "username contains invalid characters" };
@@ -87,23 +90,54 @@ export function register(username: string, password: string, email?: string, dis
87
90
  [userId, username, pwHash, email || null, displayName || username, isFirstUser ? "admin" : "user"]
88
91
  );
89
92
 
90
- // Auto-create default network + add as owner member
93
+ // Auto-create the user's own network + add as owner member.
94
+ //
95
+ // Name it after the user rather than the literal "default". Every
96
+ // registration used to mint a network called "default", so a hub with N
97
+ // users showed N identical "default" rows in the Dashboard sidebar with
98
+ // no way to tell them apart (23 of them on the live hub, 22 empty).
99
+ // Three CLI call sites (bin/cli.ts debate / demo-social / pr-review) resolve
100
+ // a network as:
101
+ // me.user.default_network_id
102
+ // || networks.find(n => n.network_name === "default")
103
+ // || networks[0]
104
+ // Checked against the live hub: GET /api/auth/me returns
105
+ // {user_id, username, display_name, email, role} — there is no
106
+ // default_network_id field, and nothing in server/ ever writes one. So the
107
+ // first term is always undefined and the name lookup is the operative path,
108
+ // not a fallback.
109
+ //
110
+ // After this change that lookup misses for new users and they land on
111
+ // networks[0]. That still resolves to this network because listNetworks()
112
+ // is `ORDER BY created_at` ascending (auth.ts:296) and this row is the
113
+ // user's oldest. Reordering that query would silently point those three
114
+ // commands at the wrong network.
91
115
  const networkId = generateId("net");
92
116
  db.run(
93
117
  "INSERT INTO networks (network_id, network_name, owner_id, description) VALUES (?1, ?2, ?3, ?4)",
94
- [networkId, "default", userId, "Auto-created default network"]
118
+ [networkId, username, userId, `Auto-created network for ${username}`]
95
119
  );
96
120
  db.run(
97
121
  "INSERT INTO network_members (network_id, user_id, role) VALUES (?1, ?2, 'owner')",
98
122
  [networkId, userId]
99
123
  );
100
124
 
125
+ // 管理员代建账号(POST /api/admin/users)时不给管理员发这个用户的令牌:
126
+ // 令牌只该由用户本人登录时拿到。
127
+ if (opts.issueTokens === false) {
128
+ return {
129
+ ok: true,
130
+ user: { user_id: userId, username, display_name: displayName || username, email: email || null, role: isFirstUser ? "admin" : "user" },
131
+ network_id: networkId,
132
+ };
133
+ }
134
+
101
135
  // User token (utok_) — not bound to network, for CLI/Dashboard login
102
136
  const userToken = generateUserToken();
103
137
  const userTokenId = generateId("tok");
104
138
  db.run(
105
- "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
106
- [userTokenId, hashToken(userToken), userId, null, "user-login", "user"]
139
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, user_agent) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
140
+ [userTokenId, hashToken(userToken), userId, null, "user-login", "user", cleanClientField(opts.client?.label, 64), cleanClientField(opts.client?.userAgent, 256)]
107
141
  );
108
142
 
109
143
  // Network token (ntok_) — bound to default network, for agent-node
@@ -118,12 +152,13 @@ export function register(username: string, password: string, email?: string, dis
118
152
  ok: true,
119
153
  user: { user_id: userId, username, display_name: displayName || username, email: email || null, role: isFirstUser ? "admin" : "user" },
120
154
  token: userToken,
155
+ token_id: userTokenId,
121
156
  network_token: networkToken,
122
157
  network_id: networkId,
123
158
  };
124
159
  }
125
160
 
126
- export function login(username: string, password: string): AuthResult {
161
+ export function login(username: string, password: string, client: SessionClientInfo = {}): AuthResult {
127
162
  const user = db.get<any>(
128
163
  "SELECT user_id, username, password_hash, display_name, email, role, must_change_password FROM users WHERE username = ?1",
129
164
  username);
@@ -167,8 +202,8 @@ export function login(username: string, password: string): AuthResult {
167
202
  const userToken = generateUserToken();
168
203
  const tokenId = generateId("tok");
169
204
  db.run(
170
- "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
171
- [tokenId, hashToken(userToken), user.user_id, null, "user-login", "user"]
205
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, user_agent) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
206
+ [tokenId, hashToken(userToken), user.user_id, null, "user-login", "user", cleanClientField(client.label, 64), cleanClientField(client.userAgent, 256)]
172
207
  );
173
208
 
174
209
  // Find default network
@@ -184,6 +219,7 @@ export function login(username: string, password: string): AuthResult {
184
219
  ok: true,
185
220
  user: { user_id: user.user_id, username: user.username, display_name: user.display_name, email: user.email, role: user.role },
186
221
  token,
222
+ token_id: tokenId,
187
223
  network_id: networkId,
188
224
  // #261 P0-2 — only include field when truthy (back-compat; old clients
189
225
  // don't see this field at all unless their account is flagged).
@@ -191,35 +227,141 @@ export function login(username: string, password: string): AuthResult {
191
227
  };
192
228
  }
193
229
 
194
- /** Create a network-scoped token (ntok_) for a specific node */
195
- export function createNetworkTokenForNode(userId: string, networkId: string, nodeName: string): { ok: boolean; token?: string; error?: string } {
230
+ /**
231
+ * Create a network-scoped token (ntok_) for a specific node.
232
+ *
233
+ * RFC-036: callers that provide nodeId atomically establish the immutable
234
+ * node-owner binding before the plaintext token is returned. The 3-argument
235
+ * legacy form remains available for old clients, but its token has no bound
236
+ * node id and therefore cannot consume external-schedule edit intents.
237
+ */
238
+ export function createNetworkTokenForNode(userId: string, networkId: string, nodeName: string, nodeId?: string): { ok: boolean; token?: string; token_id?: string; node_id?: string; error?: string } {
196
239
  // Verify user is a member of this network with write access
197
240
  const role = getUserNetworkRole(userId, networkId);
198
241
  if (!role || role === "viewer") return { ok: false, error: "no write access to this network" };
242
+ if (isAgentRestricted(userId, networkId)) return { ok: false, error: "restricted members cannot create network tokens" };
243
+ if (!nodeName || nodeName.length > 200) return { ok: false, error: "invalid_node_name" };
244
+ if (nodeId !== undefined && (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(nodeId))) {
245
+ return { ok: false, error: "invalid_node_id" };
246
+ }
199
247
  const token = generateNetworkToken();
200
248
  const tokenId = generateId("tok");
201
- db.run(
202
- "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
203
- [tokenId, hashToken(token), userId, networkId, `node:${nodeName}`, "network"]
204
- );
205
- return { ok: true, token };
249
+ try {
250
+ db.transaction(() => {
251
+ if (nodeId) {
252
+ const existing = db.get<{ network_id: string | null; owner_user_id: string | null }>(
253
+ "SELECT network_id, owner_user_id FROM nodes WHERE node_id = ?1",
254
+ nodeId,
255
+ );
256
+ if (existing?.network_id && existing.network_id !== networkId) throw new Error("cross_network_node");
257
+ // Existing pre-RFC-036 rows have no trustworthy owner anchor. A token
258
+ // refresh must not turn knowledge of a legacy node_id into ownership.
259
+ // They remain read-only until a separately authorized migration.
260
+ if (existing && !existing.owner_user_id) throw new Error("node_owner_unclaimed");
261
+ if (existing?.owner_user_id && existing.owner_user_id !== userId) throw new Error("node_owner_mismatch");
262
+ db.run(
263
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, updated_at)
264
+ VALUES (?1, ?2, ?2, ?3, ?4, datetime('now'))
265
+ ON CONFLICT(node_id) DO UPDATE SET
266
+ node_name = COALESCE(nodes.node_name, ?2),
267
+ alias = COALESCE(nodes.alias, ?2),
268
+ network_id = COALESCE(nodes.network_id, ?3),
269
+ owner_user_id = COALESCE(nodes.owner_user_id, ?4),
270
+ updated_at = datetime('now')`,
271
+ [nodeId, nodeName, networkId, userId],
272
+ );
273
+ if (!existing) {
274
+ db.run(
275
+ `INSERT INTO audit_log (user_id, action, target_type, target_id, detail, network_id)
276
+ VALUES (?1, 'external_schedule.owner_claimed', 'node', ?2, ?3, ?4)`,
277
+ [userId, nodeId, JSON.stringify({ node_id: nodeId, network_id: networkId }), networkId],
278
+ );
279
+ }
280
+ }
281
+ db.run(
282
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, bound_node_id) VALUES (?1, ?2, ?3, ?4, ?5, 'network', ?6)",
283
+ [tokenId, hashToken(token), userId, networkId, `node:${nodeName}`, nodeId ?? null],
284
+ );
285
+ });
286
+ } catch (error: any) {
287
+ const code = String(error?.message || "node_token_create_failed");
288
+ if (code === "cross_network_node" || code === "node_owner_unclaimed" || code === "node_owner_mismatch") return { ok: false, error: code };
289
+ throw error;
290
+ }
291
+ return { ok: true, token, token_id: tokenId, ...(nodeId ? { node_id: nodeId } : {}) };
292
+ }
293
+
294
+ // ── 登录会话(login session)令牌 ──
295
+ // 「会话令牌」= scope='user' 且不绑网络的 utok_:登录 / 注册签发的 user-login、改密码后换发的
296
+ // password-change、管理员重置签发的 admin-reset。它们代表「某台设备上的一次登录」,
297
+ // 由 GET /api/auth/sessions 列出、可被「退出其他设备」撤销,并且**闲置 N 天自动过期**(滑动窗口,
298
+ // 以 last_used_at 计,从没用过的以 created_at 计)。
299
+ // 不受影响:POST /api/auth/tokens 显式创建的 API 令牌(scope='full')、节点 / 网络令牌(network_id 非空)。
300
+ // 它们是给脚本和节点长期用的凭据,由各自的管理入口撤销,不该因为一段时间没被用就失效。
301
+ const SESSION_TOKEN_SQL = "scope = 'user' AND network_id IS NULL";
302
+ // last_used_at 最多每小时写一次:每个请求都会解析令牌,原来每次都 UPDATE。
303
+ // 以小时为粒度对一个以天计的闲置窗口没有影响。
304
+ const LAST_USED_WRITE_INTERVAL_SECONDS = 3600;
305
+
306
+ export type SessionClientInfo = { label?: unknown; userAgent?: unknown };
307
+
308
+ function cleanClientField(value: unknown, max: number): string | null {
309
+ if (typeof value !== "string") return null;
310
+ const cleaned = value.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, max);
311
+ return cleaned || null;
312
+ }
313
+
314
+ /** 会话令牌闲置多少天后过期;COMMHUB_SESSION_IDLE_DAYS,默认 30,0 = 关闭闲置过期。每次读,测试可以改。 */
315
+ export function sessionIdleDays(): number {
316
+ const raw = process.env.COMMHUB_SESSION_IDLE_DAYS;
317
+ if (raw === undefined || raw.trim() === "") return 30;
318
+ const n = Number(raw);
319
+ return Number.isFinite(n) && n >= 0 ? n : 30;
320
+ }
321
+
322
+ // datetime('now', ?N) 的偏移参数;PgAdapter 把它译成 NOW() + $N::INTERVAL。
323
+ function idleCutoffOffset(): string | null {
324
+ const days = sessionIdleDays();
325
+ return days > 0 ? `-${Math.round(days * 86400)} seconds` : null;
326
+ }
327
+
328
+ // 已按闲置过期的会话令牌:只有 resolveToken 拒绝之后才查,用来把 401 说清楚是「过期」而不是「无效」。
329
+ export function isExpiredSessionToken(token: string): boolean {
330
+ const cutoff = idleCutoffOffset();
331
+ if (!token || !cutoff) return false;
332
+ const row = db.get<any>(
333
+ `SELECT token_id FROM api_tokens
334
+ WHERE token_hash = ?1 AND ${SESSION_TOKEN_SQL} AND revoked_at IS NULL
335
+ AND COALESCE(last_used_at, created_at) < datetime('now', ?2)`,
336
+ hashToken(token), cutoff);
337
+ return !!row;
206
338
  }
207
339
 
208
340
  export function resolveToken(token: string): { user: AuthUser; networkId: string | null; tokenName: string | null; tokenId: string | null } | null {
209
341
  const tHash = hashToken(token);
342
+ const cutoff = idleCutoffOffset();
210
343
  const row = db.get<any>(
211
344
  `SELECT t.token_id, t.user_id, t.network_id, t.scope, t.name AS token_name,
212
345
  u.username, u.display_name, u.email, u.role
213
346
  FROM api_tokens t JOIN users u ON t.user_id = u.user_id
214
347
  WHERE t.token_hash = ?1
215
348
  AND (t.expires_at IS NULL OR t.expires_at > datetime('now'))
216
- AND t.revoked_at IS NULL`,
217
- tHash);
349
+ AND t.revoked_at IS NULL${cutoff ? `
350
+ AND NOT (t.scope = 'user' AND t.network_id IS NULL AND COALESCE(t.last_used_at, t.created_at) < datetime('now', ?2))` : ""}`,
351
+ ...(cutoff ? [tHash, cutoff] : [tHash]));
218
352
 
219
353
  if (!row) return null;
220
354
 
221
- // Update last_used
222
- db.run("UPDATE api_tokens SET last_used_at = datetime('now') WHERE token_hash = ?1", [tHash]);
355
+ // 多用户 Agent 权限:网络令牌(ntok_ / 邀请码令牌)的权限是「这个网络里的一切」,
356
+ // 受限成员在受限网络里不能持有它 —— 否则拿它就绕过了授权过滤。这里是唯一的解析入口,
357
+ // 在这里拒绝,所有 REST / MCP / SSE 路径一起生效(含升级前就签发、后来才被设为受限的令牌)。
358
+ if (row.network_id && isAgentRestricted(row.user_id, row.network_id)) return null;
359
+
360
+ // Update last_used(节流:见 LAST_USED_WRITE_INTERVAL_SECONDS)
361
+ db.run(
362
+ "UPDATE api_tokens SET last_used_at = datetime('now') WHERE token_id = ?1 AND (last_used_at IS NULL OR last_used_at < datetime('now', ?2))",
363
+ [row.token_id, `-${LAST_USED_WRITE_INTERVAL_SECONDS} seconds`]
364
+ );
223
365
 
224
366
  return {
225
367
  user: { user_id: row.user_id, username: row.username, display_name: row.display_name, email: row.email, role: row.role },
@@ -235,7 +377,7 @@ export function resolveToken(token: string): { user: AuthUser; networkId: string
235
377
 
236
378
  export function getUserNetworks(userId: string) {
237
379
  return db.all<any>(
238
- "SELECT * FROM networks WHERE owner_id = ?1 ORDER BY created_at",
380
+ `SELECT ${NETWORK_REST_SELECT} FROM networks WHERE owner_id = ?1 ORDER BY created_at`,
239
381
  userId);
240
382
  }
241
383
 
@@ -280,7 +422,7 @@ export function listTokens(userId: string) {
280
422
  }
281
423
 
282
424
  export function renameNetwork(userId: string, networkId: string, newName: string): { ok: boolean; error?: string } {
283
- const net = db.get<any>("SELECT * FROM networks WHERE network_id = ?1", networkId);
425
+ const net = db.get<any>("SELECT owner_id FROM networks WHERE network_id = ?1", networkId);
284
426
  if (!net) return { ok: false, error: "network not found" };
285
427
  if (net.owner_id !== userId) return { ok: false, error: "not your network" };
286
428
  const dup = db.get<any>("SELECT network_id FROM networks WHERE owner_id = ?1 AND network_name = ?2", userId, newName);
@@ -290,13 +432,18 @@ export function renameNetwork(userId: string, networkId: string, newName: string
290
432
  }
291
433
 
292
434
  export function deleteNetwork(userId: string, networkId: string): { ok: boolean; error?: string } {
293
- const net = db.get<any>("SELECT * FROM networks WHERE network_id = ?1", networkId);
435
+ const net = db.get<any>("SELECT owner_id FROM networks WHERE network_id = ?1", networkId);
294
436
  if (!net) return { ok: false, error: "network not found" };
295
437
  if (net.owner_id !== userId) return { ok: false, error: "not your network" };
296
438
  // Check if any sessions/tasks still reference this network
297
439
  const sessions = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM sessions WHERE network_id = ?1", networkId);
298
440
  if (sessions && sessions.cnt > 0) return { ok: false, error: `network has ${sessions.cnt} active session(s) — stop them first` };
299
441
  db.run("DELETE FROM networks WHERE network_id = ?1 AND owner_id = ?2", [networkId, userId]);
442
+ // PR #519 codex catch: leaving membership rows behind made deleted
443
+ // networks count toward getUserNetworkIds — a stale row could flip a
444
+ // single-network user to "ambiguous" or auto-resolve writes INTO the
445
+ // deleted network. Remove memberships with the network.
446
+ db.run("DELETE FROM network_members WHERE network_id = ?1", [networkId]);
300
447
  return { ok: true };
301
448
  }
302
449
 
@@ -306,6 +453,7 @@ export function createToken(userId: string, name: string, networkId?: string): {
306
453
  const role = getUserNetworkRole(userId, networkId);
307
454
  if (!role) return { ok: false, error: "not a member of this network" };
308
455
  if (role === "viewer") return { ok: false, error: "viewer cannot create full-access network tokens" };
456
+ if (isAgentRestricted(userId, networkId)) return { ok: false, error: "restricted members cannot create network tokens" };
309
457
  }
310
458
  const token = generateToken();
311
459
  const tokenId = generateId("tok");
@@ -338,6 +486,47 @@ export function revokeOtherUserTokens(userId: string, exceptTokenId?: string | n
338
486
  return result.changes;
339
487
  }
340
488
 
489
+ export type LoginSession = {
490
+ token_id: string;
491
+ name: string;
492
+ created_at: string;
493
+ last_used_at: string | null;
494
+ client_label: string | null;
495
+ user_agent: string | null;
496
+ is_current: boolean;
497
+ };
498
+
499
+ /** 我的登录会话(未过期、未撤销),最近使用的在前。 */
500
+ export function listSessions(userId: string, currentTokenId: string | null): LoginSession[] {
501
+ const cutoff = idleCutoffOffset();
502
+ const rows = db.all<any>(
503
+ `SELECT token_id, name, created_at, last_used_at, client_label, user_agent FROM api_tokens
504
+ WHERE user_id = ?1 AND ${SESSION_TOKEN_SQL} AND revoked_at IS NULL${cutoff ? `
505
+ AND COALESCE(last_used_at, created_at) >= datetime('now', ?2)` : ""}
506
+ ORDER BY COALESCE(last_used_at, created_at) DESC, created_at DESC`,
507
+ ...(cutoff ? [userId, cutoff] : [userId]));
508
+ return rows.map((r) => ({
509
+ token_id: r.token_id,
510
+ name: r.name,
511
+ created_at: r.created_at,
512
+ last_used_at: r.last_used_at ?? null,
513
+ client_label: r.client_label ?? null,
514
+ user_agent: r.user_agent ?? null,
515
+ is_current: !!currentTokenId && r.token_id === currentTokenId,
516
+ }));
517
+ }
518
+
519
+ /** 撤销我的一个登录会话;可以是当前这个(= 退出登录)。不是会话令牌(API 令牌 / 节点令牌)时当作不存在。 */
520
+ export function revokeSession(userId: string, tokenId: string): { ok: boolean; error?: string } {
521
+ const result = db.run(`DELETE FROM api_tokens WHERE token_id = ?1 AND user_id = ?2 AND ${SESSION_TOKEN_SQL}`, [tokenId, userId]);
522
+ return result.changes > 0 ? { ok: true } : { ok: false, error: "session_not_found" };
523
+ }
524
+
525
+ /** 退出其他所有设备:撤销我除当前之外的全部登录会话(含已闲置过期的)。API 令牌、节点令牌不动。 */
526
+ export function revokeOtherSessions(userId: string, currentTokenId: string): number {
527
+ return db.run(`DELETE FROM api_tokens WHERE user_id = ?1 AND ${SESSION_TOKEN_SQL} AND token_id != ?2`, [userId, currentTokenId]).changes;
528
+ }
529
+
341
530
  export function changePassword(userId: string, oldPassword: string, newPassword: string, currentTokenId?: string | null): { ok: boolean; error?: string; revoked?: number } {
342
531
  const passwordError = validatePasswordStrength(newPassword, "new password");
343
532
  if (passwordError) return { ok: false, error: passwordError };
@@ -391,22 +580,56 @@ export function resetUserPassword(targetUsername: string, callerIsHubAdmin: bool
391
580
 
392
581
  export function getNetworkMembers(networkId: string) {
393
582
  return db.all<any>(
394
- `SELECT nm.user_id, nm.role, nm.joined_at, nm.invited_by, u.username, u.display_name
583
+ `SELECT nm.user_id, nm.role, nm.joined_at, nm.invited_by, u.username, u.display_name,
584
+ CASE WHEN u.role = 'admin' OR nm.role IN ('owner', 'admin') OR nm.agent_access = 'all' THEN 'all' ELSE 'granted' END AS agent_access,
585
+ (SELECT COUNT(*) FROM network_member_agent_grants g WHERE g.network_id = nm.network_id AND g.user_id = nm.user_id) AS agent_grant_count,
586
+ (SELECT COUNT(*) FROM network_member_group_grants gg WHERE gg.network_id = nm.network_id AND gg.user_id = nm.user_id) AS agent_group_count
395
587
  FROM network_members nm JOIN users u ON nm.user_id = u.user_id
396
588
  WHERE nm.network_id = ?1 ORDER BY nm.joined_at`,
397
589
  networkId);
398
590
  }
399
591
 
592
+ /** 网络里的人类成员(给受限成员也能看的通讯录):只有身份字段,不含角色与授权。 */
593
+ export function getNetworkHumans(networkId: string) {
594
+ return db.all<{ user_id: string; username: string; display_name: string | null }>(
595
+ `SELECT u.user_id, u.username, u.display_name
596
+ FROM network_members nm JOIN users u ON nm.user_id = u.user_id
597
+ WHERE nm.network_id = ?1 ORDER BY COALESCE(NULLIF(u.display_name, ''), u.username), u.user_id`,
598
+ networkId);
599
+ }
600
+
400
601
  export function getUserNetworkRole(userId: string, networkId: string): string | null {
401
602
  const row = db.get<any>("SELECT role FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId);
402
603
  return row?.role || null;
403
604
  }
404
605
 
405
- export function addNetworkMember(networkId: string, userId: string, role: string, invitedBy?: string): { ok: boolean; error?: string } {
606
+ const MEMBER_ROLES = new Set(["admin", "member", "viewer"]);
607
+
608
+ /**
609
+ * 用户名与本网络某个 Agent 的 alias 相同时不能入网:用户的私人 SSE 频道、inbox、
610
+ * 任务往来都按「用户名」寻址,与 alias 撞名会让受限成员收到那个 Agent 的流量。
611
+ * 只在入网时查(管理员 / 邀请码持有者才触发),不放在公开的 /api/auth/register 上,
612
+ * 免得注册接口变成 alias 探测器。
613
+ */
614
+ export function usernameCollidesWithAgent(networkId: string, userId: string): boolean {
615
+ return !!db.get(
616
+ `SELECT 1 FROM users u WHERE u.user_id = ?2 AND (
617
+ EXISTS (SELECT 1 FROM sessions s WHERE s.network_id = ?1 AND s.alias = u.username)
618
+ OR EXISTS (SELECT 1 FROM nodes n WHERE n.network_id = ?1 AND n.alias = u.username))`,
619
+ networkId, userId,
620
+ );
621
+ }
622
+
623
+ // 新成员默认 agent_access='granted'(零 Agent 权限),owner/admin 角色不看这一列。
624
+ export function addNetworkMember(networkId: string, userId: string, role: string, invitedBy?: string, opts: { agentAccess?: "all" | "granted" } = {}): { ok: boolean; error?: string } {
625
+ if (!MEMBER_ROLES.has(role)) return { ok: false, error: "invalid role" };
626
+ const agentAccess = opts.agentAccess === "all" ? "all" : "granted";
627
+ if (!db.get("SELECT 1 FROM users WHERE user_id = ?1", userId)) return { ok: false, error: "user not found" };
406
628
  const existing = db.get<any>("SELECT 1 FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId);
407
629
  if (existing) return { ok: false, error: "user already a member" };
408
- db.run("INSERT INTO network_members (network_id, user_id, role, invited_by) VALUES (?1, ?2, ?3, ?4)",
409
- [networkId, userId, role, invitedBy || null]);
630
+ if (usernameCollidesWithAgent(networkId, userId)) return { ok: false, error: "username_collides_with_agent_alias" };
631
+ db.run("INSERT INTO network_members (network_id, user_id, role, invited_by, agent_access) VALUES (?1, ?2, ?3, ?4, ?5)",
632
+ [networkId, userId, role, invitedBy || null, agentAccess]);
410
633
  return { ok: true };
411
634
  }
412
635
 
@@ -422,6 +645,7 @@ export function removeNetworkMember(networkId: string, userId: string): { ok: bo
422
645
  if (!member) return { ok: false, error: "not a member" };
423
646
  if (member.role === "owner") return { ok: false, error: "cannot remove owner" };
424
647
  db.run("DELETE FROM network_members WHERE network_id = ?1 AND user_id = ?2", [networkId, userId]);
648
+ deleteAgentGrants(networkId, userId);
425
649
  return { ok: true };
426
650
  }
427
651
 
@@ -444,7 +668,10 @@ export function createInvite(networkId: string, createdBy: string, role: string
444
668
  }
445
669
 
446
670
  export function joinByInvite(inviteCode: string, userId: string): { ok: boolean; network_id?: string; role?: string; error?: string } {
447
- const invite = db.get<any>("SELECT * FROM network_invites WHERE invite_code = ?1", inviteCode);
671
+ const invite = db.get<any>(
672
+ "SELECT invite_code, network_id, role, created_by, max_uses, used_count, expires_at, created_at FROM network_invites WHERE invite_code = ?1",
673
+ inviteCode,
674
+ );
448
675
  if (!invite) return { ok: false, error: "invalid invite code" };
449
676
  if (invite.max_uses > 0 && invite.used_count >= invite.max_uses) return { ok: false, error: "invite code fully used" };
450
677
  if (invite.expires_at) {
@@ -454,10 +681,13 @@ export function joinByInvite(inviteCode: string, userId: string): { ok: boolean;
454
681
  // Check not already member
455
682
  const existing = db.get<any>("SELECT 1 FROM network_members WHERE network_id = ?1 AND user_id = ?2", invite.network_id, userId);
456
683
  if (existing) return { ok: false, error: "already a member of this network" };
684
+ if (usernameCollidesWithAgent(invite.network_id, userId)) return { ok: false, error: "username_collides_with_agent_alias" };
457
685
  // Add member + increment used count
458
- db.run("INSERT INTO network_members (network_id, user_id, role, invited_by) VALUES (?1, ?2, ?3, ?4)",
686
+ db.run("INSERT INTO network_members (network_id, user_id, role, invited_by, agent_access) VALUES (?1, ?2, ?3, ?4, 'granted')",
459
687
  [invite.network_id, userId, invite.role, invite.created_by]);
460
688
  db.run("UPDATE network_invites SET used_count = used_count + 1 WHERE invite_code = ?1", [inviteCode]);
689
+ // 受限成员不能持有网络令牌(resolveToken 会拒),别签一个用不了的。
690
+ if (isAgentRestricted(userId, invite.network_id)) return { ok: true, network_id: invite.network_id, role: invite.role };
461
691
  // Auto-create a token for this network
462
692
  const token = generateToken();
463
693
  const tokenId = generateId("tok");
@@ -469,8 +699,172 @@ export function joinByInvite(inviteCode: string, userId: string): { ok: boolean;
469
699
  /** Get all networks a user is a member of (replaces owner-only query) */
470
700
  export function getUserAllNetworks(userId: string) {
471
701
  return db.all<any>(
472
- `SELECT n.*, nm.role as member_role
702
+ `SELECT ${sqlColumns(NETWORK_REST_COLUMNS, "n")}, nm.role as member_role,
703
+ -- 多用户 Agent 权限:客户端据此显示「还没有被分配任何 Agent,请联系管理员」。
704
+ CASE WHEN u.role = 'admin' OR nm.role IN ('owner', 'admin') OR nm.agent_access = 'all' THEN 'all' ELSE 'granted' END AS agent_access
473
705
  FROM networks n JOIN network_members nm ON n.network_id = nm.network_id
706
+ JOIN users u ON u.user_id = nm.user_id
474
707
  WHERE nm.user_id = ?1 ORDER BY nm.role = 'owner' DESC, n.created_at`,
475
708
  userId);
476
709
  }
710
+
711
+ // ══════════════════════════════════════
712
+ // 多用户账号:管理员建号
713
+ // ══════════════════════════════════════
714
+
715
+ export type AdminCreateUserInput = {
716
+ username?: unknown;
717
+ password?: unknown;
718
+ display_name?: unknown;
719
+ email?: unknown;
720
+ network_id?: unknown;
721
+ role?: unknown;
722
+ };
723
+
724
+ export type AdminCreateUserResult =
725
+ | { ok: true; user: AuthUser; network_id: string; membership: { network_id: string; role: string; agent_access: "granted" } | null }
726
+ | { ok: false; error: string; status: number };
727
+
728
+ /**
729
+ * 管理员(或某网络的 owner/admin,限于把人建进自己管的网络)代建账号。
730
+ * 走 register() 的全部规则(用户名、密码 ≥ 8 且不是弱密码、自动建个人网络),
731
+ * 但不签发令牌 —— 用户自己登录才拿令牌。
732
+ * 可选一步把新用户加进 network_id,默认 role=member、agent_access='granted'(零 Agent 权限)。
733
+ * 建号与入网在同一事务里:入网失败(如用户名与 Agent alias 撞名)时账号也不落库。
734
+ */
735
+ export function adminCreateUser(input: AdminCreateUserInput, actor: { userId: string; isHubAdmin: boolean }): AdminCreateUserResult {
736
+ const str = (v: unknown) => (typeof v === "string" ? v.trim() : "");
737
+ const username = str(input.username);
738
+ const password = typeof input.password === "string" ? input.password : "";
739
+ const displayName = str(input.display_name) || undefined;
740
+ const email = str(input.email) || undefined;
741
+ const networkId = str(input.network_id) || null;
742
+ const role = str(input.role) || "member";
743
+
744
+ if (networkId) {
745
+ if (!db.get("SELECT 1 FROM networks WHERE network_id = ?1", networkId)) return { ok: false, error: "network_not_found", status: 404 };
746
+ if (!MEMBER_ROLES.has(role)) return { ok: false, error: "invalid role", status: 400 };
747
+ const actorRole = getUserNetworkRole(actor.userId, networkId);
748
+ if (!actor.isHubAdmin && actorRole !== "owner" && actorRole !== "admin") return { ok: false, error: "owner/admin required", status: 403 };
749
+ // 网络 admin 不能建出和自己平级的 admin;只有 owner / Hub 管理员可以。
750
+ if (role === "admin" && !actor.isHubAdmin && actorRole !== "owner") return { ok: false, error: "owner required to add admins", status: 403 };
751
+ } else if (!actor.isHubAdmin) {
752
+ return { ok: false, error: "admin required", status: 403 };
753
+ }
754
+ // 首个用户会被 register() 自动设成 Hub 管理员;代建路径上永远不会是首个用户(调用者本身就是用户)。
755
+ // 但仍显式拒绝,防止空库 + 旧令牌之类的边角把代建变成「造管理员」。
756
+ const userCount = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM users");
757
+ if (!userCount || userCount.cnt === 0) return { ok: false, error: "bootstrap_via_register", status: 400 };
758
+
759
+ class Rollback extends Error { constructor(readonly result: AdminCreateUserResult) { super("rollback"); } }
760
+ try {
761
+ return db.transaction(() => {
762
+ const created = register(username, password, email, displayName, { issueTokens: false });
763
+ if (!created.ok || !created.user) throw new Rollback({ ok: false, error: created.error || "register_failed", status: 400 });
764
+ if (!networkId) return { ok: true as const, user: created.user, network_id: created.network_id!, membership: null };
765
+ const added = addNetworkMember(networkId, created.user.user_id, role, actor.userId);
766
+ if (!added.ok) throw new Rollback({ ok: false, error: added.error || "add_member_failed", status: added.error === "username_collides_with_agent_alias" ? 409 : 400 });
767
+ return {
768
+ ok: true as const,
769
+ user: created.user,
770
+ network_id: created.network_id!,
771
+ membership: { network_id: networkId, role, agent_access: "granted" as const },
772
+ };
773
+ });
774
+ } catch (error) {
775
+ if (error instanceof Rollback) return error.result;
776
+ throw error;
777
+ }
778
+ }
779
+
780
+ /** Hub 管理员的用户列表:每个用户带上他所在的网络与角色、agent_access。不含密码哈希。 */
781
+ export function listUsersWithMemberships() {
782
+ const users = db.all<{ user_id: string; username: string; display_name: string | null; email: string | null; role: string; created_at: string }>(
783
+ "SELECT user_id, username, display_name, email, role, created_at FROM users ORDER BY created_at",
784
+ );
785
+ const memberships = db.all<{ user_id: string; network_id: string; network_name: string | null; role: string; agent_access: string | null }>(
786
+ `SELECT nm.user_id, nm.network_id, n.network_name, nm.role, nm.agent_access
787
+ FROM network_members nm JOIN networks n ON n.network_id = nm.network_id`,
788
+ );
789
+ const byUser = new Map<string, Array<{ network_id: string; network_name: string | null; role: string; agent_access: "all" | "granted" }>>();
790
+ for (const m of memberships) {
791
+ const list = byUser.get(m.user_id) ?? [];
792
+ list.push({ network_id: m.network_id, network_name: m.network_name, role: m.role, agent_access: m.agent_access === "all" ? "all" : "granted" });
793
+ byUser.set(m.user_id, list);
794
+ }
795
+ return users.map((u) => ({ ...u, networks: byUser.get(u.user_id) ?? [] }));
796
+ }
797
+
798
+ // Hub 管理员删**别人的**网络(清理旧测试账号留下的个人网络)。只删空网络:
799
+ // 下面每张表里有一行就拒(409),并把计数原样回给调用方。表是按 schema 里所有带
800
+ // network_id 的表逐个分类的,不是凭记忆 —— 新加一张带 network_id 的表要在这里归一类。
801
+ // 阻断:真实内容,或会在删后重新长出节点的在途请求。
802
+ // 清理:只描述「这个网络」本身的元数据/历史,网络没了它们就悬空。
803
+ // 保留:audit_log(删除记录本身要留下)。
804
+ const NETWORK_CONTENT_CHECKS: Array<[label: string, sql: string]> = [
805
+ ["nodes", "SELECT COUNT(*) AS cnt FROM nodes WHERE network_id = ?1"],
806
+ ["sessions", "SELECT COUNT(*) AS cnt FROM sessions WHERE network_id = ?1"],
807
+ ["tasks", "SELECT COUNT(*) AS cnt FROM tasks WHERE network_id = ?1"],
808
+ ["inbox", "SELECT COUNT(*) AS cnt FROM inbox WHERE network_id = ?1"],
809
+ ["user_inbox", "SELECT COUNT(*) AS cnt FROM user_inbox WHERE network_id = ?1"],
810
+ ["requirements", "SELECT COUNT(*) AS cnt FROM requirements WHERE network_id = ?1"],
811
+ ["requirement_projects", "SELECT COUNT(*) AS cnt FROM requirement_projects WHERE network_id = ?1"],
812
+ ["scheduled_tasks", "SELECT COUNT(*) AS cnt FROM scheduled_tasks WHERE network_id = ?1"],
813
+ ["providers", "SELECT COUNT(*) AS cnt FROM providers WHERE network_id = ?1"],
814
+ ["network_secrets", "SELECT COUNT(*) AS cnt FROM network_secrets WHERE network_id = ?1"],
815
+ ["skillhub_skills", "SELECT COUNT(*) AS cnt FROM skillhub_skills WHERE network_id = ?1"],
816
+ ["side_chats", "SELECT COUNT(*) AS cnt FROM side_chats WHERE network_id = ?1"],
817
+ ["pending_node_create_requests", "SELECT COUNT(*) AS cnt FROM node_create_requests WHERE network_id = ?1 AND status IN ('pending', 'delivered')"],
818
+ ["pending_node_start_requests", "SELECT COUNT(*) AS cnt FROM node_start_requests WHERE network_id = ?1 AND status IN ('pending', 'delivered')"],
819
+ ];
820
+ const NETWORK_CLEANUP_TABLES = [
821
+ "network_members", "network_member_agent_grants", "network_invites", "api_tokens",
822
+ "node_create_requests", "node_start_requests", "node_stop_requests", "node_rules_requests",
823
+ "node_config_updates", "rename_txn", "probe_results", "agent_telemetry", "completions",
824
+ "task_events", "task_terminal_events", "scheduled_task_runs", "external_schedule_edits",
825
+ ];
826
+
827
+ function tableExists(name: string): boolean {
828
+ return db.dialect === "postgres"
829
+ ? !!db.get("SELECT 1 AS x FROM information_schema.tables WHERE table_name = ?1", name)
830
+ : !!db.get("SELECT 1 AS x FROM sqlite_master WHERE type = 'table' AND name = ?1", name);
831
+ }
832
+
833
+ export function countNetworkContents(networkId: string): Record<string, number> {
834
+ const counts: Record<string, number> = {};
835
+ for (const [label, sql] of NETWORK_CONTENT_CHECKS) {
836
+ const table = sql.match(/FROM (\w+)/)![1];
837
+ // 按需建表的模块(requirements / side-thread)在没用过的库上可能还没有这张表 —— 那就是 0 行。
838
+ if (!tableExists(table)) continue;
839
+ const cnt = db.get<{ cnt: number }>(sql, networkId)?.cnt ?? 0;
840
+ if (cnt > 0) counts[label] = cnt;
841
+ }
842
+ return counts;
843
+ }
844
+
845
+ export type AdminDeleteNetworkResult =
846
+ | { ok: true; owner_id: string; network_name: string; cleaned: Record<string, number> }
847
+ | { ok: false; status: number; error: string; counts?: Record<string, number> };
848
+
849
+ // extraCounts:库外的内容(上传文件的索引在磁盘上),由调用方数好传进来,和库内计数一起判。
850
+ export function adminDeleteEmptyNetwork(networkId: string, extraCounts: Record<string, number> = {}): AdminDeleteNetworkResult {
851
+ if (networkId === "default") return { ok: false, status: 409, error: "the default network cannot be deleted" };
852
+ return db.transaction((): AdminDeleteNetworkResult => {
853
+ const net = db.get<{ owner_id: string; network_name: string }>("SELECT owner_id, network_name FROM networks WHERE network_id = ?1", networkId);
854
+ if (!net) return { ok: false, status: 404, error: "network not found" };
855
+ const counts = { ...countNetworkContents(networkId) };
856
+ for (const [k, v] of Object.entries(extraCounts)) if (v > 0) counts[k] = v;
857
+ if (Object.keys(counts).length > 0) {
858
+ const summary = Object.entries(counts).map(([k, v]) => `${k}=${v}`).join(", ");
859
+ return { ok: false, status: 409, error: `network is not empty (${summary}); only empty networks can be deleted by a hub admin`, counts };
860
+ }
861
+ const cleaned: Record<string, number> = {};
862
+ for (const table of NETWORK_CLEANUP_TABLES) {
863
+ if (!tableExists(table)) continue;
864
+ const changes = db.run(`DELETE FROM ${table} WHERE network_id = ?1`, [networkId]).changes;
865
+ if (changes > 0) cleaned[table] = changes;
866
+ }
867
+ db.run("DELETE FROM networks WHERE network_id = ?1", [networkId]);
868
+ return { ok: true, owner_id: net.owner_id, network_name: net.network_name, cleaned };
869
+ });
870
+ }