@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.
- package/package.json +1 -1
- 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.
|
|
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
|
-
|
|
547
|
-
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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 (!
|
|
556
|
-
return withCors(req, Response.json({
|
|
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
|
}
|