@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 +1 -1
- package/src/db.ts +9 -0
- package/src/index.ts +75 -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.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
|
-
|
|
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);
|
|
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
|
|
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 (!
|
|
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
|
}
|
|
@@ -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);
|