@sleep2agi/commhub-server 0.9.0-preview.4 → 0.9.0-preview.40

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 (117) 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 +84 -0
  7. package/src/alias-filter.test.ts +72 -0
  8. package/src/alias-filter.ts +32 -0
  9. package/src/api-host-supervisors-fallback.test.ts +267 -0
  10. package/src/api-nodes-shape.test.ts +107 -13
  11. package/src/auth.ts +93 -14
  12. package/src/avatar-validate.test.ts +163 -0
  13. package/src/avatar-validate.ts +103 -0
  14. package/src/boot-side-effect.test.ts +124 -0
  15. package/src/config-apply-sec1.test.ts +107 -0
  16. package/src/config-apply-validate.test.ts +92 -0
  17. package/src/config-apply-validate.ts +81 -3
  18. package/src/create-node-tool-schema.test.ts +39 -0
  19. package/src/create-node-validate.test.ts +80 -1
  20. package/src/create-node-validate.ts +49 -5
  21. package/src/create-node.ts +51 -1
  22. package/src/dashboard-slash-routing-http.test.ts +103 -0
  23. package/src/db-adapter-guard.test.ts +85 -0
  24. package/src/db-adapter.ts +64 -23
  25. package/src/db-timestamp.test.ts +38 -0
  26. package/src/db-timestamp.ts +15 -0
  27. package/src/db.ts +622 -33
  28. package/src/delivered-without-push.test.ts +140 -0
  29. package/src/desktop-message.test.ts +286 -0
  30. package/src/ext-token-shared.test.ts +60 -0
  31. package/src/ext-validation-regression.test.ts +131 -0
  32. package/src/external-schedule-edits-http.test.ts +316 -0
  33. package/src/external-schedule-edits.ts +314 -0
  34. package/src/file-download-authz-dev-open.test.ts +135 -0
  35. package/src/file-download-authz.test.ts +403 -0
  36. package/src/file-download-date-bucket.test.ts +436 -0
  37. package/src/file-network-scope-dev-open.test.ts +97 -0
  38. package/src/file-network-scope.test.ts +731 -0
  39. package/src/health-redaction.test.ts +637 -0
  40. package/src/inbox-count-sse.test.ts +310 -0
  41. package/src/inbox-count.ts +29 -0
  42. package/src/index.ts +15 -2187
  43. package/src/lifecycle-guard.ts +50 -0
  44. package/src/list-host-supervisors.test.ts +314 -0
  45. package/src/mcp-write-network-resolution.test.ts +429 -0
  46. package/src/network-name-http.test.ts +71 -0
  47. package/src/network-scope.ts +101 -0
  48. package/src/node-attrs-validate.ts +88 -0
  49. package/src/node-attrs.test.ts +213 -0
  50. package/src/node-id-alias.test.ts +147 -0
  51. package/src/node-lifecycle-controllable.test.ts +64 -0
  52. package/src/node-lifecycle-controllable.ts +51 -0
  53. package/src/observer-avatar-http.test.ts +424 -0
  54. package/src/observer-push.test.ts +252 -0
  55. package/src/package-version-consistency.test.ts +25 -0
  56. package/src/peer-reply-atomic.test.ts +455 -0
  57. package/src/peer-reply-capability.test.ts +70 -0
  58. package/src/probe-validate.test.ts +221 -0
  59. package/src/probe-validate.ts +104 -0
  60. package/src/probe.test.ts +80 -0
  61. package/src/probe.ts +200 -0
  62. package/src/push.ts +163 -7
  63. package/src/reply-target-derivation.test.ts +96 -0
  64. package/src/require-explicit-test-db.ts +9 -0
  65. package/src/resolve-port.test.ts +57 -0
  66. package/src/resolve-port.ts +44 -0
  67. package/src/response-charset.test.ts +130 -0
  68. package/src/rest-explicit-columns-http.test.ts +212 -0
  69. package/src/rest-identity.test.ts +129 -0
  70. package/src/rest-identity.ts +100 -0
  71. package/src/rest-projections.ts +67 -0
  72. package/src/rest-write-network-resolution.test.ts +133 -0
  73. package/src/runtime-label.test.ts +22 -0
  74. package/src/runtime-label.ts +21 -0
  75. package/src/scheduled-run-terminal.test.ts +268 -0
  76. package/src/scheduled-tasks-http.test.ts +479 -0
  77. package/src/scheduled-tasks.ts +549 -0
  78. package/src/send-reply-agent-warning.test.ts +202 -0
  79. package/src/send-reply-alias-optional.test.ts +131 -0
  80. package/src/send-reply-attachments.test.ts +437 -0
  81. package/src/server.ts +3591 -0
  82. package/src/shared/external-schedule-contract.ts +60 -0
  83. package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
  84. package/src/shared/probe-host-allowlist.ts +128 -0
  85. package/src/side-thread-command-transport.test.ts +302 -0
  86. package/src/side-thread-command-transport.ts +318 -0
  87. package/src/side-thread-http-integration.test.ts +229 -0
  88. package/src/side-thread-http.ts +504 -0
  89. package/src/side-thread-production.test.ts +72 -0
  90. package/src/side-thread-production.ts +118 -0
  91. package/src/side-thread.test.ts +1067 -0
  92. package/src/side-thread.ts +2495 -0
  93. package/src/skillhub-http.test.ts +88 -0
  94. package/src/start-node.test.ts +109 -0
  95. package/src/stop-delete-node.test.ts +952 -0
  96. package/src/task-auth-origin-http.test.ts +87 -0
  97. package/src/task-auth-origin.test.ts +24 -0
  98. package/src/task-auth-origin.ts +7 -0
  99. package/src/task-consumption.test.ts +471 -0
  100. package/src/task-diagnostic-http.test.ts +145 -0
  101. package/src/task-diagnostic.test.ts +115 -0
  102. package/src/task-diagnostic.ts +118 -0
  103. package/src/task-idempotency-mcp.test.ts +142 -0
  104. package/src/task-idempotency.test.ts +60 -0
  105. package/src/task-idempotency.ts +75 -0
  106. package/src/task-lifecycle-watcher.test.ts +200 -0
  107. package/src/task-lifecycle-watcher.ts +49 -0
  108. package/src/task-network-resolution-http.test.ts +137 -0
  109. package/src/tasks-pagination-http.test.ts +137 -0
  110. package/src/tools.ts +2952 -191
  111. package/src/update-provider.test.ts +234 -0
  112. package/src/upload-early-reject-guard.test.ts +155 -0
  113. package/src/uploads-http.test.ts +92 -14
  114. package/src/uploads.test.ts +66 -0
  115. package/src/uploads.ts +130 -5
  116. package/src/vault.test.ts +200 -0
  117. package/src/vault.ts +156 -0
@@ -0,0 +1,234 @@
1
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { db } from "./db.js";
3
+ import {
4
+ validateBaseUrl,
5
+ ProbeValidationError,
6
+ } from "./probe-validate.js";
7
+
8
+ // RFC-028 P1.5 — update_provider invariants. Tests the building blocks
9
+ // the MCP handler relies on:
10
+ // - SQL-level SEC-1 (provider_id × network_id uniqueness across tenants)
11
+ // - audit_log row written for every update
12
+ // - validateBaseUrl re-runs against vendor from DB (not patch) on
13
+ // base_url change → vendor immutability bypass attempt fails
14
+ // - patch-empty noop path
15
+ // - DELETE FROM provider_models + INSERT replacement is atomic
16
+ //
17
+ // Boundary behaviour (zod-strict patch wrapper, MCP -32602 on extras,
18
+ // admin gate, cross-tenant rejection) is covered by the docker e2e
19
+ // (qa-rfc028-update-provider). The unit layer focuses on the impl
20
+ // guarantees that survive even if the MCP boundary changes.
21
+
22
+ const NET_A = "net_test_pa_alpha";
23
+ const NET_B = "net_test_pa_beta";
24
+ const PROV_A = "prov_test_p15_alpha";
25
+ const PROV_B = "prov_test_p15_beta";
26
+
27
+ beforeEach(() => {
28
+ for (const p of [PROV_A, PROV_B]) {
29
+ try { db.run("DELETE FROM provider_models WHERE provider_id = ?1", [p]); } catch {}
30
+ try { db.run("DELETE FROM providers WHERE provider_id = ?1", [p]); } catch {}
31
+ }
32
+ try { db.run("DELETE FROM audit_log WHERE target_id IN (?1, ?2)", [PROV_A, PROV_B]); } catch {}
33
+ });
34
+
35
+ afterAll(() => {
36
+ for (const p of [PROV_A, PROV_B]) {
37
+ try { db.run("DELETE FROM provider_models WHERE provider_id = ?1", [p]); } catch {}
38
+ try { db.run("DELETE FROM providers WHERE provider_id = ?1", [p]); } catch {}
39
+ }
40
+ try { db.run("DELETE FROM audit_log WHERE target_id IN (?1, ?2)", [PROV_A, PROV_B]); } catch {}
41
+ });
42
+
43
+ function seedProvider(provider_id: string, network_id: string, opts: Partial<{ name: string; vendor: string; base_url: string; enabled: number }> = {}) {
44
+ db.run(
45
+ `INSERT INTO providers (provider_id, network_id, name, vendor, base_url, secret_key_ref, created_at, created_by, enabled)
46
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)`,
47
+ [provider_id, network_id, opts.name ?? "anthro-prod", opts.vendor ?? "anthropic",
48
+ opts.base_url ?? "https://api.anthropic.com", "ANTHROPIC_API_KEY",
49
+ Date.now(), "test", opts.enabled ?? 1],
50
+ );
51
+ }
52
+
53
+ describe("update_provider — SQL invariants (RFC-028 P1.5)", () => {
54
+ test("SEC-1: provider_id is unique-per-network — netA query for netB provider_id returns nothing", () => {
55
+ seedProvider(PROV_A, NET_A);
56
+ seedProvider(PROV_B, NET_B);
57
+ // The handler's lookup query: SELECT ... WHERE provider_id = ? AND network_id = ?
58
+ const fromNetA = db.get<any>(
59
+ "SELECT provider_id FROM providers WHERE provider_id = ?1 AND network_id = ?2",
60
+ PROV_B, NET_A,
61
+ );
62
+ expect(fromNetA == null).toBe(true);
63
+ // Sanity: PROV_B does exist in its own network
64
+ const fromNetB = db.get<any>(
65
+ "SELECT provider_id FROM providers WHERE provider_id = ?1 AND network_id = ?2",
66
+ PROV_B, NET_B,
67
+ );
68
+ expect(fromNetB?.provider_id).toBe(PROV_B);
69
+ });
70
+
71
+ test("vendor immutability via validateBaseUrl: changing base_url uses DB vendor, attempt to widen via patched vendor fails", () => {
72
+ seedProvider(PROV_A, NET_A, { vendor: "anthropic" });
73
+ // Handler reads vendor from DB row, NOT from patch. Suppose patch tried
74
+ // to set vendor=evil + base_url=https://attacker.example.com — the
75
+ // handler ignores the vendor from patch (it's not a field in the
76
+ // zod patch schema), and validateBaseUrl runs with vendor='anthropic'
77
+ // from the DB, so the host check is applied.
78
+ const dbVendor = db.get<{vendor: string}>("SELECT vendor FROM providers WHERE provider_id = ?1", PROV_A)!.vendor;
79
+ expect(dbVendor).toBe("anthropic");
80
+ // attempting to use base_url not in anthropic allowlist fails
81
+ expect(() => validateBaseUrl(dbVendor, "https://attacker.example.com")).toThrow(ProbeValidationError);
82
+ expect(() => validateBaseUrl(dbVendor, "https://attacker.example.com")).toThrow(/probe_target_forbidden/);
83
+ // valid anthropic host passes
84
+ expect(() => validateBaseUrl(dbVendor, "https://api.anthropic.com/v1")).not.toThrow();
85
+ });
86
+
87
+ test("patch noop: enabled set to same value → SET clause skipped", () => {
88
+ seedProvider(PROV_A, NET_A, { enabled: 1 });
89
+ const before = db.get<{enabled: number}>("SELECT enabled FROM providers WHERE provider_id = ?1", PROV_A)!.enabled;
90
+ expect(before).toBe(1);
91
+ // simulate the handler's diff logic: if patch.enabled === row.enabled, skip
92
+ const newEnabled = true ? 1 : 0;
93
+ const shouldUpdate = newEnabled !== before;
94
+ expect(shouldUpdate).toBe(false);
95
+ // verify no SET ran (row unchanged)
96
+ const after = db.get<{enabled: number}>("SELECT enabled FROM providers WHERE provider_id = ?1", PROV_A)!.enabled;
97
+ expect(after).toBe(1);
98
+ });
99
+
100
+ test("enabled toggle 1→0 written + audit log row created in same transaction", () => {
101
+ seedProvider(PROV_A, NET_A, { enabled: 1 });
102
+ db.exec("BEGIN");
103
+ db.run("UPDATE providers SET enabled = 0 WHERE provider_id = ?1", [PROV_A]);
104
+ db.run(
105
+ `INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
106
+ VALUES (?1, ?2, 'update_provider', 'provider', ?3, ?4, ?5)`,
107
+ ["u_test", "test", PROV_A, JSON.stringify({ diff: { enabled: { before: true, after: false } } }), NET_A],
108
+ );
109
+ db.exec("COMMIT");
110
+ expect(db.get<{enabled:number}>("SELECT enabled FROM providers WHERE provider_id = ?1", PROV_A)?.enabled).toBe(0);
111
+ const audit = db.get<any>("SELECT action, target_id, detail FROM audit_log WHERE target_id = ?1 AND action = 'update_provider'", PROV_A);
112
+ expect(audit?.action).toBe("update_provider");
113
+ expect(audit?.target_id).toBe(PROV_A);
114
+ expect(JSON.parse(audit!.detail).diff.enabled.after).toBe(false);
115
+ });
116
+
117
+ test("audit detail does NOT contain secret_key_ref VALUE under any patch", () => {
118
+ seedProvider(PROV_A, NET_A);
119
+ // Even if an attacker tried to slip secret into the diff (impossible
120
+ // through the schema, but defense-in-depth), the audit serializer
121
+ // only includes the fields actually in `diff`. Secret isn't a patch
122
+ // field, so it can't reach the diff. Verify the absence:
123
+ const auditPayload = JSON.stringify({ diff: { name: { before: "old", after: "new" } } });
124
+ expect(auditPayload).not.toContain("secret");
125
+ expect(auditPayload).not.toContain("ANTHROPIC_API_KEY");
126
+ expect(auditPayload).not.toContain("sk-");
127
+ });
128
+
129
+ test("provider_models DELETE+INSERT atomicity (transaction wraps both)", () => {
130
+ seedProvider(PROV_A, NET_A);
131
+ db.run(
132
+ `INSERT INTO provider_models (model_id, provider_id, model_name, enabled, created_at)
133
+ VALUES ('pm_old1', ?1, 'claude-old-1', 1, ?2), ('pm_old2', ?1, 'claude-old-2', 1, ?2)`,
134
+ [PROV_A, Date.now()],
135
+ );
136
+ expect(db.all("SELECT model_id FROM provider_models WHERE provider_id = ?1", PROV_A).length).toBe(2);
137
+ // Replace in one transaction
138
+ db.exec("BEGIN");
139
+ db.run("DELETE FROM provider_models WHERE provider_id = ?1", [PROV_A]);
140
+ db.run(
141
+ `INSERT INTO provider_models (model_id, provider_id, model_name, enabled, created_at)
142
+ VALUES ('pm_new1', ?1, 'claude-new-1', 1, ?2)`,
143
+ [PROV_A, Date.now()],
144
+ );
145
+ db.exec("COMMIT");
146
+ const rows = db.all<{model_name: string}>("SELECT model_name FROM provider_models WHERE provider_id = ?1", PROV_A);
147
+ expect(rows.length).toBe(1);
148
+ expect(rows[0].model_name).toBe("claude-new-1");
149
+ });
150
+
151
+ test("base_url change rejected via validateBaseUrl when host not in vendor allowlist", () => {
152
+ seedProvider(PROV_A, NET_A, { vendor: "anthropic" });
153
+ const vendor = db.get<{vendor: string}>("SELECT vendor FROM providers WHERE provider_id = ?1", PROV_A)!.vendor;
154
+ // any host other than api.anthropic.com is rejected
155
+ expect(() => validateBaseUrl(vendor, "https://api.openai.com")).toThrow(/probe_target_forbidden/);
156
+ expect(() => validateBaseUrl(vendor, "https://169.254.169.254/v1")).toThrow(/probe_target_forbidden/);
157
+ expect(() => validateBaseUrl(vendor, "http://api.anthropic.com")).toThrow(/probe_base_url_invalid/);
158
+ });
159
+
160
+ test("provider_disabled error distinct from provider_not_found in probe lookup", () => {
161
+ seedProvider(PROV_A, NET_A, { enabled: 0 });
162
+ // The new probe handler does the lookup WITHOUT enabled filter, then
163
+ // checks .enabled — distinguishing not_found vs disabled.
164
+ const row = db.get<{enabled: number}>("SELECT enabled FROM providers WHERE provider_id = ?1 AND network_id = ?2", PROV_A, NET_A);
165
+ expect(row).toBeTruthy();
166
+ expect(row!.enabled).toBe(0);
167
+ // dashboard renders different message for these two cases
168
+ });
169
+
170
+ test("no-op same-value patch: NO audit_log row written (通信牛 nit)", () => {
171
+ // Regression for the post-merge fix: when patch fields all match the
172
+ // existing row (e.g. dashboard saves an unchanged form), the handler
173
+ // must early-return WITHOUT inserting an empty-diff audit row.
174
+ // Empty audit rows pollute the trail and make `forced_*` / sensitive
175
+ // actions harder to find.
176
+ seedProvider(PROV_A, NET_A, { name: "same-name", enabled: 1 });
177
+ const auditBefore = db.get<{n:number}>(
178
+ "SELECT COUNT(*) AS n FROM audit_log WHERE target_id = ?1 AND action = 'update_provider'",
179
+ PROV_A,
180
+ )!.n;
181
+
182
+ // Mirror the handler's diff-check logic on a same-value patch
183
+ const row = db.get<{name:string, enabled:number, base_url:string}>(
184
+ "SELECT name, enabled, base_url FROM providers WHERE provider_id = ?1", PROV_A,
185
+ )!;
186
+ const patch = { name: "same-name", enabled: true };
187
+ const sets: string[] = [];
188
+ if (patch.name !== undefined && patch.name !== row.name) sets.push("name");
189
+ if (patch.enabled !== undefined && (patch.enabled ? 1 : 0) !== row.enabled) sets.push("enabled");
190
+ const willReplaceModels = false; // no patch.models
191
+
192
+ // sets MUST be empty for a same-value patch
193
+ expect(sets.length).toBe(0);
194
+ expect(willReplaceModels).toBe(false);
195
+
196
+ // Handler returns here WITHOUT writing audit_log → count unchanged
197
+ const auditAfter = db.get<{n:number}>(
198
+ "SELECT COUNT(*) AS n FROM audit_log WHERE target_id = ?1 AND action = 'update_provider'",
199
+ PROV_A,
200
+ )!.n;
201
+ expect(auditAfter).toBe(auditBefore);
202
+ expect(auditAfter).toBe(0);
203
+ });
204
+
205
+ test("real change DOES write audit_log row (positive guard for the early-return)", () => {
206
+ // Belt-and-suspenders: no-op early-return MUST NOT trigger when even
207
+ // one field actually changes. Otherwise we'd silently lose audit
208
+ // coverage for real updates.
209
+ seedProvider(PROV_A, NET_A, { name: "old-name", enabled: 1 });
210
+ const row = db.get<{name:string, enabled:number}>(
211
+ "SELECT name, enabled FROM providers WHERE provider_id = ?1", PROV_A,
212
+ )!;
213
+ const patch = { name: "new-name", enabled: true };
214
+ const sets: string[] = [];
215
+ if (patch.name !== undefined && patch.name !== row.name) sets.push("name");
216
+ if (patch.enabled !== undefined && (patch.enabled ? 1 : 0) !== row.enabled) sets.push("enabled");
217
+ expect(sets).toEqual(["name"]);
218
+
219
+ db.exec("BEGIN");
220
+ db.run("UPDATE providers SET name = ?1 WHERE provider_id = ?2", ["new-name", PROV_A]);
221
+ db.run(
222
+ `INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
223
+ VALUES (?1, ?2, 'update_provider', 'provider', ?3, ?4, ?5)`,
224
+ ["u_test", "test", PROV_A, JSON.stringify({ diff: { name: { before: "old-name", after: "new-name" } }, fields_changed: ["name"] }), NET_A],
225
+ );
226
+ db.exec("COMMIT");
227
+
228
+ const audit = db.get<any>(
229
+ "SELECT detail FROM audit_log WHERE target_id = ?1 AND action = 'update_provider' ORDER BY id DESC LIMIT 1",
230
+ PROV_A,
231
+ )!;
232
+ expect(JSON.parse(audit.detail).fields_changed).toEqual(["name"]);
233
+ });
234
+ });
@@ -0,0 +1,155 @@
1
+ // #503 F3=A static guard: every 4xx return in the /api/upload handler
2
+ // that fires BEFORE `req.formData()` drains the request body MUST be
3
+ // wrapped in `earlyReject` (which sets `Connection: close`). Otherwise
4
+ // the client's still-inbound body poisons the keepalive pool and the
5
+ // next pooled request stalls.
6
+ //
7
+ // Why a STATIC guard (not a runtime one): mutation M9 in
8
+ // docs/tests/p-503-file-network-scope/mutation-matrix.txt showed that
9
+ // under Bun's fetch client in this test setup, removing
10
+ // `Connection: close` does NOT turn any dynamic test red — the pool
11
+ // poisoning is real HTTP-level behavior but the client under test does
12
+ // not reliably reproduce it. Without a guard, deleting the helper or
13
+ // forgetting to wrap a NEW 15th pre-drain return goes silent. This
14
+ // test judges the SOURCE FILE, per lead 7a88ce29 and Constraint 3-1
15
+ // pattern (source-based, not runner-output-based).
16
+ //
17
+ // PRIOR-ART GOTCHA (lead 7a88ce29 踩过): parse boundaries MUST exclude
18
+ // comments. `req.formData()` appears in a doc comment near the top of
19
+ // the handler too; if the boundary matches on that, the pre-drain
20
+ // range collapses to empty and this guard silently becomes vacuously
21
+ // true. Same trap family as "范围为空的全称断言恒真". So this test
22
+ // has an explicit precondition: the parsed pre-drain return count must
23
+ // be ≥ 10 (currently 14). If the count drops to 0, the boundary parser
24
+ // broke, not the code.
25
+
26
+ import { describe, expect, test } from "bun:test";
27
+ import { readFileSync } from "fs";
28
+ import { join } from "path";
29
+
30
+ const SOURCE_PATH = join(import.meta.dirname, "server.ts");
31
+ const RAW = readFileSync(SOURCE_PATH, "utf-8");
32
+ const LINES = RAW.split("\n");
33
+
34
+ // Strip line-comments so we don't match on comment-mentioned tokens
35
+ // like the "req.formData()" doc references at the top of the handler.
36
+ // (Block comments /* */ are rare inside this handler; if you add one
37
+ // that mentions the boundary, extend this helper.)
38
+ //
39
+ // KNOWN LIMITATION (lead 50208c1b): this naive strip cuts on ANY `//`,
40
+ // including one inside a string literal (e.g. `"https://…"`). If a
41
+ // pre-drain line ever contains such a literal, the strip could snip
42
+ // off the return statement after it and produce a false NEGATIVE (miss
43
+ // a genuine unwrapped return). Currently no pre-drain line in the
44
+ // /api/upload handler holds this shape; the precondition floor of 10
45
+ // catches only "range collapsed to empty", not "silently minus one".
46
+ // If someone adds a URL-string literal in this range in the future,
47
+ // upgrade to a real tokenizer — not because block-comment mentions of
48
+ // req.formData() need it, but because the string-literal-with-`//`
49
+ // case is the actual gap.
50
+ function stripLineComment(s: string): string {
51
+ const idx = s.indexOf("//");
52
+ return idx >= 0 ? s.slice(0, idx) : s;
53
+ }
54
+ const CODE = LINES.map(stripLineComment);
55
+
56
+ // Find the /api/upload handler start (guard against a repo rename).
57
+ const handlerStartIdx = CODE.findIndex((line) =>
58
+ line.includes('url.pathname === "/api/upload"') &&
59
+ line.includes('req.method === "POST"'),
60
+ );
61
+
62
+ // Find the true body-drain boundary: `await req.formData()` INSIDE the
63
+ // handler. Search from handler start forward for the first NON-COMMENT
64
+ // occurrence (stripLineComment above already ate any `// ... formData ...`
65
+ // mention).
66
+ let drainIdx = -1;
67
+ if (handlerStartIdx >= 0) {
68
+ for (let i = handlerStartIdx + 1; i < CODE.length; i++) {
69
+ if (CODE[i].includes("req.formData()")) {
70
+ drainIdx = i;
71
+ break;
72
+ }
73
+ }
74
+ }
75
+
76
+ // Collect every `return` statement in the pre-drain range. Classify:
77
+ // earlyReject — the correct wrapping (sets Connection: close)
78
+ // withCors — bare wrapping without Connection: close (regression)
79
+ // other — produces a Response some OTHER way (e.g.
80
+ // `return Response.json(...)` / `return new Response(...)`);
81
+ // this shape ALSO skips earlyReject and MUST fail the guard.
82
+ // Detected via /Response\b/ so `return wrapped;` /
83
+ // `return;` / `return blob;` (helper body, non-response
84
+ // returns) are correctly skipped, but any new Response-
85
+ // producing path (Response.json / new Response / etc.) is
86
+ // caught. Lead 50208c1b: earlier version used implicit-skip
87
+ // which made the third assertion恒真 — it counted only what
88
+ // it recognised, so "unrecognised = 0" was vacuous.
89
+ // Order matters: earlyReject → withCors → other. A line
90
+ // like `return earlyReject(new Response(…))` contains
91
+ // both `earlyReject` AND `Response`, and must classify as
92
+ // earlyReject (already-wrapped), not as other.
93
+ type ReturnHit = { lineNumber: number; text: string; kind: "earlyReject" | "withCors" | "other" };
94
+ const preDrainReturns: ReturnHit[] = [];
95
+ if (handlerStartIdx >= 0 && drainIdx > handlerStartIdx) {
96
+ for (let i = handlerStartIdx; i < drainIdx; i++) {
97
+ const code = CODE[i];
98
+ if (!/\breturn\b/.test(code)) continue;
99
+ if (/\breturn earlyReject\b/.test(code)) {
100
+ preDrainReturns.push({ lineNumber: i + 1, text: LINES[i].trim(), kind: "earlyReject" });
101
+ } else if (/\breturn withCors\b/.test(code)) {
102
+ preDrainReturns.push({ lineNumber: i + 1, text: LINES[i].trim(), kind: "withCors" });
103
+ } else if (/Response\b/.test(code)) {
104
+ // `return new Response(...)` / `return Response.json(...)` / etc.
105
+ // — produces a Response but skips the earlyReject wrapper.
106
+ preDrainReturns.push({ lineNumber: i + 1, text: LINES[i].trim(), kind: "other" });
107
+ }
108
+ // Bare `return wrapped;`, `return;`, `return blob;` are non-response
109
+ // returns and correctly skipped (no `Response` token).
110
+ }
111
+ }
112
+
113
+ describe("#503 F3=A static guard: /api/upload pre-body-drain returns use earlyReject", () => {
114
+ test("precondition: scanner located handler + drain boundary + non-trivial return count (defends against parse collapse)", () => {
115
+ expect(handlerStartIdx).toBeGreaterThanOrEqual(0);
116
+ expect(drainIdx).toBeGreaterThan(handlerStartIdx);
117
+ // If boundary parsing collapses (someone renames req.formData(),
118
+ // or a comment-strip bug lets a doc reference win), this range
119
+ // becomes 0 and the second test would vacuously pass. Anchor
120
+ // the range: at time of writing (85e5c140 + #503) the count is
121
+ // 14; lead 7a88ce29 confirmed 14/14. Floor at 10 to leave room
122
+ // for genuine deletions while still catching a full collapse.
123
+ expect(preDrainReturns.length).toBeGreaterThanOrEqual(10);
124
+ });
125
+
126
+ test("every pre-drain return is wrapped in earlyReject (not bare withCors)", () => {
127
+ const bareWithCors = preDrainReturns.filter((r) => r.kind === "withCors");
128
+ if (bareWithCors.length > 0) {
129
+ const detail = bareWithCors
130
+ .map((r) => ` L${r.lineNumber}: ${r.text}`)
131
+ .join("\n");
132
+ throw new Error(
133
+ `#503 F3=A regression: ${bareWithCors.length} pre-body-drain return(s) in ` +
134
+ `/api/upload use bare withCors() instead of earlyReject():\n${detail}\n\n` +
135
+ `Every early-reject in this range MUST set Connection: close. Otherwise ` +
136
+ `the client's in-flight request body poisons the keepalive pool and the ` +
137
+ `next pooled request stalls. Wrap with the earlyReject() helper defined ` +
138
+ `at the top of the upload handler.`,
139
+ );
140
+ }
141
+ expect(bareWithCors.length).toBe(0);
142
+ });
143
+
144
+ test("no pre-drain return produces a Response without going through earlyReject (catches new response shapes)", () => {
145
+ // Catches `return Response.json(...)` / `return new Response(...)` /
146
+ // any other pre-drain Response-producing path that skips the wrapper.
147
+ // Lead 50208c1b: previous version was 恒真 because unrecognised
148
+ // shapes were silently dropped from `preDrainReturns` — the filter
149
+ // then counted "recognised = recognised" and never failed. Now the
150
+ // collector classifies unrecognised Response-producers as "other";
151
+ // this assertion reports them by line, not just count.
152
+ const unknown = preDrainReturns.filter((r) => r.kind === "other");
153
+ expect(unknown.map((r) => `L${r.lineNumber}: ${r.text}`)).toEqual([]);
154
+ });
155
+ });
@@ -7,15 +7,18 @@
7
7
  // this file already exercises the same code path). The hub binds to
8
8
  // an ephemeral port so multiple test runs don't collide.
9
9
  import { describe, expect, test, beforeAll, afterAll } from "bun:test";
10
+ import "./require-explicit-test-db.js";
10
11
  import { mkdtempSync, rmSync, existsSync } from "fs";
11
12
  import { join } from "path";
12
13
  import { tmpdir } from "os";
13
14
  import { register, login } from "./auth.js";
14
15
 
15
- const SERVER_DB = mkdtempSync(join(tmpdir(), "anet-upload-http-db-")) + "/commhub.db";
16
16
  const UPLOADS_DIR = mkdtempSync(join(tmpdir(), "anet-upload-http-fs-"));
17
- const PORT = 19000 + Math.floor(Math.random() * 1000);
18
- const BASE = `http://127.0.0.1:${PORT}`;
17
+ // #438 corrective: private bootServer({ port: 0 }) instance — the OS
18
+ // assigns the port and BASE is derived from the ACTUAL bound port, so
19
+ // this suite is immune to both the parent-side random-port TOCTOU and
20
+ // the aggregate module-cache no-op (import no longer boots anything).
21
+ let BASE = "";
19
22
 
20
23
  let server: any;
21
24
  let userToken = "";
@@ -25,9 +28,7 @@ beforeAll(async () => {
25
28
  // Bootstrap a real admin + token on the temp DB so the server can
26
29
  // resolve it via requireAuth (we don't want to short-circuit auth —
27
30
  // we want to validate the production code path).
28
- process.env.COMMHUB_DB = SERVER_DB;
29
31
  process.env.COMMHUB_UPLOADS_DIR = UPLOADS_DIR;
30
- process.env.PORT = String(PORT);
31
32
  process.env.HOST = "127.0.0.1";
32
33
 
33
34
  // Use a unique username + strong password each run so we don't trip
@@ -50,31 +51,40 @@ beforeAll(async () => {
50
51
  }
51
52
  expect(userToken).toBeTruthy();
52
53
 
53
- // Import the server module — its top-level `Bun.serve` call binds
54
- // the port immediately. The import side effect IS the server start.
55
- await import("./index.js");
54
+ // Importing is side-effect-free now; boot a private instance and
55
+ // read the real port back.
56
+ const { bootServer } = await import("./server.js");
57
+ server = bootServer({ port: 0, hostname: "127.0.0.1" });
58
+ BASE = `http://127.0.0.1:${server.port}`;
56
59
  // Tiny settle so the listener is ready.
57
60
  await new Promise((r) => setTimeout(r, 100));
58
61
  });
59
62
 
60
63
  afterAll(() => {
61
- // Best-effort cleanup. The Bun.serve handle is created at import
62
- // time so we can't easily stop it — process exit handles that.
64
+ try { server?.stop?.(true); } catch {}
63
65
  try { rmSync(UPLOADS_DIR, { recursive: true, force: true }); } catch {}
64
- try { rmSync(SERVER_DB, { recursive: true, force: true }); } catch {}
65
66
  });
66
67
 
67
68
  function authHeaders(): Record<string, string> {
68
69
  return { "Authorization": `Bearer ${userToken}` };
69
70
  }
70
71
 
72
+ // #503 — the bootstrap user is the first account in this suite's fresh
73
+ // DB, so it is a system admin, and admins must name the target network
74
+ // explicitly (attribution is never guessed). uploadNetworkId() keeps
75
+ // that detail in one place.
76
+ function uploadNetworkId(): string {
77
+ if (!userNetworkId) throw new Error("test bootstrap: userNetworkId missing");
78
+ return `?network_id=${encodeURIComponent(userNetworkId)}`;
79
+ }
80
+
71
81
  async function uploadFile(content: Uint8Array, filename: string, mime: string, opts: { skipAuth?: boolean; overrideContentLength?: string | null } = {}): Promise<Response> {
72
82
  const form = new FormData();
73
83
  form.append("file", new Blob([content], { type: mime }), filename);
74
84
  const headers: Record<string, string> = opts.skipAuth ? {} : { ...authHeaders() };
75
85
  // Note: fetch sets Content-Length automatically; only override when
76
86
  // testing the missing-header path.
77
- const res = await fetch(`${BASE}/api/upload`, { method: "POST", body: form, headers });
87
+ const res = await fetch(`${BASE}/api/upload${uploadNetworkId()}`, { method: "POST", body: form, headers });
78
88
  return res;
79
89
  }
80
90
 
@@ -134,7 +144,7 @@ describe("POST /api/upload — 6-item security checklist (HTTP integration)", ()
134
144
  controller.close();
135
145
  },
136
146
  });
137
- const res = await fetch(`${BASE}/api/upload`, {
147
+ const res = await fetch(`${BASE}/api/upload${uploadNetworkId()}`, {
138
148
  method: "POST",
139
149
  body: stream,
140
150
  headers: { ...authHeaders(), "Content-Type": "multipart/form-data; boundary=xxx", "Transfer-Encoding": "chunked" },
@@ -150,7 +160,7 @@ describe("POST /api/upload — 6-item security checklist (HTTP integration)", ()
150
160
  // this body" which is the security property we care about; the
151
161
  // exact code depends on whether Bun's multipart parser bails before
152
162
  // or after our Content-Type pre-check.
153
- const res = await fetch(`${BASE}/api/upload`, {
163
+ const res = await fetch(`${BASE}/api/upload${uploadNetworkId()}`, {
154
164
  method: "POST",
155
165
  body: JSON.stringify({ nope: 1 }),
156
166
  headers: { ...authHeaders(), "Content-Type": "application/json" },
@@ -161,12 +171,18 @@ describe("POST /api/upload — 6-item security checklist (HTTP integration)", ()
161
171
 
162
172
  describe("GET /api/files/:file_id — download safety", () => {
163
173
  let fileId = "";
174
+ let rangeFileId = "";
175
+ const rangeContent = Uint8Array.from({ length: 64 }, (_, i) => i);
164
176
 
165
177
  beforeAll(async () => {
166
178
  const content = new TextEncoder().encode("hello-world-from-#221-integration-test");
167
179
  const res = await uploadFile(content, "greeting.txt", "text/plain");
168
180
  const body = await res.json() as any;
169
181
  fileId = body.file_id;
182
+
183
+ const rangeRes = await uploadFile(rangeContent, "range.bin", "application/octet-stream");
184
+ const rangeBody = await rangeRes.json() as any;
185
+ rangeFileId = rangeBody.file_id;
170
186
  });
171
187
 
172
188
  test("(item 3) download returns the blob with Content-Disposition: attachment + X-Content-Type-Options: nosniff", async () => {
@@ -175,10 +191,72 @@ describe("GET /api/files/:file_id — download safety", () => {
175
191
  expect(res.headers.get("Content-Disposition") ?? "").toMatch(/^attachment(;|$)/i);
176
192
  expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
177
193
  expect(res.headers.get("Content-Type")).toBe("application/octet-stream");
194
+ expect(res.headers.get("Accept-Ranges")).toBe("bytes");
178
195
  const text = await res.text();
179
196
  expect(text).toBe("hello-world-from-#221-integration-test");
180
197
  });
181
198
 
199
+ test("#514 Range bytes=a-b returns exact 206 partial content, not a tolerant 200/full body", async () => {
200
+ const res = await fetch(`${BASE}/api/files/${rangeFileId}`, {
201
+ headers: { ...authHeaders(), Range: "bytes=3-8" },
202
+ });
203
+ expect(res.status).toBe(206);
204
+ expect(res.headers.get("Content-Range")).toBe("bytes 3-8/64");
205
+ expect(res.headers.get("Content-Length")).toBe("6");
206
+ expect(res.headers.get("Accept-Ranges")).toBe("bytes");
207
+ const body = new Uint8Array(await res.arrayBuffer());
208
+ expect(body.length).toBe(6);
209
+ expect([...body]).toEqual([...rangeContent.slice(3, 9)]);
210
+ });
211
+
212
+ test("#514 Range bytes=N- returns the exact open-ended tail", async () => {
213
+ const res = await fetch(`${BASE}/api/files/${rangeFileId}`, {
214
+ headers: { ...authHeaders(), Range: "bytes=60-" },
215
+ });
216
+ expect(res.status).toBe(206);
217
+ expect(res.headers.get("Content-Range")).toBe("bytes 60-63/64");
218
+ expect(res.headers.get("Content-Length")).toBe("4");
219
+ const body = new Uint8Array(await res.arrayBuffer());
220
+ expect(body.length).toBe(4);
221
+ expect([...body]).toEqual([...rangeContent.slice(60)]);
222
+ });
223
+
224
+ test("#514 Range bytes=-N returns the exact suffix", async () => {
225
+ const res = await fetch(`${BASE}/api/files/${rangeFileId}`, {
226
+ headers: { ...authHeaders(), Range: "bytes=-7" },
227
+ });
228
+ expect(res.status).toBe(206);
229
+ expect(res.headers.get("Content-Range")).toBe("bytes 57-63/64");
230
+ expect(res.headers.get("Content-Length")).toBe("7");
231
+ const body = new Uint8Array(await res.arrayBuffer());
232
+ expect(body.length).toBe(7);
233
+ expect([...body]).toEqual([...rangeContent.slice(57)]);
234
+ });
235
+
236
+ test("#514 unsatisfiable Range returns 416 with concrete Content-Range size", async () => {
237
+ const res = await fetch(`${BASE}/api/files/${rangeFileId}`, {
238
+ headers: { ...authHeaders(), Range: "bytes=64-70" },
239
+ });
240
+ expect(res.status).toBe(416);
241
+ expect(res.headers.get("Content-Range")).toBe("bytes */64");
242
+ expect(res.headers.get("Content-Length")).toBe("0");
243
+ const body = new Uint8Array(await res.arrayBuffer());
244
+ expect(body.length).toBe(0);
245
+ });
246
+
247
+ test("#514 HEAD Range mirrors GET status and range headers without a body", async () => {
248
+ const res = await fetch(`${BASE}/api/files/${rangeFileId}`, {
249
+ method: "HEAD",
250
+ headers: { ...authHeaders(), Range: "bytes=3-8" },
251
+ });
252
+ expect(res.status).toBe(206);
253
+ expect(res.headers.get("Content-Range")).toBe("bytes 3-8/64");
254
+ expect(res.headers.get("Content-Length")).toBe("6");
255
+ expect(res.headers.get("Accept-Ranges")).toBe("bytes");
256
+ const body = new Uint8Array(await res.arrayBuffer());
257
+ expect(body.length).toBe(0);
258
+ });
259
+
182
260
  test("anonymous download is 401", async () => {
183
261
  const res = await fetch(`${BASE}/api/files/${fileId}`);
184
262
  expect(res.status).toBe(401);
@@ -312,3 +312,69 @@ describe("indexEntryPath", () => {
312
312
  expect(p).toBe(`/srv/up/.index/${id}.json`);
313
313
  });
314
314
  });
315
+
316
+ import { stripHostLocalPathsForCrossHostSafe } from "./uploads";
317
+
318
+ describe("stripHostLocalPathsForCrossHostSafe (#222 cross-host attachment safety)", () => {
319
+ test("strips path when file_id is present (cross-host safe — agent will fetch via /api/files/<id>)", () => {
320
+ const meta = {
321
+ attachments: [
322
+ { type: "file", file_id: "abc12345", path: "/home/hub/uploads/abc12345.png", mime: "image/png" },
323
+ ],
324
+ otherKey: "preserved",
325
+ };
326
+ const out = stripHostLocalPathsForCrossHostSafe(meta);
327
+ expect(out.attachments[0]).toEqual({ type: "file", file_id: "abc12345", mime: "image/png" });
328
+ expect(out.attachments[0].path).toBeUndefined();
329
+ expect(out.otherKey).toBe("preserved");
330
+ });
331
+
332
+ test("PRESERVES path when file_id is absent (feishu single-host fallback)", () => {
333
+ // 🔴 通信龙 PR #222 pre-review nit — feishu-bridge ships path-only
334
+ // attachments (writes /work/feishu-attachments/...). If we strip
335
+ // unconditionally those break too. Lock the conditional contract.
336
+ const meta = {
337
+ attachments: [
338
+ { type: "file", path: "/work/feishu-attachments/feishu-local/oc_xxx/img.jpg", mime: "image/jpeg" },
339
+ ],
340
+ };
341
+ const out = stripHostLocalPathsForCrossHostSafe(meta);
342
+ expect(out.attachments[0].path).toBe("/work/feishu-attachments/feishu-local/oc_xxx/img.jpg");
343
+ expect(out.attachments[0].file_id).toBeUndefined();
344
+ });
345
+
346
+ test("mixed array: strips path on entries with file_id, preserves on path-only entries", () => {
347
+ const meta = {
348
+ attachments: [
349
+ { type: "file", file_id: "id1xxxxx", path: "/hub/local/id1.png", mime: "image/png" },
350
+ { type: "file", path: "/work/feishu-attachments/x.jpg", mime: "image/jpeg" },
351
+ { type: "file", file_id: "id2yyyyy", mime: "image/gif" }, // already path-free
352
+ ],
353
+ };
354
+ const out = stripHostLocalPathsForCrossHostSafe(meta);
355
+ expect(out.attachments[0]).toEqual({ type: "file", file_id: "id1xxxxx", mime: "image/png" });
356
+ expect(out.attachments[1]).toEqual({ type: "file", path: "/work/feishu-attachments/x.jpg", mime: "image/jpeg" });
357
+ expect(out.attachments[2]).toEqual({ type: "file", file_id: "id2yyyyy", mime: "image/gif" });
358
+ });
359
+
360
+ test("no-op when meta has no attachments array", () => {
361
+ const meta = { something: "else" };
362
+ expect(stripHostLocalPathsForCrossHostSafe(meta)).toBe(meta);
363
+ });
364
+
365
+ test("no-op when nothing to strip (returns SAME object reference, not a copy)", () => {
366
+ const meta = { attachments: [{ type: "file", file_id: "x12345xx", mime: "image/png" }] };
367
+ expect(stripHostLocalPathsForCrossHostSafe(meta)).toBe(meta);
368
+ });
369
+
370
+ test("handles null / non-object meta gracefully", () => {
371
+ expect(stripHostLocalPathsForCrossHostSafe(null)).toBe(null);
372
+ expect(stripHostLocalPathsForCrossHostSafe(undefined as any)).toBe(undefined);
373
+ expect(stripHostLocalPathsForCrossHostSafe("string" as any)).toBe("string");
374
+ });
375
+
376
+ test("handles non-array attachments field gracefully", () => {
377
+ const meta = { attachments: "not-an-array" };
378
+ expect(stripHostLocalPathsForCrossHostSafe(meta)).toBe(meta);
379
+ });
380
+ });