@sleep2agi/commhub-server 0.8.6 → 0.8.7-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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +66 -7
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.7-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/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);
548
564
  }
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>(
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);
606
+ }
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
  }