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

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 (162) hide show
  1. package/README.md +125 -7
  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-networks-http.test.ts +88 -0
  7. package/src/agent-access.ts +267 -0
  8. package/src/agent-acl-http.test.ts +510 -0
  9. package/src/agent-acl-slice3-http.test.ts +267 -0
  10. package/src/alias-filter.test.ts +72 -0
  11. package/src/alias-filter.ts +32 -0
  12. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  13. package/src/api-host-supervisors-fallback.test.ts +271 -0
  14. package/src/api-nodes-shape.test.ts +26 -10
  15. package/src/auth-sessions-http.test.ts +250 -0
  16. package/src/auth-validate.test.ts +5 -1
  17. package/src/auth.ts +348 -29
  18. package/src/avatar-validate.test.ts +163 -0
  19. package/src/avatar-validate.ts +103 -0
  20. package/src/blocked-exit-on-reply.test.ts +122 -0
  21. package/src/boot-side-effect.test.ts +124 -0
  22. package/src/config-apply-sec1.test.ts +107 -0
  23. package/src/config-apply-validate.test.ts +92 -0
  24. package/src/config-apply-validate.ts +81 -3
  25. package/src/create-node-blocked-gate.test.ts +356 -0
  26. package/src/create-node-tool-schema.test.ts +39 -0
  27. package/src/create-node-validate.test.ts +89 -1
  28. package/src/create-node-validate.ts +89 -7
  29. package/src/create-node-workdir.test.ts +255 -0
  30. package/src/create-node.ts +51 -1
  31. package/src/dashboard-slash-routing-http.test.ts +107 -0
  32. package/src/db-adapter-guard.test.ts +85 -0
  33. package/src/db-adapter.ts +64 -23
  34. package/src/db-timestamp.test.ts +38 -0
  35. package/src/db-timestamp.ts +15 -0
  36. package/src/db.ts +762 -33
  37. package/src/delivered-without-push.test.ts +140 -0
  38. package/src/desktop-message-seam.test.ts +150 -0
  39. package/src/desktop-message.test.ts +368 -0
  40. package/src/ext-token-shared.test.ts +60 -0
  41. package/src/ext-validation-regression.test.ts +131 -0
  42. package/src/external-schedule-edits-http.test.ts +321 -0
  43. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  44. package/src/external-schedule-edits.ts +327 -0
  45. package/src/file-download-authz-dev-open.test.ts +135 -0
  46. package/src/file-download-authz.test.ts +404 -0
  47. package/src/file-download-date-bucket.test.ts +436 -0
  48. package/src/file-network-scope-dev-open.test.ts +97 -0
  49. package/src/file-network-scope.test.ts +732 -0
  50. package/src/health-redaction.test.ts +642 -0
  51. package/src/http-gzip.test.ts +73 -0
  52. package/src/http-gzip.ts +60 -0
  53. package/src/hub-timestamp.test.ts +38 -0
  54. package/src/hub-timestamp.ts +27 -0
  55. package/src/human-dm.ts +149 -0
  56. package/src/inbox-count-sse.test.ts +310 -0
  57. package/src/inbox-count.ts +29 -0
  58. package/src/index.ts +15 -2331
  59. package/src/lifecycle-guard.ts +50 -0
  60. package/src/mcp-write-network-resolution.test.ts +429 -0
  61. package/src/messages-ack-by-agent-http.test.ts +142 -0
  62. package/src/network-name-http.test.ts +75 -0
  63. package/src/network-scope.ts +224 -0
  64. package/src/node-attrs-validate.ts +88 -0
  65. package/src/node-attrs.test.ts +217 -0
  66. package/src/node-create-request-status-http.test.ts +92 -0
  67. package/src/node-files-transport.test.ts +344 -0
  68. package/src/node-id-alias.test.ts +188 -0
  69. package/src/node-lifecycle-controllable.test.ts +64 -0
  70. package/src/node-lifecycle-controllable.ts +51 -0
  71. package/src/node-logs-transport.test.ts +263 -0
  72. package/src/node-request-retention.test.ts +281 -0
  73. package/src/node-request-retention.ts +120 -0
  74. package/src/node-skills-transport.test.ts +209 -0
  75. package/src/node-token-privilege-http.test.ts +149 -0
  76. package/src/observer-avatar-http.test.ts +429 -0
  77. package/src/observer-push.test.ts +252 -0
  78. package/src/package-version-consistency.test.ts +25 -0
  79. package/src/peer-reply-atomic.test.ts +455 -0
  80. package/src/peer-reply-capability.test.ts +70 -0
  81. package/src/push.ts +204 -7
  82. package/src/redact-tokens.test.ts +65 -0
  83. package/src/redact-tokens.ts +40 -0
  84. package/src/rename.ts +7 -2
  85. package/src/reply-target-derivation.test.ts +96 -0
  86. package/src/report-status-host-schema.test.ts +211 -0
  87. package/src/report-status-resume-id-handover.test.ts +150 -0
  88. package/src/require-explicit-test-db.ts +9 -0
  89. package/src/requirement-tags.ts +16 -0
  90. package/src/requirements-http.test.ts +708 -0
  91. package/src/requirements-mcp.test.ts +142 -0
  92. package/src/requirements-migrate.ts +83 -0
  93. package/src/requirements-priority-lowest.test.ts +114 -0
  94. package/src/requirements-upgrade-e2e.test.ts +126 -0
  95. package/src/requirements-upgrade-migration.test.ts +85 -0
  96. package/src/requirements.ts +865 -0
  97. package/src/resolve-port.test.ts +57 -0
  98. package/src/resolve-port.ts +44 -0
  99. package/src/response-charset.test.ts +130 -0
  100. package/src/rest-explicit-columns-http.test.ts +216 -0
  101. package/src/rest-identity.test.ts +129 -0
  102. package/src/rest-identity.ts +100 -0
  103. package/src/rest-projections.ts +68 -0
  104. package/src/rest-write-network-resolution.test.ts +133 -0
  105. package/src/restricted-files.ts +73 -0
  106. package/src/retention.ts +10 -1
  107. package/src/rules-file-duplicate-alias.test.ts +234 -0
  108. package/src/rules-file-session-target.test.ts +207 -0
  109. package/src/rules-file-transport.test.ts +237 -0
  110. package/src/runtime-label.test.ts +22 -0
  111. package/src/runtime-label.ts +21 -0
  112. package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
  113. package/src/scheduled-run-terminal.test.ts +268 -0
  114. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  115. package/src/scheduled-tasks-http.test.ts +574 -0
  116. package/src/scheduled-tasks.ts +596 -0
  117. package/src/send-dedup-request-id.test.ts +229 -0
  118. package/src/send-reply-agent-warning.test.ts +202 -0
  119. package/src/send-reply-alias-optional.test.ts +131 -0
  120. package/src/send-reply-attachments.test.ts +458 -0
  121. package/src/send_dedup.test.ts +54 -0
  122. package/src/send_dedup.ts +52 -6
  123. package/src/serve-error.test.ts +38 -0
  124. package/src/serve-error.ts +27 -0
  125. package/src/server.ts +4279 -0
  126. package/src/shared/external-schedule-contract.ts +60 -0
  127. package/src/side-thread-command-transport.test.ts +302 -0
  128. package/src/side-thread-command-transport.ts +318 -0
  129. package/src/side-thread-http-integration.test.ts +233 -0
  130. package/src/side-thread-http.ts +504 -0
  131. package/src/side-thread-production.test.ts +72 -0
  132. package/src/side-thread-production.ts +118 -0
  133. package/src/side-thread.test.ts +1067 -0
  134. package/src/side-thread.ts +2495 -0
  135. package/src/skillhub-http.test.ts +92 -0
  136. package/src/start-node.test.ts +109 -0
  137. package/src/stop-delete-node.test.ts +1164 -0
  138. package/src/task-auth-origin-http.test.ts +91 -0
  139. package/src/task-auth-origin.test.ts +24 -0
  140. package/src/task-auth-origin.ts +7 -0
  141. package/src/task-consumption.test.ts +482 -0
  142. package/src/task-diagnostic-http.test.ts +149 -0
  143. package/src/task-diagnostic.test.ts +115 -0
  144. package/src/task-diagnostic.ts +118 -0
  145. package/src/task-idempotency-mcp.test.ts +142 -0
  146. package/src/task-idempotency.test.ts +60 -0
  147. package/src/task-idempotency.ts +75 -0
  148. package/src/task-lifecycle-watcher.test.ts +204 -0
  149. package/src/task-lifecycle-watcher.ts +49 -0
  150. package/src/task-network-resolution-http.test.ts +141 -0
  151. package/src/tasks-pagination-http.test.ts +141 -0
  152. package/src/tools-list-json-schema.test.ts +66 -0
  153. package/src/tools.ts +3445 -257
  154. package/src/update-provider.test.ts +65 -0
  155. package/src/upload-early-reject-guard.test.ts +155 -0
  156. package/src/uploads-http.test.ts +97 -15
  157. package/src/uploads.test.ts +66 -0
  158. package/src/uploads.ts +130 -5
  159. package/src/user-inbox-by-agent-http.test.ts +161 -0
  160. package/src/user-inbox-netid-notnull.test.ts +122 -0
  161. package/src/user-inbox-read-http.test.ts +223 -0
  162. package/src/user-inbox-schema.test.ts +54 -0
package/src/uploads.ts CHANGED
@@ -53,6 +53,17 @@ export const UPLOAD_RATE_MAX_ENTRIES = 50_000;
53
53
  // traversal.
54
54
  export const FILE_ID_REGEX = /^[A-Za-z0-9_-]{8,64}$/;
55
55
 
56
+ // Stored extension tokens have one invariant at every path boundary:
57
+ // either no extension, or a leading dot followed by 1-16 ASCII
58
+ // alphanumeric characters. Keep this separate from sanitizeExt(), which
59
+ // parses a client filename and therefore deliberately has different
60
+ // capture/anchoring semantics.
61
+ const EXT_TOKEN_REGEX = /^\.[A-Za-z0-9]{1,16}$/;
62
+
63
+ function isValidExtToken(ext: unknown): ext is string {
64
+ return typeof ext === "string" && (ext === "" || EXT_TOKEN_REGEX.test(ext));
65
+ }
66
+
56
67
  export type GeneratedFile = {
57
68
  fileId: string;
58
69
  ext: string;
@@ -94,14 +105,51 @@ export function sanitizeExt(filename: string | undefined | null): string {
94
105
  return match ? "." + match[1].toLowerCase() : "";
95
106
  }
96
107
 
108
+ // YYYY-MM-DD bucket string regex — matches what getDateBucket produces
109
+ // and what an index entry's `date_bucket` field is validated against.
110
+ const DATE_BUCKET_REGEX = /^\d{4}-\d{2}-\d{2}$/;
111
+
97
112
  /**
98
- * Build the per-upload storage path. The file_id and ext are validated
99
- * against strict regexes; any path-separator or `..` segment from a
100
- * malicious client gets rejected at the regex layer before any I/O.
113
+ * Validate a date_bucket string against BOTH shape (YYYY-MM-DD regex)
114
+ * AND real-calendar semantics: rejects "2026-02-30", "2026-13-01",
115
+ * "2026-00-15", leap-day-on-non-leap-year, etc. Shape-only check is not
116
+ * enough because the regex accepts any digit combination in each
117
+ * position.
118
+ *
119
+ * Used as a pre-check by the download handler before it calls into
120
+ * pathForExistingBlob — a non-calendar bucket is a 404 (invalid input
121
+ * rejected at the boundary), NOT a 500 (uncaught throw from deep in
122
+ * the read path). Distinguishing these matters: 500 says "server
123
+ * failed"; 404 says "we know your request is bad, refused early".
124
+ */
125
+ export function isValidCalendarBucket(bucket: unknown): bucket is string {
126
+ if (typeof bucket !== "string") return false;
127
+ if (!DATE_BUCKET_REGEX.test(bucket)) return false;
128
+ // Round-trip via Date to reject non-calendar shapes like "2026-02-30"
129
+ // or "2026-13-01". `Date.UTC` coerces overflow ("2026-02-30" becomes
130
+ // "2026-03-02") — if the ISO round-trip doesn't equal the input, the
131
+ // caller supplied a non-calendar bucket.
132
+ const [yStr, mStr, dStr] = bucket.split("-");
133
+ const y = Number(yStr), m = Number(mStr), d = Number(dStr);
134
+ if (m < 1 || m > 12) return false;
135
+ if (d < 1 || d > 31) return false;
136
+ const roundtrip = new Date(Date.UTC(y, m - 1, d)).toISOString().slice(0, 10);
137
+ return roundtrip === bucket;
138
+ }
139
+
140
+ /**
141
+ * Build the storage path for a NEW upload. Computes today's date bucket
142
+ * from the runtime clock (or `opts.now` for tests). Use ONLY for creating
143
+ * a fresh blob; for reading an existing blob whose bucket is already
144
+ * recorded in the index, use pathForExistingBlob() instead.
145
+ *
146
+ * The file_id and ext are validated against strict regexes; any
147
+ * path-separator or `..` segment from a malicious client gets rejected
148
+ * at the regex layer before any I/O.
101
149
  */
102
150
  export function buildStoragePath(fileId: string, ext: string, opts: { uploadsRoot?: string; now?: Date } = {}): GeneratedFile {
103
151
  if (!FILE_ID_REGEX.test(fileId)) throw new Error(`file_id "${fileId}" is invalid`);
104
- if (ext && !/^\.[A-Za-z0-9]{1,16}$/.test(ext)) throw new Error(`ext "${ext}" is invalid`);
152
+ if (!isValidExtToken(ext)) throw new Error(`ext "${ext}" is invalid`);
105
153
  const dateBucket = getDateBucket(opts.now);
106
154
  const root = opts.uploadsRoot ?? getUploadsRoot();
107
155
  const relativePath = join(dateBucket, fileId + ext);
@@ -109,6 +157,32 @@ export function buildStoragePath(fileId: string, ext: string, opts: { uploadsRoo
109
157
  return { fileId, ext, dateBucket, relativePath, absolutePath };
110
158
  }
111
159
 
160
+ /**
161
+ * Build the storage path for an EXISTING blob, using the caller-supplied
162
+ * `dateBucket` (typically read from the index entry). Does NOT compute
163
+ * a date from the runtime clock — that is the write-side concern.
164
+ *
165
+ * This split exists because sharing one date-computing function between
166
+ * write and read semantics caused #509: yesterday's file downloaded
167
+ * today would look under today's bucket → blob_missing → 404, even
168
+ * though the blob was still sitting in yesterday's directory.
169
+ *
170
+ * The file_id, ext and dateBucket are validated against strict regexes
171
+ * before any I/O; a poisoned index bucket (e.g. `../etc`) is rejected
172
+ * here just as file_id path-escape attempts are.
173
+ */
174
+ export function pathForExistingBlob(dateBucket: string, fileId: string, ext: string, opts: { uploadsRoot?: string } = {}): GeneratedFile {
175
+ if (!FILE_ID_REGEX.test(fileId)) throw new Error(`file_id "${fileId}" is invalid`);
176
+ if (!isValidExtToken(ext)) throw new Error(`ext "${ext}" is invalid`);
177
+ if (typeof dateBucket !== "string" || !DATE_BUCKET_REGEX.test(dateBucket)) {
178
+ throw new Error(`date_bucket "${dateBucket}" is invalid`);
179
+ }
180
+ const root = opts.uploadsRoot ?? getUploadsRoot();
181
+ const relativePath = join(dateBucket, fileId + ext);
182
+ const absolutePath = join(root, relativePath);
183
+ return { fileId, ext, dateBucket, relativePath, absolutePath };
184
+ }
185
+
112
186
  export type UploadIndexEntry = {
113
187
  file_id: string;
114
188
  date_bucket: string; // YYYY-MM-DD
@@ -117,6 +191,15 @@ export type UploadIndexEntry = {
117
191
  mime: string; // best-effort mime from the multipart Content-Type
118
192
  size: number; // byte length of the binary
119
193
  owner?: string; // upload token id / username, for traceability
194
+ // Written by the upload handler as `authCtx?.userId ?? null`, so `null`
195
+ // is a real on-disk value — the type must say so rather than pretend
196
+ // the field is `string | undefined`.
197
+ owner_id?: string | null;
198
+ // #503 — network the upload is attributed to. The key is OMITTED (not
199
+ // written as null) when there is no attribution, so "absent" and
200
+ // "unattributed" are the same on-disk shape and legacy entries written
201
+ // before #503 are indistinguishable from new unattributed ones.
202
+ network_id?: string;
120
203
  uploaded_at: string; // ISO 8601
121
204
  };
122
205
 
@@ -135,10 +218,16 @@ export function indexEntryPath(fileId: string, opts: { uploadsRoot?: string } =
135
218
  export function validateIndexEntry(entry: unknown): entry is UploadIndexEntry {
136
219
  if (!entry || typeof entry !== "object") return false;
137
220
  const e: any = entry;
221
+ // #503 — if the network_id key is present it must be a non-empty string.
222
+ // An absent key is valid (legacy entries + unattributed uploads); that
223
+ // keeps this in sync with the writer, which omits the key entirely
224
+ // rather than writing null. owner_id is deliberately NOT constrained
225
+ // here — existing entries legitimately carry owner_id=null.
226
+ if ("network_id" in e && !(typeof e.network_id === "string" && e.network_id.length > 0)) return false;
138
227
  return (
139
228
  typeof e.file_id === "string" && FILE_ID_REGEX.test(e.file_id) &&
140
229
  typeof e.date_bucket === "string" && /^\d{4}-\d{2}-\d{2}$/.test(e.date_bucket) &&
141
- typeof e.ext === "string" && (e.ext === "" || /^\.[A-Za-z0-9]{1,16}$/.test(e.ext)) &&
230
+ isValidExtToken(e.ext) &&
142
231
  typeof e.size === "number" && e.size >= 0 && e.size <= MAX_UPLOAD_BYTES
143
232
  );
144
233
  }
@@ -241,6 +330,42 @@ export type TaskAttachment = {
241
330
  size?: number;
242
331
  };
243
332
 
333
+ /**
334
+ * #222 cross-host attachment safety — strip `path` from meta.attachments
335
+ * entries that ALSO carry a `file_id`. Reason: the hub's /api/upload
336
+ * returns `{file_id, path}` where `path` is a hub-machine-local
337
+ * absolute path. Senders sometimes echo `path` back into meta.attachments.
338
+ * When the receiving agent runs on a different host (Vincent 2026-06-30
339
+ * a partner-team host case), that path string is meaningless. agent-node's
340
+ * fetch-attachment.ts will fetch via `file_id` regardless, but leaving
341
+ * the stale `path` in the JSON misleads any tooling that reads it.
342
+ *
343
+ * CRITICAL — conditional, not unconditional (通信龙 PR #222 pre-review
344
+ * nit): feishu-bridge currently writes `/work/feishu-attachments/<conn>/
345
+ * <chat>/` directly and ships path-only attachments (NO file_id). If we
346
+ * strip path unconditionally those break too. Only strip when file_id
347
+ * is present (the "safe to drop" signal). Single-host feishu fallback
348
+ * preserved.
349
+ *
350
+ * Shared by tools.ts (MCP send_task) and index.ts (REST /api/task) so
351
+ * both transports apply the same sanitization to meta.
352
+ */
353
+ export function stripHostLocalPathsForCrossHostSafe(meta: any): any {
354
+ if (!meta || typeof meta !== "object") return meta;
355
+ if (!Array.isArray((meta as any).attachments)) return meta;
356
+ let mutated = false;
357
+ const sanitized = (meta as any).attachments.map((a: any) => {
358
+ if (a && typeof a === "object" && typeof a.file_id === "string" && a.file_id && typeof a.path === "string") {
359
+ mutated = true;
360
+ const { path: _stripped, ...rest } = a;
361
+ return rest;
362
+ }
363
+ return a;
364
+ });
365
+ if (!mutated) return meta;
366
+ return { ...meta, attachments: sanitized };
367
+ }
368
+
244
369
  /** Light validator usable from the REST handler. Returns null on success or an error message string. */
245
370
  export function validateAttachments(input: unknown): { ok: true; attachments: TaskAttachment[] } | { ok: false; error: string } {
246
371
  if (input === undefined || input === null) return { ok: true, attachments: [] };
@@ -0,0 +1,161 @@
1
+ // #1828 —— /api/messages?scope=user 按 agent 分的未读 + 用户 ack 覆盖 inbox 里的回复行。
2
+ //
3
+ // 生产 hub 24h:admin 收到的 70 条 agent 回复全在 `inbox`(session_name='admin', type='reply',
4
+ // acked 永远 0),`user_inbox` 只有 2 行;角标链只读 user_inbox,所以回复从不进未读(app#260)。
5
+ // 这里让 scope=user 同时给 `unread_by_agent`(两表合并)与 `unread_total`,`unread`/`pending_count`
6
+ // 口径不动(qa-hub-14 钉着);POST /api/messages/ack 也能 ack inbox 里发给调用者用户名的回复行。
7
+ //
8
+ // 承重的是两条边界:
9
+ // ① 只算/只 ack **调用者自己用户名** 的 inbox 行 —— B 拿 A 的 id 来 ack 匹配不到;
10
+ // ② 用户名与某节点 alias 撞名时,inbox 那半边整体跳过(那是节点的待办,不是用户的)。
11
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
12
+ import { mkdtempSync, rmSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+
16
+ const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-user-inbox-by-agent-"));
17
+ process.env.COMMHUB_DB ||= join(PRIVATE_DB_DIR, "hub.db");
18
+
19
+ let server: ReturnType<typeof Bun.serve>;
20
+ let base = "";
21
+ let aToken = "", bToken = "", cToken = "";
22
+ let aUserId = "", bUserId = "", cUserId = "", aNet = "", cNet = "";
23
+ let aName = "", bName = "", cName = "";
24
+
25
+ const get = async (path: string, token: string) => {
26
+ const r = await fetch(`${base}${path}`, { headers: { Authorization: `Bearer ${token}` } });
27
+ return { status: r.status, body: await r.json() as any };
28
+ };
29
+ const post = async (path: string, token: string, body: unknown) => {
30
+ const r = await fetch(`${base}${path}`, {
31
+ method: "POST",
32
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
33
+ body: JSON.stringify(body),
34
+ });
35
+ return { status: r.status, body: await r.json() as any };
36
+ };
37
+
38
+ function seedUserInbox(mid: string, userId: string, netId: string, from: string) {
39
+ const { db } = require("./db.js");
40
+ db.run(
41
+ `INSERT INTO user_inbox (message_id, network_id, user_id, from_session, kind, title, content, severity, meta_json, acked)
42
+ VALUES (?1, ?2, ?3, ?4, 'agent_message', NULL, 'dm', 'info', NULL, 0)`,
43
+ [mid, netId, userId, from],
44
+ );
45
+ }
46
+ function seedInbox(id: string, sessionName: string, netId: string, from: string, type = "reply", acked = 0) {
47
+ const { db } = require("./db.js");
48
+ db.run(
49
+ `INSERT INTO inbox (id, session_name, type, priority, content, from_session, acked, network_id, created_at)
50
+ VALUES (?1, ?2, ?3, 'normal', 'reply body', ?4, ?5, ?6, datetime('now'))`,
51
+ [id, sessionName, type, from, acked, netId],
52
+ );
53
+ }
54
+
55
+ beforeAll(async () => {
56
+ process.env.COMMHUB_AUTH_TOKEN ||= "legacy-master-token-for-test";
57
+ const { db } = await import("./db.js");
58
+ const { register, addNetworkMember } = await import("./auth.js");
59
+ const stamp = Date.now();
60
+
61
+ // 首个注册用户 = 全局 admin;探针 A/B/C 都不是 admin(admin 走不过滤分支,隔离断言会白过)。
62
+ const admin = register(`uba_admin_${stamp}`, "UbaAdmin-Strong-1!");
63
+ expect(admin.ok).toBe(true);
64
+ aName = `uba_a_${stamp}`; bName = `uba_b_${stamp}`; cName = `uba_c_${stamp}`;
65
+ const a = register(aName, "UbaA-Strong-1!");
66
+ const b = register(bName, "UbaB-Strong-1!");
67
+ const c = register(cName, "UbaC-Strong-1!");
68
+ expect(a.ok && b.ok && c.ok).toBe(true);
69
+ aToken = a.token!; bToken = b.token!; cToken = c.token!;
70
+ aNet = a.network_id!; cNet = c.network_id!;
71
+ const uid = (name: string) => db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", name)!.user_id;
72
+ aUserId = uid(aName); bUserId = uid(bName); cUserId = uid(cName);
73
+ for (const id of [aUserId, bUserId, cUserId]) {
74
+ expect(db.get<{ role: string }>("SELECT role FROM users WHERE user_id = ?1", id)?.role).toBe("user");
75
+ }
76
+ // B 与 A 同网:user_id / session_name 过滤是唯一的隔离手段。
77
+ expect(addNetworkMember(aNet, bUserId, "member").ok).toBe(true);
78
+
79
+ // A:user_inbox 两条(node-x),inbox 回复两条(peer-1)+ 一条已 ack + 一条 status 类(不算)。
80
+ seedUserInbox("dm_a1", aUserId, aNet, "node-x");
81
+ seedUserInbox("dm_a2", aUserId, aNet, "node-x");
82
+ seedInbox("ib_a1", aName, aNet, "peer-1");
83
+ seedInbox("ib_a2", aName, aNet, "peer-1");
84
+ seedInbox("ib_a_acked", aName, aNet, "peer-1", "reply", 1);
85
+ seedInbox("ib_a_status", aName, aNet, "peer-1", "status");
86
+ // B 同网:自己的一条回复;不该出现在 A 的数里。
87
+ seedInbox("ib_b1", bName, aNet, "peer-2");
88
+ // C:用户名撞节点 alias —— inbox 里那行是节点的待办。
89
+ db.run(
90
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at)
91
+ VALUES (?1, ?2, ?3, ?4, datetime('now'), datetime('now'))`,
92
+ [`node_${stamp}`, cName, cName, cNet],
93
+ );
94
+ seedInbox("ib_c_node_task", cName, cNet, "leader", "task");
95
+ seedUserInbox("dm_c1", cUserId, cNet, "node-y");
96
+
97
+ const mod = await import("./server.js");
98
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
99
+ base = `http://127.0.0.1:${server.port}`;
100
+ });
101
+
102
+ afterAll(() => {
103
+ try { server?.stop(true); } catch {}
104
+ try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
105
+ });
106
+
107
+ describe("#1828 GET /api/messages?scope=user unread_by_agent", () => {
108
+ test("A:user_inbox + inbox 回复按 agent 合并;unread/pending_count 口径不变", async () => {
109
+ const { status, body } = await get("/api/messages?scope=user", aToken);
110
+ expect(status).toBe(200);
111
+ expect(body.unread).toBe(2); // 只算 user_inbox(qa-hub-14 钉着)
112
+ expect(body.pending_count).toBe(2);
113
+ expect(body.unread_by_agent).toEqual({ "node-x": 2, "peer-1": 2 }); // 已 ack 与 status 类不算
114
+ expect(body.unread_total).toBe(4);
115
+ });
116
+
117
+ test("B(同网):只看到自己的回复,看不到 A 的", async () => {
118
+ const { body } = await get("/api/messages?scope=user", bToken);
119
+ expect(body.unread_by_agent).toEqual({ "peer-2": 1 });
120
+ expect(body.unread_total).toBe(1);
121
+ });
122
+
123
+ test("🔴 C 的用户名撞节点 alias:inbox 半边整体跳过,只剩 user_inbox", async () => {
124
+ const { body } = await get("/api/messages?scope=user", cToken);
125
+ expect(body.unread_by_agent).toEqual({ "node-y": 1 });
126
+ expect(body.unread_total).toBe(1);
127
+ });
128
+ });
129
+
130
+ describe("#1828 POST /api/messages/ack covers inbox reply rows", () => {
131
+ test("🔴 B 拿 A 的 inbox id 来 ack:匹配不到,A 的数不动", async () => {
132
+ const { body } = await post("/api/messages/ack", bToken, { message_ids: ["ib_a1"] });
133
+ expect(body).toMatchObject({ ok: true, acked: 0, acked_inbox: 0 });
134
+ const a = await get("/api/messages?scope=user", aToken);
135
+ expect(a.body.unread_by_agent["peer-1"]).toBe(2);
136
+ });
137
+
138
+ test("A ack 一条 inbox 回复 + 一条 user_inbox:两边各减一,响应分开计数", async () => {
139
+ const { body } = await post("/api/messages/ack", aToken, { message_ids: ["ib_a1", "dm_a1"] });
140
+ expect(body).toMatchObject({ ok: true, acked: 2, acked_user_inbox: 1, acked_inbox: 1 });
141
+ const a = await get("/api/messages?scope=user", aToken);
142
+ expect(a.body.unread).toBe(1);
143
+ expect(a.body.unread_by_agent).toEqual({ "node-x": 1, "peer-1": 1 });
144
+ expect(a.body.unread_total).toBe(2);
145
+ // 再 ack 同一条:幂等,0 行。
146
+ const again = await post("/api/messages/ack", aToken, { message_ids: ["ib_a1"] });
147
+ expect(again.body.acked).toBe(0);
148
+ });
149
+
150
+ test("status 类 inbox 行不会被用户 ack 掉(不是发给用户的消息)", async () => {
151
+ const { body } = await post("/api/messages/ack", aToken, { message_ids: ["ib_a_status"] });
152
+ expect(body.acked_inbox).toBe(0);
153
+ });
154
+
155
+ test("🔴 C 撞名:节点的待办不能被用户 ack 走", async () => {
156
+ const { body } = await post("/api/messages/ack", cToken, { message_ids: ["ib_c_node_task"] });
157
+ expect(body.acked_inbox).toBe(0);
158
+ const { db } = require("./db.js");
159
+ expect(db.get<{ acked: number }>("SELECT acked FROM inbox WHERE id = 'ib_c_node_task'")?.acked).toBe(0);
160
+ });
161
+ });
@@ -0,0 +1,122 @@
1
+ // #1493 — user_inbox.network_id 升 schema 级 NOT NULL(belt-and-suspenders)。
2
+ //
3
+ // 「不产生 NULL 孤儿」原是代码级三闸(#1492 证不可达)。schema 级 NOT NULL 让**任何**
4
+ // 未来绕过那三闸写 NULL 的回归在 INSERT 处被 DB 直接拒,而不是产出 scoped-unreadable
5
+ // 的静默孤儿。生产 hub 已在 preview.41、user_inbox 有真实行 → 迁移必须:① 新库直接
6
+ // NOT NULL;② 存量库无损重建(建新表→copy→drop→rename);③ 迁移前若发现 NULL 行(理应
7
+ // 为 0)fail-closed 停下,不静默丢。
8
+ //
9
+ // 后两条要预置「旧 schema(nullable)」的库 → 用 bun:sqlite 手造库 + 子进程 import db.js
10
+ // 触发迁移(与 boot-side-effect.test.ts 同款子进程范式)。
11
+
12
+ import { describe, expect, test } from "bun:test";
13
+ import { Database } from "bun:sqlite";
14
+ import { mkdtempSync } from "fs";
15
+ import { join } from "path";
16
+ import { tmpdir } from "os";
17
+ import { db } from "./db.js";
18
+
19
+ const OLD_SCHEMA = `CREATE TABLE user_inbox (
20
+ message_id TEXT PRIMARY KEY, network_id TEXT, user_id TEXT NOT NULL, from_session TEXT,
21
+ kind TEXT NOT NULL DEFAULT 'info', title TEXT, content TEXT NOT NULL,
22
+ severity TEXT NOT NULL DEFAULT 'info', meta_json TEXT, acked INTEGER NOT NULL DEFAULT 0,
23
+ created_at TEXT NOT NULL DEFAULT (datetime('now')), acked_at TEXT)`;
24
+
25
+ /** 预置一个「旧 schema(network_id 可空)」的库文件,可选带若干行。返回文件路径。 */
26
+ function seedOldDb(rows: Array<{ mid: string; net: string | null; user: string }>): string {
27
+ const file = join(mkdtempSync(join(tmpdir(), "anet-1493-")), "hub.db");
28
+ const raw = new Database(file);
29
+ raw.exec(OLD_SCHEMA);
30
+ for (const r of rows) {
31
+ raw.run("INSERT INTO user_inbox (message_id, network_id, user_id, content) VALUES (?1, ?2, ?3, 'body')",
32
+ [r.mid, r.net, r.user]);
33
+ }
34
+ raw.close();
35
+ return file;
36
+ }
37
+
38
+ /** 子进程里 import db.js(触发迁移),回读一段结果。返回 {exitCode, stdout, stderr}。 */
39
+ function bootWithDb(file: string, probe: string) {
40
+ const script = `
41
+ process.env.COMMHUB_DB = ${JSON.stringify(file)};
42
+ const { db } = await import("./src/db.js");
43
+ ${probe}
44
+ process.exit(0);
45
+ `;
46
+ const child = Bun.spawnSync({
47
+ cmd: ["bun", "-e", script],
48
+ cwd: `${import.meta.dir}/..`,
49
+ env: { ...process.env, COMMHUB_DB: file },
50
+ timeout: 20_000,
51
+ });
52
+ return {
53
+ exitCode: child.exitCode,
54
+ stdout: new TextDecoder().decode(child.stdout),
55
+ stderr: new TextDecoder().decode(child.stderr),
56
+ };
57
+ }
58
+
59
+ describe("#1493 user_inbox.network_id NOT NULL (belt-and-suspenders)", () => {
60
+ test("new DB: network_id is NOT NULL and rejects a NULL insert", () => {
61
+ const col = db.all<{ name: string; notnull: number }>("PRAGMA table_info(user_inbox)").find(c => c.name === "network_id");
62
+ expect(col?.notnull).toBe(1);
63
+ // 绕过代码闸直接写 NULL → DB 拒(这正是 belt-and-suspenders 的价值)
64
+ expect(() => db.run(
65
+ "INSERT INTO user_inbox (message_id, user_id, content) VALUES (?1, ?2, ?3)",
66
+ [`dm_nn_${Date.now()}`, "u_nn", "hi"],
67
+ )).toThrow(/NOT NULL/i);
68
+ });
69
+
70
+ test("migration: old nullable table with valid rows → rebuilt NOT NULL, rows preserved, index recreated", () => {
71
+ const file = seedOldDb([
72
+ { mid: "dm_1", net: "net_a", user: "u_1" },
73
+ { mid: "dm_2", net: "net_b", user: "u_2" },
74
+ ]);
75
+ const r = bootWithDb(file, `
76
+ const col = db.all("PRAGMA table_info(user_inbox)").find(c => c.name === "network_id");
77
+ const rows = db.get("SELECT COUNT(*) AS n FROM user_inbox").n;
78
+ const idx = db.get("SELECT COUNT(*) AS n FROM sqlite_master WHERE type='index' AND name='idx_user_inbox_user_acked'").n;
79
+ let nullRejected = false;
80
+ try { db.run("INSERT INTO user_inbox (message_id, user_id, content) VALUES ('dm_z','u_z','x')"); }
81
+ catch { nullRejected = true; }
82
+ console.log("RESULT:" + JSON.stringify({ notnull: col.notnull, rows, idx, nullRejected }));
83
+ `);
84
+ expect(r.exitCode).toBe(0);
85
+ const m = r.stdout.match(/RESULT:(\{.*\})/);
86
+ expect(m).not.toBeNull();
87
+ const out = JSON.parse(m![1]);
88
+ expect(out.notnull).toBe(1); // 迁移后 NOT NULL
89
+ expect(out.rows).toBe(2); // 两行无损保留
90
+ expect(out.idx).toBe(1); // 索引重建
91
+ expect(out.nullRejected).toBe(true);
92
+ });
93
+
94
+ test("migration guard: old table with a network_id IS NULL row → fail-safe (warn + skip, hub boots, data intact)", () => {
95
+ const file = seedOldDb([
96
+ { mid: "dm_ok", net: "net_a", user: "u_1" },
97
+ { mid: "dm_orphan", net: null, user: "u_2" }, // 理应不可达;若真出现 → warn+跳过,不停机
98
+ ]);
99
+ const r = bootWithDb(file, `
100
+ const col = db.all("PRAGMA table_info(user_inbox)").find(c => c.name === "network_id");
101
+ const rows = db.get("SELECT COUNT(*) AS n FROM user_inbox").n;
102
+ console.log("RESULT:" + JSON.stringify({ notnull: col.notnull, rows }));
103
+ `);
104
+ expect(r.exitCode).toBe(0); // hub 照常启动(fail-safe,不停机)
105
+ expect(r.stderr).toMatch(/migrate #1493/); // 大声 warn(明确运维信号)
106
+ expect(r.stderr).toMatch(/network_id IS NULL/);
107
+ const m = r.stdout.match(/RESULT:(\{.*\})/);
108
+ expect(m).not.toBeNull();
109
+ const out = JSON.parse(m![1]);
110
+ expect(out.notnull).toBe(0); // 迁移被跳过 → 列暂保持可空
111
+ expect(out.rows).toBe(2); // 数据没丢(没盲目清空)
112
+ });
113
+
114
+ test("idempotent: booting an already-migrated (NOT NULL) DB twice is a no-op", () => {
115
+ const file = seedOldDb([{ mid: "dm_a", net: "net_a", user: "u_1" }]);
116
+ const r1 = bootWithDb(file, `console.log("RESULT:" + db.all("PRAGMA table_info(user_inbox)").find(c=>c.name==="network_id").notnull);`);
117
+ expect(r1.exitCode).toBe(0);
118
+ const r2 = bootWithDb(file, `console.log("RESULT:" + db.all("PRAGMA table_info(user_inbox)").find(c=>c.name==="network_id").notnull);`);
119
+ expect(r2.exitCode).toBe(0);
120
+ expect(r2.stdout).toContain("RESULT:1"); // 第二次仍 NOT NULL、不炸
121
+ });
122
+ });