@sleep2agi/commhub-server 0.8.7-preview.0 → 0.8.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.8.7-preview.0",
3
+ "version": "0.8.8",
4
4
  "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 17 MCP tools.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -13,7 +13,8 @@
13
13
  ],
14
14
  "scripts": {
15
15
  "start": "bun run src/index.ts",
16
- "dev": "bun --watch run src/index.ts"
16
+ "dev": "bun --watch run src/index.ts",
17
+ "test": "COMMHUB_DB=/tmp/test-commhub-$$.db bun test src/"
17
18
  },
18
19
  "keywords": [
19
20
  "commhub",
package/src/db-adapter.ts CHANGED
@@ -216,6 +216,25 @@ export class PgAdapter implements DbAdapter {
216
216
  * Create the appropriate adapter based on environment.
217
217
  * - DATABASE_URL starts with "postgres://" → PgAdapter
218
218
  * - Otherwise → SQLiteAdapter with COMMHUB_DB or default path
219
+ *
220
+ * Test-default-prod GUARD: when `NODE_ENV === "test"` (set automatically by
221
+ * `bun test`) AND `COMMHUB_DB` is unset, we REFUSE to fall through to the
222
+ * default `~/.commhub/commhub.db` path. Several modules (notably
223
+ * `server/src/auth.ts register()`) write into the configured database at
224
+ * import-time-side-effect granularity; running their tests against the
225
+ * production hub DB silently created spurious users / networks / tokens
226
+ * on 2026-06-23 (4u / 4net / 8tok by one `bun -e` probe + an unknown
227
+ * pre-existing history of test pollution in the same DB). The guard makes
228
+ * the failure mode loud + actionable instead of silent + destructive.
229
+ *
230
+ * The guard does NOT fire when:
231
+ * - COMMHUB_DB is set (the test runner's documented contract); or
232
+ * - NODE_ENV is unset (production server) or any non-"test" value.
233
+ *
234
+ * Operators who run `bun -e` snippets that touch the DB are expected to
235
+ * `COMMHUB_DB=/tmp/...` themselves — `bun -e` doesn't set NODE_ENV, so the
236
+ * guard can't catch that case. The rule is documented in CLAUDE.md /
237
+ * memory ([[feedback_no_prod_db_access.md]]).
219
238
  */
220
239
  export function createAdapter(): DbAdapter {
221
240
  const dbUrl = process.env.DATABASE_URL;
@@ -226,6 +245,18 @@ export function createAdapter(): DbAdapter {
226
245
  // Default: SQLite
227
246
  const { mkdirSync } = require("fs");
228
247
  const { dirname } = require("path");
248
+
249
+ // Test-default-prod GUARD (see docblock above).
250
+ if (process.env.NODE_ENV === "test" && !process.env.COMMHUB_DB) {
251
+ throw new Error(
252
+ "[commhub] REFUSING to open the default SQLite database under NODE_ENV=test.\n" +
253
+ " Tests must explicitly set COMMHUB_DB to a throwaway path so they don't\n" +
254
+ " pollute the production hub DB. Run tests via:\n" +
255
+ " COMMHUB_DB=/tmp/test-$$.db bun test src/\n" +
256
+ " (or use the `npm run test` script which sets this for you)."
257
+ );
258
+ }
259
+
229
260
  const dbPath = process.env.COMMHUB_DB || `${process.env.HOME}/.commhub/commhub.db`;
230
261
  mkdirSync(dirname(dbPath), { recursive: true });
231
262
  console.log(`[commhub] database: ${dbPath}`);
package/src/db.ts CHANGED
@@ -168,6 +168,15 @@ db.exec(`
168
168
  CREATE INDEX IF NOT EXISTS idx_tasks_from ON tasks(from_name);
169
169
  CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
170
170
  CREATE INDEX IF NOT EXISTS idx_tasks_created ON tasks(created_at);
171
+ -- #248 — composite index for the dashboard chat-panel history query
172
+ -- (/api/tasks?to_name=X&limit=30 ORDER BY created_at DESC). Without it
173
+ -- SQLite walks every row for the alias via idx_tasks_to and sorts in
174
+ -- memory; on an active node with thousands of tasks that's O(N log N)
175
+ -- per panel open. The composite ordered DESC lets the planner stop
176
+ -- after LIMIT rows — O(log N + LIMIT). Idempotent migration; safe to
177
+ -- ship — existing single-column idx_tasks_to is kept (other paths
178
+ -- use it and we don't need the risk of dropping it).
179
+ CREATE INDEX IF NOT EXISTS idx_tasks_to_created ON tasks(to_name, created_at DESC);
171
180
  `);
172
181
 
173
182
  // nodes table (V2 Sprint 2) — persistent node identity, separate from runtime sessions
package/src/index.ts CHANGED
@@ -1963,6 +1963,12 @@ Bun.serve({
1963
1963
  const toName = url.searchParams.get("to_name");
1964
1964
  const fromName = url.searchParams.get("from_name");
1965
1965
  const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 200);
1966
+ // #248 — opt-out of the stats GROUP-BY scan. The dashboard chat panel
1967
+ // never consumes `stats`; it's a global per-status table scan that
1968
+ // dominates the request time on a large DB (270 MB / many tasks).
1969
+ // Existing callers that don't pass `skip_stats=1` get the same
1970
+ // {ok, tasks, count, stats} shape — backwards-compatible.
1971
+ const skipStats = url.searchParams.get("skip_stats") === "1";
1966
1972
 
1967
1973
  let sql = "SELECT * FROM tasks WHERE 1=1";
1968
1974
  const params: any[] = [];
@@ -1975,6 +1981,9 @@ Bun.serve({
1975
1981
  params.push(limit);
1976
1982
 
1977
1983
  const rows = db.all(sql, ...params);
1984
+ if (skipStats) {
1985
+ return withCors(req, Response.json({ ok: true, tasks: rows, count: rows.length }));
1986
+ }
1978
1987
  const statsParams: any[] = [];
1979
1988
  let statsSql = "SELECT status, COUNT(*) as count FROM tasks WHERE 1=1";
1980
1989
  statsSql = addNetworkScope(statsSql, statsParams, restScope);
package/src/push.ts CHANGED
@@ -7,6 +7,10 @@ import { eventBus, type RenameCommittedEvent } from "./event_bus";
7
7
  type SSEClient = {
8
8
  controller: ReadableStreamDefaultController;
9
9
  encoder: TextEncoder;
10
+ // 30s keepalive interval — stored so cancel() can clear it without
11
+ // relying on stringly-typed any-cast. Set in start(), cleared in cancel()
12
+ // or by the interval itself on enqueue failure.
13
+ keepalive?: ReturnType<typeof setInterval>;
10
14
  };
11
15
 
12
16
  // 一个 session 可能有多个 SSE 连接(重连时短暂并存)
@@ -44,7 +48,7 @@ export function createSSEStream(sessionName: string, networkId?: string | null):
44
48
  clearInterval(keepalive);
45
49
  }
46
50
  }, 30_000);
47
- (client as any)._keepalive = keepalive;
51
+ client.keepalive = keepalive;
48
52
  },
49
53
  cancel() {
50
54
  // 断线清理
@@ -52,7 +56,7 @@ export function createSSEStream(sessionName: string, networkId?: string | null):
52
56
  if (arr) {
53
57
  const idx = arr.findIndex(c => c.controller === ctrl);
54
58
  if (idx !== -1) {
55
- clearInterval((arr[idx] as any)._keepalive);
59
+ if (arr[idx].keepalive) clearInterval(arr[idx].keepalive);
56
60
  arr.splice(idx, 1);
57
61
  }
58
62
  if (arr.length === 0) clients.delete(key);
@@ -98,7 +102,12 @@ eventBus.on("rename-committed", (event: RenameCommittedEvent) => {
98
102
 
99
103
  /** 推送事件给指定 session 的所有 SSE 连接 */
100
104
  export function pushEvent(sessionName: string, event: Record<string, unknown>, networkId?: string | null): void {
101
- const arr = clients.get(clientKey(sessionName, networkId ?? (event.network_id as string | null | undefined)));
105
+ // Resolve the key once — both lookup and post-cleanup `clients.delete` MUST
106
+ // use the same key, otherwise an emptied bucket would leak (the explicit
107
+ // arg wins over event.network_id, falling back so legacy callers that pass
108
+ // network_id inside `event` still hit the right bucket).
109
+ const key = clientKey(sessionName, networkId ?? (event.network_id as string | null | undefined));
110
+ const arr = clients.get(key);
102
111
  if (!arr || arr.length === 0) return;
103
112
 
104
113
  const data = `data: ${JSON.stringify(event)}\n\n`;
@@ -112,11 +121,16 @@ export function pushEvent(sessionName: string, event: Record<string, unknown>, n
112
121
  }
113
122
  }
114
123
 
115
- // 清理死连接
124
+ // 清理死连接 — also clear their keepalive timers so the dead client doesn't
125
+ // keep an interval handle alive past its controller (small leak previously
126
+ // bounded by the next failed enqueue catching itself, but cleanup-on-detect
127
+ // is cheaper than waiting 30s).
116
128
  for (let i = dead.length - 1; i >= 0; i--) {
129
+ const c = arr[dead[i]];
130
+ if (c.keepalive) clearInterval(c.keepalive);
117
131
  arr.splice(dead[i], 1);
118
132
  }
119
- if (arr.length === 0) clients.delete(clientKey(sessionName, networkId ?? (event.network_id as string | null | undefined)));
133
+ if (arr.length === 0) clients.delete(key);
120
134
  }
121
135
 
122
136
  export function __resetSSEClientsForTest(): void {
@@ -117,6 +117,24 @@ describe("SendDedup", () => {
117
117
  expect(d.size).toBeGreaterThan(0);
118
118
  });
119
119
 
120
+ it("evictOldest drops to exactly floor(maxKeys * 0.9) on the first trigger", () => {
121
+ // Regression guard for the size-during-loop bug fixed in the
122
+ // robustness pass. Insert exactly maxKeys+1 records to fire exactly
123
+ // ONE evictOldest call, then snapshot size before any further inserts
124
+ // can re-grow it. With the bug, the loop bound
125
+ // `i < this.last.size - target` shrunk each iteration and only
126
+ // ~half the intended deletions ran (e.g. 14 of 27 for maxKeys=256,
127
+ // landing size at ~243 instead of 230). After the fix size lands at
128
+ // the documented target = floor(maxKeys * 0.9).
129
+ const maxKeys = 256;
130
+ const target = Math.floor(maxKeys * 0.9); // 230
131
+ const d = new SendDedup({ windowMs: 600_000, maxKeys });
132
+ for (let i = 0; i <= maxKeys; i++) {
133
+ d.record(`from-${i}`, "bob", `content-${i}`, 1_000 + i);
134
+ }
135
+ expect(d.size).toBe(target);
136
+ });
137
+
120
138
  it("hashes content rather than holding the raw bytes", () => {
121
139
  // The key function is the public surface that proves we don't keep
122
140
  // raw content in memory between sends — the map keys themselves are
package/src/send_dedup.ts CHANGED
@@ -44,13 +44,20 @@ export type DedupConfig = {
44
44
  };
45
45
 
46
46
  export function readDedupConfig(env: NodeJS.ProcessEnv = process.env): DedupConfig {
47
- const rawWindow = env.COMMHUB_SEND_DEDUP_WINDOW_MS;
48
- const windowMs = rawWindow !== undefined ? Math.max(0, Number(rawWindow)) : 300_000;
49
- const rawMax = env.COMMHUB_SEND_DEDUP_MAX_KEYS;
50
- const maxKeys = rawMax !== undefined ? Math.max(64, Number(rawMax)) : 4096;
47
+ // Parse-then-validate-then-clamp: `Number("abc")` is NaN and `Math.max(0, NaN)`
48
+ // is ALSO NaN per spec, so the old inline `Math.max(0, Number(rawWindow))`
49
+ // relied on the later `Number.isFinite` fallback to repair the result.
50
+ // Hoisting the validity check first makes the intent explicit and removes
51
+ // the risk that a future refactor drops the `isFinite` rescue.
52
+ const parseClamped = (raw: string | undefined, min: number, fallback: number): number => {
53
+ if (raw === undefined) return fallback;
54
+ const n = Number(raw);
55
+ if (!Number.isFinite(n)) return fallback;
56
+ return Math.max(min, n);
57
+ };
51
58
  return {
52
- windowMs: Number.isFinite(windowMs) ? windowMs : 300_000,
53
- maxKeys: Number.isFinite(maxKeys) ? maxKeys : 4096,
59
+ windowMs: parseClamped(env.COMMHUB_SEND_DEDUP_WINDOW_MS, 0, 300_000),
60
+ maxKeys: parseClamped(env.COMMHUB_SEND_DEDUP_MAX_KEYS, 64, 4096),
54
61
  };
55
62
  }
56
63
 
@@ -133,8 +140,16 @@ export class SendDedup {
133
140
  private evictOldest(): void {
134
141
  const target = Math.max(64, Math.floor(this.cfg.maxKeys * 0.9));
135
142
  if (this.last.size <= target) return;
143
+ // CACHE toDelete BEFORE the deletion loop. Previously the loop bound
144
+ // was `i < this.last.size - target`, which is re-evaluated every
145
+ // iteration — and `this.last.size` shrinks by 1 per delete. The result
146
+ // was that we only evicted ~half of the intended count (size landed
147
+ // around 95 % of maxKeys instead of the documented ~90 %). The map
148
+ // stayed bounded so this was efficiency-only, not a leak, but the
149
+ // documented contract was being violated.
150
+ const toDelete = this.last.size - target;
136
151
  const entries = Array.from(this.last.entries()).sort((a, b) => a[1] - b[1]);
137
- for (let i = 0; i < this.last.size - target; i++) this.last.delete(entries[i][0]);
152
+ for (let i = 0; i < toDelete; i++) this.last.delete(entries[i][0]);
138
153
  }
139
154
  }
140
155
 
@@ -187,6 +187,26 @@ describe("UploadRateLimiter", () => {
187
187
  }
188
188
  expect(lim.check("token-y", 1000).allowed).toBe(false);
189
189
  });
190
+
191
+ test("caps in-memory state by evicting oldest entries past maxEntries", () => {
192
+ // A small maxEntries lets us drive the eviction path deterministically.
193
+ // Without this cap the limiter would grow unbounded under an IP-flood
194
+ // (public-facing hub, IPv6 rotation makes each request a "new" key).
195
+ const lim = new UploadRateLimiter(60_000, 5, 4);
196
+ const t0 = 1_700_000_000_000;
197
+ // Fill to cap with non-expired entries — eviction should fire on insert 5.
198
+ for (let i = 0; i < 4; i++) {
199
+ lim.check(`key-${i}`, t0);
200
+ }
201
+ expect(lim.size()).toBe(4);
202
+ lim.check("key-new", t0 + 100);
203
+ expect(lim.size()).toBeLessThanOrEqual(4);
204
+ // Hitting an EXISTING key while at cap must NOT trigger an eviction
205
+ // (an attacker hitting the same key shouldn't push out other callers).
206
+ const beforeSize = lim.size();
207
+ lim.check("key-new", t0 + 200);
208
+ expect(lim.size()).toBe(beforeSize);
209
+ });
190
210
  });
191
211
 
192
212
  describe("validateAttachments", () => {
package/src/uploads.ts CHANGED
@@ -37,6 +37,12 @@ export const MAX_REQUEST_CONTENT_LENGTH = MAX_UPLOAD_BYTES + 1024 * 1024;
37
37
  // the source of truth; resets on restart).
38
38
  export const UPLOAD_RATE_WINDOW_MS = 60 * 60 * 1000;
39
39
  export const UPLOAD_RATE_MAX_PER_WINDOW = 60;
40
+ // Cap the in-memory rate-limit map so a public-facing hub can't be
41
+ // pushed to OOM by a flood of unique IPs / token ids (the call site
42
+ // uses IP-as-key for legacy auth, which is rotatable cheaply over IPv6).
43
+ // Matches LOGIN_GUARD_MAX_ENTRIES in auth_login_guard.ts for symmetric
44
+ // behaviour across the two in-memory limiters this hub maintains.
45
+ export const UPLOAD_RATE_MAX_ENTRIES = 50_000;
40
46
 
41
47
  // Strict file_id regex. Generated server-side via crypto.randomUUID()
42
48
  // with hyphens stripped, so the natural output is 32 hex chars. The
@@ -159,6 +165,7 @@ export class UploadRateLimiter {
159
165
  constructor(
160
166
  private readonly windowMs: number = UPLOAD_RATE_WINDOW_MS,
161
167
  private readonly maxPerWindow: number = UPLOAD_RATE_MAX_PER_WINDOW,
168
+ private readonly maxEntries: number = UPLOAD_RATE_MAX_ENTRIES,
162
169
  ) {}
163
170
 
164
171
  /**
@@ -169,6 +176,14 @@ export class UploadRateLimiter {
169
176
  * Window is rolling on first touch (not aligned to wall-clock hours).
170
177
  */
171
178
  check(key: string, nowMs: number = Date.now()): { allowed: boolean; remaining: number; resetAt: number; retryAfterMs?: number } {
179
+ // Before inserting a brand-new key into a full map, sweep expired
180
+ // entries and (if still over cap) evict the oldest by insertion order.
181
+ // Existing-key updates skip the eviction path so an attacker who
182
+ // keeps hitting the same key can't push out other callers.
183
+ if (this.state.size >= this.maxEntries && !this.state.has(key)) {
184
+ this.pruneExpired(nowMs);
185
+ this.evictOldestUntilBelowCap();
186
+ }
172
187
  const entry = this.state.get(key);
173
188
  if (!entry || nowMs >= entry.resetAt) {
174
189
  const resetAt = nowMs + this.windowMs;
@@ -191,6 +206,20 @@ export class UploadRateLimiter {
191
206
  size(): number {
192
207
  return this.state.size;
193
208
  }
209
+
210
+ private pruneExpired(nowMs: number): void {
211
+ for (const [key, entry] of this.state) {
212
+ if (nowMs >= entry.resetAt) this.state.delete(key);
213
+ }
214
+ }
215
+
216
+ private evictOldestUntilBelowCap(): void {
217
+ while (this.state.size >= this.maxEntries) {
218
+ const oldest = this.state.keys().next().value as string | undefined;
219
+ if (!oldest) return;
220
+ this.state.delete(oldest);
221
+ }
222
+ }
194
223
  }
195
224
 
196
225
  /** Module-level singleton shared by the HTTP /api/upload handler. */