@sleep2agi/commhub-server 0.8.8-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 +3 -2
- package/src/db-adapter.ts +31 -0
- package/src/push.ts +19 -5
- package/src/send_dedup.test.ts +18 -0
- package/src/send_dedup.ts +22 -7
- package/src/uploads.test.ts +20 -0
- package/src/uploads.ts +29 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.8.8
|
|
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/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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
133
|
+
if (arr.length === 0) clients.delete(key);
|
|
120
134
|
}
|
|
121
135
|
|
|
122
136
|
export function __resetSSEClientsForTest(): void {
|
package/src/send_dedup.test.ts
CHANGED
|
@@ -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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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:
|
|
53
|
-
maxKeys:
|
|
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 <
|
|
152
|
+
for (let i = 0; i < toDelete; i++) this.last.delete(entries[i][0]);
|
|
138
153
|
}
|
|
139
154
|
}
|
|
140
155
|
|
package/src/uploads.test.ts
CHANGED
|
@@ -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. */
|