@sleep2agi/commhub-server 0.9.0-preview.3 → 0.9.0-preview.30

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 (99) 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 +188 -0
  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.ts +48 -1
  19. package/src/dashboard-slash-routing-http.test.ts +103 -0
  20. package/src/db-adapter-guard.test.ts +85 -0
  21. package/src/db-adapter.ts +64 -23
  22. package/src/db.ts +522 -32
  23. package/src/ext-token-shared.test.ts +60 -0
  24. package/src/ext-validation-regression.test.ts +131 -0
  25. package/src/external-schedule-edits-http.test.ts +316 -0
  26. package/src/external-schedule-edits.ts +314 -0
  27. package/src/file-download-authz-dev-open.test.ts +135 -0
  28. package/src/file-download-authz.test.ts +403 -0
  29. package/src/file-download-date-bucket.test.ts +436 -0
  30. package/src/file-network-scope-dev-open.test.ts +97 -0
  31. package/src/file-network-scope.test.ts +731 -0
  32. package/src/health-redaction.test.ts +637 -0
  33. package/src/index.ts +15 -2172
  34. package/src/lifecycle-guard.ts +50 -0
  35. package/src/list-host-supervisors.test.ts +314 -0
  36. package/src/mcp-write-network-resolution.test.ts +429 -0
  37. package/src/network-name-http.test.ts +71 -0
  38. package/src/network-scope.ts +101 -0
  39. package/src/node-attrs-validate.ts +88 -0
  40. package/src/node-attrs.test.ts +213 -0
  41. package/src/observer-avatar-http.test.ts +349 -0
  42. package/src/observer-push.test.ts +176 -0
  43. package/src/peer-reply-atomic.test.ts +455 -0
  44. package/src/peer-reply-capability.test.ts +70 -0
  45. package/src/probe-validate.test.ts +221 -0
  46. package/src/probe-validate.ts +104 -0
  47. package/src/probe.test.ts +80 -0
  48. package/src/probe.ts +200 -0
  49. package/src/push.ts +96 -7
  50. package/src/require-explicit-test-db.ts +9 -0
  51. package/src/resolve-port.test.ts +57 -0
  52. package/src/resolve-port.ts +44 -0
  53. package/src/response-charset.test.ts +130 -0
  54. package/src/rest-explicit-columns-http.test.ts +211 -0
  55. package/src/rest-identity.test.ts +129 -0
  56. package/src/rest-identity.ts +100 -0
  57. package/src/rest-projections.ts +67 -0
  58. package/src/rest-write-network-resolution.test.ts +133 -0
  59. package/src/runtime-label.test.ts +22 -0
  60. package/src/runtime-label.ts +21 -0
  61. package/src/scheduled-run-terminal.test.ts +268 -0
  62. package/src/scheduled-tasks-http.test.ts +479 -0
  63. package/src/scheduled-tasks.ts +549 -0
  64. package/src/send-reply-agent-warning.test.ts +202 -0
  65. package/src/send-reply-attachments.test.ts +437 -0
  66. package/src/server.ts +3492 -0
  67. package/src/shared/external-schedule-contract.ts +60 -0
  68. package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
  69. package/src/shared/probe-host-allowlist.ts +128 -0
  70. package/src/side-thread-command-transport.test.ts +140 -0
  71. package/src/side-thread-command-transport.ts +309 -0
  72. package/src/side-thread-http-integration.test.ts +229 -0
  73. package/src/side-thread-http.ts +504 -0
  74. package/src/side-thread.test.ts +1038 -0
  75. package/src/side-thread.ts +2241 -0
  76. package/src/skillhub-http.test.ts +88 -0
  77. package/src/stop-delete-node.test.ts +720 -0
  78. package/src/task-auth-origin-http.test.ts +87 -0
  79. package/src/task-auth-origin.test.ts +24 -0
  80. package/src/task-auth-origin.ts +7 -0
  81. package/src/task-consumption.test.ts +398 -0
  82. package/src/task-diagnostic-http.test.ts +145 -0
  83. package/src/task-diagnostic.test.ts +115 -0
  84. package/src/task-diagnostic.ts +118 -0
  85. package/src/task-idempotency-mcp.test.ts +142 -0
  86. package/src/task-idempotency.test.ts +60 -0
  87. package/src/task-idempotency.ts +75 -0
  88. package/src/task-lifecycle-watcher.test.ts +200 -0
  89. package/src/task-lifecycle-watcher.ts +49 -0
  90. package/src/task-network-resolution-http.test.ts +137 -0
  91. package/src/tasks-pagination-http.test.ts +137 -0
  92. package/src/tools.ts +2302 -169
  93. package/src/update-provider.test.ts +234 -0
  94. package/src/upload-early-reject-guard.test.ts +155 -0
  95. package/src/uploads-http.test.ts +92 -14
  96. package/src/uploads.test.ts +66 -0
  97. package/src/uploads.ts +130 -5
  98. package/src/vault.test.ts +200 -0
  99. package/src/vault.ts +156 -0
package/src/push.ts CHANGED
@@ -97,6 +97,40 @@ function clientKey(sessionName: string, networkId?: string | null): string {
97
97
  return `${networkId || "global"}:${sessionName}`;
98
98
  }
99
99
 
100
+ // ── #461 network observer channels ───────────────────────────────────
101
+ // Dashboard (or any network member) subscribes GET /events/network/:id
102
+ // and receives SUMMARY events for ALL new_task / new_reply traffic in
103
+ // that network — not just tasks it sent/received. Summary events carry
104
+ // ids + routing metadata only, never task/reply content.
105
+ //
106
+ // Key scheme: leading NUL byte makes an observer key impossible to
107
+ // forge from the session scheme above (`${networkId}:${sessionName}`)
108
+ // for any networkId the hub itself issues (net_* / "global"). Only a
109
+ // master-token caller could even inject a NUL into network_id via the
110
+ // legacy ?network_id= path, and master token is root already.
111
+ const OBSERVER_KEY_PREFIX = "\u0000netobs:";
112
+
113
+ /** Printable form of `OBSERVER_KEY_PREFIX` — the shape emitted by
114
+ * `printableKey()` (below) and therefore the shape callers see via
115
+ * `getSSEStats().sessions`. Exported so downstream consumers (e.g.
116
+ * `/health` filter in server.ts) key off the same literal instead of
117
+ * each side re-writing the escape sequence — CR3 audit 4703b0e7
118
+ * found server.ts had `"netobs:"` (no `\\0`) which never matched and
119
+ * silently over-filtered observer keys. */
120
+ export const PRINTABLE_OBSERVER_KEY_PREFIX = "\\0netobs:";
121
+
122
+ function observerKey(networkId: string): string {
123
+ return `${OBSERVER_KEY_PREFIX}${networkId}`;
124
+ }
125
+
126
+ /** Render a client key for logs / stats. Observer keys embed a raw NUL
127
+ * byte (collision guard above); printing that verbatim makes grep treat
128
+ * hub logs as binary and breaks log shippers, and getSSEStats feeds
129
+ * /health. Display as a visible "\\0" escape instead. */
130
+ function printableKey(key: string): string {
131
+ return key.split("\u0000").join("\\0");
132
+ }
133
+
100
134
  // ─────────────────────────────────────────────────────────────────────
101
135
  // Close + dispatch helpers
102
136
  // ─────────────────────────────────────────────────────────────────────
@@ -114,7 +148,7 @@ function closeClient(client: SSEClient, reason: string): void {
114
148
  // Already closed by the runtime, or controller errored. Either way
115
149
  // we're done with it.
116
150
  }
117
- console.log(`[${ts()}] SSE ✕ ${client.key} closed (reason=${reason})`);
151
+ console.log(`[${ts()}] SSE ✕ ${printableKey(client.key)} closed (reason=${reason})`);
118
152
  }
119
153
 
120
154
  /** Best-effort enqueue with backpressure guard. Returns:
@@ -215,8 +249,24 @@ function ensureLivenessSweep(): void {
215
249
 
216
250
  /** 创建 SSE Response 并注册到 clients map */
217
251
  export function createSSEStream(sessionName: string, networkId?: string | null): Response {
252
+ return createStreamForKey(
253
+ clientKey(sessionName, networkId),
254
+ { type: "connected", session: sessionName, network_id: networkId ?? null },
255
+ );
256
+ }
257
+
258
+ /** #461 — 创建网络级观察者 SSE Response(dashboard 观察第三方流量)。
259
+ * Shares the exact same registration / backpressure / liveness
260
+ * machinery as session streams; only the key scheme differs. */
261
+ export function createNetworkObserverStream(networkId: string): Response {
262
+ return createStreamForKey(
263
+ observerKey(networkId),
264
+ { type: "connected", observer: true, network_id: networkId },
265
+ );
266
+ }
267
+
268
+ function createStreamForKey(key: string, initialEvent: Record<string, unknown>): Response {
218
269
  const encoder = new TextEncoder();
219
- const key = clientKey(sessionName, networkId);
220
270
  let client: SSEClient;
221
271
 
222
272
  const stream = new ReadableStream<Uint8Array>({
@@ -231,13 +281,13 @@ export function createSSEStream(sessionName: string, networkId?: string | null):
231
281
 
232
282
  if (!clients.has(key)) clients.set(key, []);
233
283
  clients.get(key)!.push(client);
234
- console.log(`[${ts()}] SSE ← ${key} connected (${clients.get(key)!.length} clients)`);
284
+ console.log(`[${ts()}] SSE ← ${printableKey(key)} connected (${clients.get(key)!.length} clients)`);
235
285
 
236
286
  // Send initial connected frame through the backpressure guard so
237
287
  // even the first byte respects the contract.
238
288
  tryEnqueueBytes(
239
289
  client,
240
- encoder.encode(`data: ${JSON.stringify({ type: "connected", session: sessionName, network_id: networkId ?? null })}\n\n`),
290
+ encoder.encode(`data: ${JSON.stringify(initialEvent)}\n\n`),
241
291
  );
242
292
 
243
293
  // Periodic keepalive doubles as a half-open probe — if the
@@ -280,7 +330,12 @@ export function createSSEStream(sessionName: string, networkId?: string | null):
280
330
 
281
331
  return new Response(stream, {
282
332
  headers: {
283
- "Content-Type": "text/event-stream",
333
+ // #426: explicit charset so legacy Windows clients (PowerShell 5.1's
334
+ // Invoke-RestMethod / Invoke-WebRequest default to ISO-8859-1 when
335
+ // Content-Type omits the charset) decode UTF-8 payloads correctly.
336
+ // Hub itself was already writing clean UTF-8 bytes; the client-side
337
+ // 双重编码样式 mojibake was the header telling the client to guess.
338
+ "Content-Type": "text/event-stream; charset=utf-8",
284
339
  "Cache-Control": "no-cache, no-transform",
285
340
  "Connection": "keep-alive",
286
341
  },
@@ -294,7 +349,7 @@ function pruneClosed(key: string): void {
294
349
  const alive = arr.filter((c) => !c.closed);
295
350
  if (alive.length === 0) {
296
351
  clients.delete(key);
297
- console.log(`[${ts()}] SSE ✕ ${key} disconnected (0 remaining)`);
352
+ console.log(`[${ts()}] SSE ✕ ${printableKey(key)} disconnected (0 remaining)`);
298
353
  } else if (alive.length !== arr.length) {
299
354
  clients.set(key, alive);
300
355
  }
@@ -347,6 +402,40 @@ export function pushEvent(sessionName: string, event: Record<string, unknown>, n
347
402
  if (needPrune) pruneClosed(key);
348
403
  }
349
404
 
405
+ /** #461 — 推送网络级摘要事件给该网络的所有观察者连接。
406
+ *
407
+ * Callers pass ROUTING METADATA ONLY (task_id / from / to / status /
408
+ * priority / message_id) — never task or reply content. The event is
409
+ * stamped with `network_id` + `scope: "network"` here so observers can
410
+ * always tell which network the event belongs to and that it came from
411
+ * the observer stream rather than their personal channel.
412
+ *
413
+ * No-op when networkId is falsy: legacy null-network traffic has no
414
+ * observer stream (the route requires an explicit network id). */
415
+ export function pushNetworkObserverEvent(
416
+ networkId: string | null | undefined,
417
+ event: Record<string, unknown>,
418
+ ): void {
419
+ if (!networkId) return;
420
+ const key = observerKey(networkId);
421
+ const arr = clients.get(key);
422
+ if (!arr || arr.length === 0) return;
423
+
424
+ const data = `data: ${JSON.stringify({ ...event, network_id: networkId, scope: "network" })}\n\n`;
425
+ let needPrune = false;
426
+
427
+ for (const c of arr) {
428
+ if (c.closed) {
429
+ needPrune = true;
430
+ continue;
431
+ }
432
+ const result = tryEnqueueBytes(c, c.encoder.encode(data));
433
+ if (result === "dead") needPrune = true;
434
+ }
435
+
436
+ if (needPrune) pruneClosed(key);
437
+ }
438
+
350
439
  export function __resetSSEClientsForTest(): void {
351
440
  for (const arr of clients.values()) {
352
441
  for (const c of arr) closeClient(c, "reset-for-test");
@@ -365,7 +454,7 @@ export function getSSEStats(): { total: number; sessions: Record<string, number>
365
454
  for (const [name, arr] of clients) {
366
455
  const alive = arr.filter((c) => !c.closed);
367
456
  if (alive.length === 0) continue;
368
- sessions[name] = alive.length;
457
+ sessions[printableKey(name)] = alive.length;
369
458
  total += alive.length;
370
459
  }
371
460
  return { total, sessions };
@@ -0,0 +1,9 @@
1
+ // #434: real-server integration tests must know their DB before importing
2
+ // auth/db/server modules. Setting COMMHUB_DB in beforeAll is too late because
3
+ // ESM dependencies have already evaluated the db singleton.
4
+ if (!process.env.COMMHUB_DB) {
5
+ throw new Error(
6
+ "explicit_test_database_required: run `cd server && bun run test`, " +
7
+ "or set COMMHUB_DB to an isolated temporary path before running this file",
8
+ );
9
+ }
@@ -0,0 +1,57 @@
1
+ import { expect, test } from "bun:test";
2
+ import { DEFAULT_PORT, resolvePort } from "./resolve-port";
3
+
4
+ // The whole reason this module exists.
5
+ test("PORT=0 means an ephemeral port, not the production default", () => {
6
+ expect(resolvePort("0")).toBe(0);
7
+ expect(resolvePort("0")).not.toBe(DEFAULT_PORT);
8
+ });
9
+
10
+ test("unset or empty falls back to the default", () => {
11
+ expect(resolvePort(undefined)).toBe(DEFAULT_PORT);
12
+ expect(resolvePort("")).toBe(DEFAULT_PORT);
13
+ expect(resolvePort(" ")).toBe(DEFAULT_PORT);
14
+ });
15
+
16
+ test("surrounding whitespace is tolerated — it is trimmed, not treated as malformed", () => {
17
+ expect(resolvePort(" 9201 ")).toBe(9201);
18
+ expect(resolvePort("\t0\n")).toBe(0);
19
+ });
20
+
21
+ test("an explicit port is used verbatim", () => {
22
+ expect(resolvePort("9201")).toBe(9201);
23
+ expect(resolvePort("1")).toBe(1);
24
+ expect(resolvePort("65535")).toBe(65535);
25
+ });
26
+
27
+ // Defaulting on a malformed value means a typo silently starts the server on
28
+ // the production port — on this fleet, on top of the running Hub.
29
+ test("a malformed value is rejected, never quietly defaulted", () => {
30
+ for (const bad of ["abc", "80a", "-1", "65536", "1.5", "0x10", "NaN", "Infinity", "9 200", "+80"]) {
31
+ expect(() => resolvePort(bad)).toThrow();
32
+ }
33
+ });
34
+
35
+ test("the rejection names the value and the accepted range", () => {
36
+ try {
37
+ resolvePort("abc");
38
+ throw new Error("should have thrown");
39
+ } catch (e: any) {
40
+ expect(e.message).toContain("abc");
41
+ expect(e.message).toContain("0 and 65535");
42
+ expect(e.message).toContain("ephemeral");
43
+ }
44
+ });
45
+
46
+ test("a caller can override the fallback without touching the default", () => {
47
+ expect(resolvePort(undefined, 3000)).toBe(3000);
48
+ expect(resolvePort("0", 3000)).toBe(0);
49
+ });
50
+
51
+ test("server.ts resolves PORT through this module, not through `|| DEFAULT`", () => {
52
+ const src = require("fs").readFileSync(require("path").join(import.meta.dir, "server.ts"), "utf8");
53
+ const code = src.split("\n").filter((l: string) => !l.trim().startsWith("//")).join("\n");
54
+ expect(code).toContain("resolvePort(process.env.PORT)");
55
+ // `Number(env) || default` is the exact shape that swallowed the 0.
56
+ expect(code).not.toMatch(/Number\(process\.env\.PORT\)\s*\|\|/);
57
+ });
@@ -0,0 +1,44 @@
1
+ // Resolve the listen port from the environment.
2
+ //
3
+ // `Number(process.env.PORT) || 9200` swallows a legitimate `0`. `Number("0")`
4
+ // is `0`, which is falsy, so `PORT=0` — the conventional way to ask the OS for
5
+ // an ephemeral port — silently became 9200, the production Hub port.
6
+ //
7
+ // Three consequences, and the middle one is the worst:
8
+ //
9
+ // 1. On a host where 9200 is already taken (a running Hub), a test that sets
10
+ // PORT=0 dies with EADDRINUSE and reads as a product bug.
11
+ // task-lifecycle-watcher.test.ts fails on main today for exactly this.
12
+ // 2. On a host where 9200 is FREE, the same test passes — by binding 9200.
13
+ // It is green because it grabbed the production port, not because PORT=0
14
+ // worked. Green for the wrong reason is worse than red.
15
+ // 3. Anyone asking for an ephemeral port gets the production port instead.
16
+ //
17
+ // The file already knew: `bootServer` uses `opts.port ?? PORT` with a comment
18
+ // saying `||` "would swallow a legitimate 0". The rule was one level up from
19
+ // where it was needed.
20
+ //
21
+ // A malformed value is rejected rather than defaulted. Falling back to 9200 on
22
+ // `PORT=abc` means a typo silently starts the server somewhere the operator did
23
+ // not ask for — and on this fleet that somewhere is production.
24
+
25
+ export const DEFAULT_PORT = 9200;
26
+
27
+ export function resolvePort(raw: string | undefined, fallback = DEFAULT_PORT): number {
28
+ // Unset or empty means "not specified". An empty string is what a shell
29
+ // exports for an unset variable it still passes along, so treating it as 0
30
+ // would make `PORT= anet hub start` bind an ephemeral port by accident.
31
+ if (raw === undefined || raw.trim() === "") return fallback;
32
+
33
+ // Decimal digits only, after trimming. `Number()` alone accepts "0x10" (16)
34
+ // and " 9200 ", so a value that does not look like a port would still resolve
35
+ // to one — quietly, and to a different number than the operator typed.
36
+ const text = raw.trim();
37
+ const n = /^\d+$/.test(text) ? Number(text) : NaN;
38
+ if (!Number.isInteger(n) || n < 0 || n > 65535) {
39
+ throw new Error(
40
+ `PORT must be an integer between 0 and 65535 (0 asks the OS for an ephemeral port); got ${JSON.stringify(raw)}`,
41
+ );
42
+ }
43
+ return n;
44
+ }
@@ -0,0 +1,130 @@
1
+ // #426 unit tests for the withUtf8CharsetContentType helper — the
2
+ // piece that rewrites text-y Content-Type headers to include
3
+ // charset=utf-8 so legacy Windows clients don't fall back to
4
+ // ISO-8859-1 decoding. The helper lives in server/src/index.ts;
5
+ // to keep the test standalone we re-implement its pure semantics here
6
+ // and assert those. Any drift between this pure copy and the
7
+ // production helper is caught by the "identical semantics" test at
8
+ // the bottom via a source grep.
9
+
10
+ import { describe, expect, test } from "bun:test";
11
+ import { readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+
14
+ // Pure re-implementation — MUST match server/src/index.ts:withUtf8CharsetContentType.
15
+ function withUtf8CharsetContentType(res: Response): Response {
16
+ const ct = res.headers.get("content-type");
17
+ if (!ct) return res;
18
+ if (/;\s*charset=/i.test(ct)) return res;
19
+ const trimmed = ct.trim();
20
+ if (
21
+ trimmed === "application/json" ||
22
+ trimmed.startsWith("text/") ||
23
+ trimmed.startsWith("application/json;") ||
24
+ trimmed === "application/ld+json" ||
25
+ trimmed === "application/xml"
26
+ ) {
27
+ res.headers.set("content-type", `${trimmed}; charset=utf-8`);
28
+ }
29
+ return res;
30
+ }
31
+
32
+ function makeRes(contentType: string | null, body = ""): Response {
33
+ return new Response(body, { headers: contentType ? { "content-type": contentType } : {} });
34
+ }
35
+
36
+ describe("withUtf8CharsetContentType — #426", () => {
37
+ test("appends charset to text/event-stream", () => {
38
+ const res = withUtf8CharsetContentType(makeRes("text/event-stream"));
39
+ expect(res.headers.get("content-type")).toBe("text/event-stream; charset=utf-8");
40
+ });
41
+
42
+ test("appends charset to application/json (bare)", () => {
43
+ const res = withUtf8CharsetContentType(makeRes("application/json"));
44
+ expect(res.headers.get("content-type")).toBe("application/json; charset=utf-8");
45
+ });
46
+
47
+ test("appends charset to text/plain", () => {
48
+ const res = withUtf8CharsetContentType(makeRes("text/plain"));
49
+ expect(res.headers.get("content-type")).toBe("text/plain; charset=utf-8");
50
+ });
51
+
52
+ test("appends charset to text/html", () => {
53
+ const res = withUtf8CharsetContentType(makeRes("text/html"));
54
+ expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
55
+ });
56
+
57
+ test("no-op when charset already present (case-insensitive)", () => {
58
+ for (const ct of [
59
+ "application/json; charset=utf-8",
60
+ "application/json;charset=UTF-8",
61
+ "text/event-stream;Charset=us-ascii",
62
+ "text/plain; CHARSET=iso-8859-1",
63
+ ]) {
64
+ const res = withUtf8CharsetContentType(makeRes(ct));
65
+ expect(res.headers.get("content-type")).toBe(ct);
66
+ }
67
+ });
68
+
69
+ test("no-op on binary types (opaque)", () => {
70
+ for (const ct of [
71
+ "application/octet-stream",
72
+ "image/png",
73
+ "image/jpeg",
74
+ "video/mp4",
75
+ "application/pdf",
76
+ "font/woff2",
77
+ ]) {
78
+ const res = withUtf8CharsetContentType(makeRes(ct));
79
+ expect(res.headers.get("content-type")).toBe(ct);
80
+ }
81
+ });
82
+
83
+ test("no-op when Content-Type header is absent entirely (returns as-is)", () => {
84
+ const res = withUtf8CharsetContentType(makeRes(null));
85
+ expect(res.headers.get("content-type")).toBeNull();
86
+ });
87
+
88
+ test("handles application/ld+json / application/xml", () => {
89
+ expect(withUtf8CharsetContentType(makeRes("application/ld+json")).headers.get("content-type"))
90
+ .toBe("application/ld+json; charset=utf-8");
91
+ expect(withUtf8CharsetContentType(makeRes("application/xml")).headers.get("content-type"))
92
+ .toBe("application/xml; charset=utf-8");
93
+ });
94
+
95
+ test("preserves non-charset parameters and appends charset (application/json; boundary=…)", () => {
96
+ // Bun's Response.json emits `application/json;charset=utf-8`; the test
97
+ // above covers that no-op path. When a caller sends a parametrised
98
+ // header like `application/json; profile=…`, the helper still recognises
99
+ // it via the `application/json;` prefix and appends the charset.
100
+ const res = withUtf8CharsetContentType(makeRes("application/json; profile=example"));
101
+ expect(res.headers.get("content-type")).toBe("application/json; profile=example; charset=utf-8");
102
+ });
103
+
104
+ test("returns the same Response instance (mutates in place)", () => {
105
+ const input = makeRes("text/event-stream");
106
+ const output = withUtf8CharsetContentType(input);
107
+ expect(output).toBe(input);
108
+ });
109
+
110
+ // Drift guard: the pure copy in this file MUST match the production
111
+ // helper's branching set. If someone edits one without touching the
112
+ // other, this test fails visibly instead of the helper going silently
113
+ // out of sync. Kept as fragment-level greps (not whole-body match) so
114
+ // added comments don't break the assertion.
115
+ test("production helper covers the same content-type branches", () => {
116
+ const src = readFileSync(join(import.meta.dir, "server.ts"), "utf-8");
117
+ expect(src).toContain("function withUtf8CharsetContentType(res: Response): Response {");
118
+ expect(src).toContain("if (/;\\s*charset=/i.test(ct)) return res;");
119
+ for (const branch of [
120
+ 'trimmed === "application/json"',
121
+ 'trimmed.startsWith("text/")',
122
+ 'trimmed.startsWith("application/json;")',
123
+ 'trimmed === "application/ld+json"',
124
+ 'trimmed === "application/xml"',
125
+ ]) {
126
+ expect(src).toContain(branch);
127
+ }
128
+ expect(src).toContain('res.headers.set("content-type", `${trimmed}; charset=utf-8`);');
129
+ });
130
+ });
@@ -0,0 +1,211 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-rest-columns-"));
7
+ process.env.COMMHUB_DB ||= join(PRIVATE_DB_DIR, "hub.db");
8
+
9
+ let db: typeof import("./db.js").db;
10
+ let server: ReturnType<typeof Bun.serve>;
11
+ let base = "";
12
+ let token = "";
13
+ let networkId = "";
14
+
15
+ const INTERNAL_SENTINEL = "future_internal_only";
16
+ const sortedKeys = (value: Record<string, unknown>): string[] => Object.keys(value).sort();
17
+ const sorted = (values: readonly string[]): string[] => [...values].sort();
18
+
19
+ // Independent wire-contract golden. Do not derive these expectations from
20
+ // rest-projections.ts: doing so makes a deleted projection column disappear
21
+ // from both the response and the expected value, producing a false green.
22
+ const GOLDEN_RESPONSE_KEYS = {
23
+ networkList: [
24
+ "network_id", "network_name", "owner_id", "description", "settings",
25
+ "created_at", "updated_at", "visibility", "max_members", "member_role", "name",
26
+ ],
27
+ // 🔴 #1097:GET /api/auth/me 走的是 auth.ts 的 getUserAllNetworks(),
28
+ // 与 GET /api/networks 的 admin 分支**不是同一条查询**。
29
+ // server.ts:985 直接返回原始行,没有 :1166 那个 withNetworkNameAlias,
30
+ // 所以比 networkList 少一个 "name"。这里逐字写死,不从 rest-projections.ts 推导
31
+ // —— 理由见本文件顶部:从投影推导会让被删掉的列同时从响应和期望里消失,产生假绿。
32
+ authMeNetwork: [
33
+ "network_id", "network_name", "owner_id", "description", "settings",
34
+ "created_at", "updated_at", "visibility", "max_members", "member_role",
35
+ ],
36
+ networkDetail: [
37
+ "network_id", "network_name", "owner_id", "description", "settings",
38
+ "created_at", "updated_at", "visibility", "max_members",
39
+ ],
40
+ statusSession: [
41
+ "resume_id", "alias", "tmux_name", "server", "ip", "hostname", "agent",
42
+ "project_dir", "version", "status", "task", "output", "progress", "score",
43
+ "cpu_load_1min", "cpu_cores", "mem_total_gb", "mem_used_gb", "mem_avail_gb",
44
+ "disk_total_gb", "disk_used_gb", "disk_avail_gb", "process_rss_bytes",
45
+ "process_rss_mb", "process_cpu_pct", "process_uptime_seconds",
46
+ "process_in_flight_count", "network_id", "registered_at", "updated_at",
47
+ "node_id", "session_id", "config_path", "channels", "last_seen_at", "model",
48
+ "external_schedules", "runtime", "host", "process_telemetry",
49
+ ],
50
+ task: [
51
+ "task_id", "from_node_id", "from_name", "to_node_id", "to_name", "priority",
52
+ "status", "content", "result", "in_reply_to", "requires_response", "scope",
53
+ "created_at", "delivered_at", "started_at", "runtime_submitted_at", "consumed_at",
54
+ "completed_at", "expires_at", "network_id", "parent_task_id", "meta_json",
55
+ ],
56
+ audit: [
57
+ "id", "user_id", "username", "action", "target_type", "target_id", "detail",
58
+ "ip", "network_id", "created_at",
59
+ ],
60
+ taskEvent: [
61
+ "id", "task_id", "from_status", "to_status", "event_type", "actor", "detail",
62
+ "created_at", "network_id",
63
+ ],
64
+ completion: [
65
+ "id", "session_name", "task", "result", "artifacts", "score",
66
+ "duration_minutes", "network_id", "completed_at",
67
+ ],
68
+ scheduledTask: [
69
+ "schedule_id", "network_id", "name", "target_node_id", "target_alias",
70
+ "task_content", "priority", "schedule_type", "timezone", "overlap_policy",
71
+ "misfire_policy", "status", "next_run_at", "last_run_at", "revision",
72
+ "created_at", "updated_at", "schedule",
73
+ ],
74
+ } as const;
75
+
76
+ async function api(path: string): Promise<any> {
77
+ const response = await fetch(`${base}${path}`, {
78
+ headers: { Authorization: `Bearer ${token}` },
79
+ });
80
+ expect(response.status).toBe(200);
81
+ return response.json();
82
+ }
83
+
84
+ beforeAll(async () => {
85
+ ({ db } = await import("./db.js"));
86
+ const { register } = await import("./auth.js");
87
+ const auth = register(`rest_shape_${Date.now()}`, "RestShape-Strong-1!", undefined, "rest-shape");
88
+ expect(auth.ok).toBe(true);
89
+ token = auth.token!;
90
+ networkId = auth.network_id!;
91
+
92
+ // Simulate future migrations. Any REST SELECT * silently broadcasts these
93
+ // columns; every endpoint below must keep them private without needing a
94
+ // code change when the table grows.
95
+ for (const table of ["networks", "sessions", "audit_log", "task_events", "tasks", "completions", "scheduled_tasks"]) {
96
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${INTERNAL_SENTINEL} TEXT`);
97
+ }
98
+
99
+ db.run(
100
+ `INSERT INTO sessions (resume_id, alias, status, agent, model, network_id, external_schedules)
101
+ VALUES ('resume-rest-shape', 'rest-shape-node', 'idle', 'agent-node:codex', 'gpt-shape', ?1, ?2)`,
102
+ [networkId, JSON.stringify({ observed_at: "2026-08-10T02:00:00.000Z", schedules: [] })],
103
+ );
104
+ db.run(
105
+ `INSERT INTO tasks (task_id, from_name, to_name, status, content, priority, network_id, parent_task_id, meta_json)
106
+ VALUES ('task-rest-shape', 'sender', 'rest-shape-node', 'delivered', 'shape', 'normal', ?1, 'parent-shape', '{"attachment":true}')`,
107
+ [networkId],
108
+ );
109
+ db.run(
110
+ `INSERT INTO task_events (task_id, from_status, to_status, actor, detail, network_id)
111
+ VALUES ('task-rest-shape', 'created', 'delivered', 'shape-test', 'shape', ?1)`,
112
+ [networkId],
113
+ );
114
+ db.run(
115
+ `INSERT INTO completions (id, session_name, task, result, network_id)
116
+ VALUES ('completion-rest-shape', 'rest-shape-node', 'shape', 'done', ?1)`,
117
+ [networkId],
118
+ );
119
+ db.run(
120
+ `INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, ip, network_id)
121
+ VALUES ((SELECT owner_id FROM networks WHERE network_id = ?1), 'shape-user', 'shape_action', 'shape', 'shape-id', 'shape', '127.0.0.1', ?1)`,
122
+ [networkId],
123
+ );
124
+ db.run(
125
+ `INSERT INTO scheduled_tasks
126
+ (schedule_id, network_id, created_by, name, target_node_id, target_alias,
127
+ task_content, schedule_type, schedule_json, timezone, next_run_at)
128
+ VALUES ('schedule-rest-shape', ?1, 'private-user-id', 'shape schedule',
129
+ 'node-rest-shape', 'rest-shape-node', 'shape task', 'interval',
130
+ '{"type":"interval","every_seconds":3600}', 'UTC', '2099-01-01T00:00:00.000Z')`,
131
+ [networkId],
132
+ );
133
+
134
+ const mod = await import("./server.js");
135
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
136
+ base = `http://127.0.0.1:${server.port}`;
137
+ });
138
+
139
+ afterAll(() => {
140
+ try { server?.stop(true); } catch {}
141
+ try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
142
+ });
143
+
144
+ describe("#311 REST response projections are stable across future ALTER TABLE", () => {
145
+ test("network list and detail preserve their existing public keys", async () => {
146
+ const list = await api("/api/networks");
147
+ const row = list.networks.find((item: any) => item.network_id === networkId);
148
+ expect(sortedKeys(row)).toEqual(sorted(GOLDEN_RESPONSE_KEYS.networkList));
149
+ expect(row[INTERNAL_SENTINEL]).toBeUndefined();
150
+
151
+ const detail = await api(`/api/networks/${networkId}`);
152
+ expect(sortedKeys(detail.network)).toEqual(sorted(GOLDEN_RESPONSE_KEYS.networkDetail));
153
+ expect(detail.network[INTERNAL_SENTINEL]).toBeUndefined();
154
+ });
155
+
156
+ // 🔴 #1097:这条补的是 test647 抓到的一个**真覆盖洞**。
157
+ // tests/test647-rest-explicit-columns 的 L3b 变异把 auth.ts 的
158
+ // `SELECT ${sqlColumns(NETWORK_REST_COLUMNS, "n")}, nm.role as member_role`
159
+ // 改成 `SELECT n.*, …`,而**整套测试仍然全绿** —— 因为 getUserAllNetworks()
160
+ // 只在两处可达:server.ts:985(GET /api/auth/me) 与 server.ts:1165
161
+ // (GET /api/networks 的三元 **else** 分支),而上面那条测试打的是 /api/networks
162
+ // 且它的用户走 **if** 分支(:1156-1162 的内联查询)⇒ 两条可达路径一条都没覆盖。
163
+ test("auth/me networks come from an explicit column list, not SELECT *", async () => {
164
+ const me = await api("/api/auth/me");
165
+ const row = me.networks.find((item: any) => item.network_id === networkId);
166
+ expect(row).toBeDefined();
167
+ expect(sortedKeys(row)).toEqual(sorted(GOLDEN_RESPONSE_KEYS.authMeNetwork));
168
+ expect(row[INTERNAL_SENTINEL]).toBeUndefined();
169
+ });
170
+
171
+ test("full status preserves telemetry compatibility without storage drift", async () => {
172
+ const body = await api(`/api/status?network_id=${networkId}`);
173
+ const row = body.sessions.find((item: any) => item.alias === "rest-shape-node");
174
+ expect(sortedKeys(row)).toEqual(sorted(GOLDEN_RESPONSE_KEYS.statusSession));
175
+ expect(row[INTERNAL_SENTINEL]).toBeUndefined();
176
+ expect(row.external_schedules).toEqual({ observed_at: "2026-08-10T02:00:00.000Z", schedules: [] });
177
+ });
178
+
179
+ test("task list and task detail expose the same explicit contract", async () => {
180
+ const list = await api(`/api/tasks?network_id=${networkId}&task_id=task-rest-shape&skip_stats=1`);
181
+ expect(sortedKeys(list.tasks[0])).toEqual(sorted(GOLDEN_RESPONSE_KEYS.task));
182
+ expect(list.tasks[0][INTERNAL_SENTINEL]).toBeUndefined();
183
+
184
+ const detail = await api(`/api/tasks/task-rest-shape?network_id=${networkId}`);
185
+ expect(sortedKeys(detail.task)).toEqual(sorted(GOLDEN_RESPONSE_KEYS.task));
186
+ expect(detail.task[INTERNAL_SENTINEL]).toBeUndefined();
187
+ });
188
+
189
+ test("audit, task-event, and completion rows are explicit", async () => {
190
+ const audit = await api("/api/audit-log?action=shape_action");
191
+ expect(sortedKeys(audit.logs[0])).toEqual(sorted(GOLDEN_RESPONSE_KEYS.audit));
192
+ expect(audit.logs[0][INTERNAL_SENTINEL]).toBeUndefined();
193
+
194
+ const events = await api(`/api/task_events?network_id=${networkId}&task_id=task-rest-shape`);
195
+ expect(sortedKeys(events.events[0])).toEqual(sorted(GOLDEN_RESPONSE_KEYS.taskEvent));
196
+ expect(events.events[0][INTERNAL_SENTINEL]).toBeUndefined();
197
+
198
+ const completions = await api(`/api/completions?network_id=${networkId}&since=2000-01-01T00:00:00.000Z`);
199
+ expect(sortedKeys(completions.completions[0])).toEqual(sorted(GOLDEN_RESPONSE_KEYS.completion));
200
+ expect(completions.completions[0][INTERNAL_SENTINEL]).toBeUndefined();
201
+ });
202
+
203
+ test("scheduled-task decoding keeps storage-only identity and JSON private", async () => {
204
+ const body = await api(`/api/scheduled-tasks?network_id=${networkId}`);
205
+ const row = body.schedules.find((item: any) => item.schedule_id === "schedule-rest-shape");
206
+ expect(sortedKeys(row)).toEqual(sorted(GOLDEN_RESPONSE_KEYS.scheduledTask));
207
+ expect(row.created_by).toBeUndefined();
208
+ expect(row.schedule_json).toBeUndefined();
209
+ expect(row[INTERNAL_SENTINEL]).toBeUndefined();
210
+ });
211
+ });