@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.
- package/README.md +125 -7
- package/bin/commhub.ts +10 -2
- package/package.json +5 -5
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/agent-access.ts +267 -0
- package/src/agent-acl-http.test.ts +510 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +348 -29
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +89 -1
- package/src/create-node-validate.ts +89 -7
- package/src/create-node-workdir.test.ts +255 -0
- package/src/create-node.ts +51 -1
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +85 -0
- package/src/db-adapter.ts +64 -23
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +762 -33
- package/src/delivered-without-push.test.ts +140 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +321 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +404 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +732 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +73 -0
- package/src/http-gzip.ts +60 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +149 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2331
- package/src/lifecycle-guard.ts +50 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +224 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-create-request-status-http.test.ts +92 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-id-alias.test.ts +188 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-privilege-http.test.ts +149 -0
- package/src/observer-avatar-http.test.ts +429 -0
- package/src/observer-push.test.ts +252 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/push.ts +204 -7
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +7 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-host-schema.test.ts +211 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-tags.ts +16 -0
- package/src/requirements-http.test.ts +708 -0
- package/src/requirements-mcp.test.ts +142 -0
- package/src/requirements-migrate.ts +83 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +85 -0
- package/src/requirements.ts +865 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +216 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +73 -0
- package/src/retention.ts +10 -1
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-tasks-http.test.ts +574 -0
- package/src/scheduled-tasks.ts +596 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +458 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +4279 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/side-thread-command-transport.test.ts +302 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +233 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2495 -0
- package/src/skillhub-http.test.ts +92 -0
- package/src/start-node.test.ts +109 -0
- package/src/stop-delete-node.test.ts +1164 -0
- package/src/task-auth-origin-http.test.ts +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +141 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tools-list-json-schema.test.ts +66 -0
- package/src/tools.ts +3445 -257
- package/src/update-provider.test.ts +65 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +130 -5
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- 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
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
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 (
|
|
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
|
-
|
|
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
|
+
});
|