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

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.6",
3
+ "version": "0.8.8-preview.0",
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",
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
@@ -533,6 +533,8 @@ Bun.serve({
533
533
  const token = requestToken(req);
534
534
  const authCtx = resolveRequestAuth(req);
535
535
  const scopedNetId = authCtx?.networkId || url.searchParams.get("network_id");
536
+
537
+ // ── Path 1: legacy AUTH_TOKEN (master token) — unchanged ──
536
538
  if (!authCtx && isLegacyAuthToken(req)) {
537
539
  if (scopedNetId) {
538
540
  const session = db.get<any>(
@@ -543,17 +545,74 @@ Bun.serve({
543
545
  }
544
546
  return createSSEStream(sessionName, scopedNetId);
545
547
  }
546
- if (!token?.startsWith("ntok_") || !authCtx || !scopedNetId) {
547
- return withCors(req, Response.json({ ok: false, error: "network-scoped token required for SSE" }, { status: 403 }));
548
+
549
+ // ── Path 2: ntok_ (network-bound agent token) — pre-#247 behavior preserved ──
550
+ if (token?.startsWith("ntok_")) {
551
+ if (!authCtx || !scopedNetId) {
552
+ return withCors(req, Response.json({ ok: false, error: "network-scoped token required for SSE" }, { status: 403 }));
553
+ }
554
+ const role = getUserNetworkRole(authCtx.userId, scopedNetId);
555
+ if (!role) return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
556
+ const session = db.get<any>(
557
+ "SELECT 1 FROM sessions WHERE alias = ?1 AND network_id = ?2",
558
+ sessionName, scopedNetId
559
+ );
560
+ if (!session && authCtx.networkId !== scopedNetId) {
561
+ return withCors(req, Response.json({ ok: false, error: "session not in requested network" }, { status: 403 }));
562
+ }
563
+ return createSSEStream(sessionName, scopedNetId);
564
+ }
565
+
566
+ // ── Path 3 (#247): utok_ (user token, dashboard SSE path) — 4 gates ──
567
+ //
568
+ // Pre-#247 the only V3 path required `ntok_` and rejected `utok_` outright,
569
+ // which locked the dashboard out of SSE (the dashboard proxy authenticates
570
+ // with the user's `utok_`). That broke real-time chat updates — replies
571
+ // were persisted but never live-pushed to the UI.
572
+ //
573
+ // Auth-scoping rules (all four must pass — see #247 design):
574
+ // gate 1: token resolves to a valid user context (authCtx non-null)
575
+ // gate 2: network scope is explicit — `utok_` has no implicit network,
576
+ // so the caller must pass `?network_id=<id>`
577
+ // gate 3: user is a member of that network (any role: owner / admin / member)
578
+ // gate 4: channel-ownership — one of:
579
+ // (a) sessionName === own username (the dashboard's default channel —
580
+ // `send_reply` pushes to the original sender's alias which equals
581
+ // the dashboard user's username; NO `sessions` row required for
582
+ // this branch since the dashboard user is not a registered agent)
583
+ // (b) sessionName is an existing agent alias in `scopedNetId`
584
+ // (lets members monitor agents within their network)
585
+ //
586
+ // Security: rule 4(a) uses strict equality, so a `utok_` user cannot subscribe
587
+ // to another user's username channel (no cross-user eavesdrop). Rule 4(b) is
588
+ // scoped to `scopedNetId` + gate 3 membership, so cross-network eavesdrop is
589
+ // blocked too. There is intentionally NO org-admin bypass — even users with
590
+ // global `admin` role must be a member of the network they want to listen on.
591
+ if (!authCtx) {
592
+ return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 403 }));
593
+ }
594
+ if (!scopedNetId) {
595
+ return withCors(req, Response.json({
596
+ ok: false,
597
+ error: "network_id required (utok has no implicit network; pass ?network_id=<id>)"
598
+ }, { status: 403 }));
599
+ }
600
+ const utokRole = getUserNetworkRole(authCtx.userId, scopedNetId);
601
+ if (!utokRole) {
602
+ return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
603
+ }
604
+ if (sessionName === authCtx.username) {
605
+ return createSSEStream(sessionName, scopedNetId);
548
606
  }
549
- const role = getUserNetworkRole(authCtx.userId, scopedNetId);
550
- if (!role) return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
551
- const session = db.get<any>(
607
+ const utokSession = db.get<any>(
552
608
  "SELECT 1 FROM sessions WHERE alias = ?1 AND network_id = ?2",
553
609
  sessionName, scopedNetId
554
610
  );
555
- if (!session && authCtx.networkId !== scopedNetId) {
556
- return withCors(req, Response.json({ ok: false, error: "session not in requested network" }, { status: 403 }));
611
+ if (!utokSession) {
612
+ return withCors(req, Response.json({
613
+ ok: false,
614
+ error: "channel not allowed: must be your username or an existing agent in your network"
615
+ }, { status: 403 }));
557
616
  }
558
617
  return createSSEStream(sessionName, scopedNetId);
559
618
  }
@@ -1904,6 +1963,12 @@ Bun.serve({
1904
1963
  const toName = url.searchParams.get("to_name");
1905
1964
  const fromName = url.searchParams.get("from_name");
1906
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";
1907
1972
 
1908
1973
  let sql = "SELECT * FROM tasks WHERE 1=1";
1909
1974
  const params: any[] = [];
@@ -1916,6 +1981,9 @@ Bun.serve({
1916
1981
  params.push(limit);
1917
1982
 
1918
1983
  const rows = db.all(sql, ...params);
1984
+ if (skipStats) {
1985
+ return withCors(req, Response.json({ ok: true, tasks: rows, count: rows.length }));
1986
+ }
1919
1987
  const statsParams: any[] = [];
1920
1988
  let statsSql = "SELECT status, COUNT(*) as count FROM tasks WHERE 1=1";
1921
1989
  statsSql = addNetworkScope(statsSql, statsParams, restScope);