@sleep2agi/commhub-server 0.9.0-preview.23 → 0.9.0-preview.24

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.9.0-preview.23",
3
+ "version": "0.9.0-preview.24",
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",
@@ -15,7 +15,7 @@ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
15
15
  import { mkdtempSync, rmSync } from "fs";
16
16
  import { join } from "path";
17
17
  import { tmpdir } from "os";
18
- import { register } from "./auth.js";
18
+ import { register, addNetworkMember, removeNetworkMember } from "./auth.js";
19
19
  import { db } from "./db.js";
20
20
 
21
21
  const SERVER_DB = mkdtempSync(join(tmpdir(), "anet-health-red-db-")) + "/commhub.db";
@@ -115,6 +115,76 @@ describe("#473 anonymous /health — watchdog contract intact, no topology leak"
115
115
  });
116
116
  });
117
117
 
118
+ describe("f28a6c1b /health sse_sessions — auth-gated restoration for dashboard 'online' widget", () => {
119
+ // The dashboard's server-side Next.js proxy (agent-network-dashboard
120
+ // `app/api/hub/health/route.ts` → `hubFetch('/health')`) forwards the
121
+ // logged-in user's V3 utok_ as `Authorization: Bearer …`. Anonymous
122
+ // callers (public watchdog, internet strangers) still get NO
123
+ // sse_sessions — that leak stays closed. Authenticated callers get
124
+ // the sessions map scoped to their networks (ops sees all).
125
+
126
+ test("regular member with valid utok_: sse_sessions present, filtered to member's networks", async () => {
127
+ const res = await fetch(`${BASE}/health`, {
128
+ headers: { Authorization: `Bearer ${userToken}` },
129
+ });
130
+ expect(res.status).toBe(200);
131
+ const body = await res.json() as any;
132
+ // Field must be present when authenticated — otherwise dashboard
133
+ // "online" widget shows 0.
134
+ expect("sse_sessions" in body).toBe(true);
135
+ // Must contain the member's own SSE connection key.
136
+ const ownKey = `${userNetworkId}:${userName}`;
137
+ expect(body.sse_sessions[ownKey]).toBeGreaterThanOrEqual(1);
138
+ // Watchdog aggregate contract unchanged.
139
+ expect(body.sse_connections).toBeGreaterThanOrEqual(1);
140
+ });
141
+
142
+ test("admin with valid utok_: sse_sessions present, full unfiltered map (ops parity with /api/stats/sse)", async () => {
143
+ const res = await fetch(`${BASE}/health`, {
144
+ headers: { Authorization: `Bearer ${adminToken}` },
145
+ });
146
+ expect(res.status).toBe(200);
147
+ const body = await res.json() as any;
148
+ expect("sse_sessions" in body).toBe(true);
149
+ // Admin sees the member's key even though they live in a different
150
+ // network (admin scope spans everything, same as /api/stats/sse).
151
+ const memberKey = `${userNetworkId}:${userName}`;
152
+ expect(body.sse_sessions[memberKey]).toBeGreaterThanOrEqual(1);
153
+ // Sanity: admin's view must match the auth-gated ops endpoint.
154
+ const opsRes = await fetch(`${BASE}/api/stats/sse`, {
155
+ headers: { Authorization: `Bearer ${adminToken}` },
156
+ });
157
+ const ops = await opsRes.json() as any;
158
+ expect(body.sse_sessions).toEqual(ops.sessions);
159
+ });
160
+
161
+ test("anonymous still gets NO sse_sessions (leak stays closed, mutation-red witness)", async () => {
162
+ // This is the mutation-red assertion for the auth gate: if the
163
+ // handler stops distinguishing anonymous from authenticated and
164
+ // starts including sse_sessions unconditionally, this test turns
165
+ // RED. Distinct from the older `does NOT expose SSE key detail`
166
+ // test — that one guards against any regression restoring the
167
+ // OLD unconditional leak; this one guards against a well-meaning
168
+ // "just always send it if authenticated is optional" bug.
169
+ const res = await fetch(`${BASE}/health`);
170
+ const body = await res.json() as any;
171
+ expect("sse_sessions" in body).toBe(false);
172
+ });
173
+
174
+ test("invalid/garbage token: treated as anonymous — no sse_sessions", async () => {
175
+ const res = await fetch(`${BASE}/health`, {
176
+ headers: { Authorization: "Bearer utok_completely_fake_nonexistent" },
177
+ });
178
+ expect(res.status).toBe(200);
179
+ const body = await res.json() as any;
180
+ // resolveRequestAuth returns null for unresolvable tokens, so the
181
+ // handler must NOT fall through to "authenticated" branch. Otherwise
182
+ // a bad token would degrade to "anonymous with sse_sessions" — that
183
+ // would be the leak in a different disguise.
184
+ expect("sse_sessions" in body).toBe(false);
185
+ });
186
+ });
187
+
118
188
  describe("#473 GET /api/stats/sse — detail survives, behind ops auth", () => {
119
189
  test("anonymous → 401", async () => {
120
190
  const res = await fetch(`${BASE}/api/stats/sse`);
@@ -174,3 +244,382 @@ describe("#473 GET /api/stats/sse — detail survives, behind ops auth", () => {
174
244
  expect(r.healthHasSseSessions).toBe(false); // /health still redacted with a token set
175
245
  }, 20_000);
176
246
  });
247
+
248
+ // ─────────────────────────────────────────────────────────────────────
249
+ // CR3 (4703b0e7) — M1-M7 matrix tests
250
+ // ─────────────────────────────────────────────────────────────────────
251
+ //
252
+ // Independent audit on aef48a3d showed the existing f28a6c1b tests
253
+ // pinned only the "anonymous vs authenticated" gate. The reviewer
254
+ // mutated `scopedSessions = sse.sessions` (unconditional) and got
255
+ // 11/11 green — meaning the *tenant-scope* invariant (a member sees
256
+ // only their own networks, cross-network is invisible) was not
257
+ // witnessed by any assertion, and a future refactor that broke the
258
+ // filter would ship silently.
259
+ //
260
+ // This block adds explicit tests for every shape the fork's auth
261
+ // decision distinguishes:
262
+ // M1: utok_ member sees own network's sessions
263
+ // M2: utok_ member does NOT see other network's sessions
264
+ // M3: ntok_ (network-scoped token) is forced to the token's bound
265
+ // network — the user's other networks are invisible even
266
+ // though the underlying user is a member
267
+ // M4: same user, utok_ vs ntok_ — utok_ sees union, ntok_ sees only
268
+ // the token's bound network (mutation-red for the ntok_
269
+ // "healthAuth.networkId ? [...] : db.all(...)" branch)
270
+ // M5: membership revocation invalidates visibility immediately —
271
+ // no cache; the next /health request reflects new membership
272
+ // M6: observer keys (shape `\0netobs:<networkId>`; the getSSEStats
273
+ // serializer renders leading NUL as printable `\\0`) are
274
+ // classified through the shared PRINTABLE_OBSERVER_KEY_PREFIX
275
+ // constant, not a re-typed literal. Mutation-red for the
276
+ // constant drift (server.ts had `"netobs:"` without `\\0`
277
+ // before this CR, so all observer keys silently fell through).
278
+ // M7: two networks with the same alias — invisibility survives
279
+ // key collision. Explicit assertion; M1/M2 hide-cover this
280
+ // implicitly but M7 makes the property visible in review.
281
+ describe("f28a6c1b CR3 — network-scoped filter matrix (M1-M7)", () => {
282
+ let mUserAToken = "";
283
+ let mUserANet = "";
284
+ let mUserAName = "";
285
+ let mUserBToken = "";
286
+ let mUserBNet = "";
287
+ let mUserBName = "";
288
+ let mAliceToken = ""; // utok_ (user-scoped)
289
+ let mAliceNtok = ""; // ntok_ bound to mAliceNet
290
+ let mAliceNet = "";
291
+ let mAliceName = "";
292
+ let mCarolToken = "";
293
+ let mCarolNet = "";
294
+ let mCarolName = "";
295
+ let mUserAId = "";
296
+ let mAliceId = "";
297
+ let mCarolId = "";
298
+ let mSameAliasName = ""; // used by M7 — two sessions, one per network
299
+
300
+ const openReaders: ReadableStreamDefaultReader<Uint8Array>[] = [];
301
+
302
+ const startSse = async (base: string, name: string, netId: string, token: string) => {
303
+ const res = await fetch(`${base}/events/${encodeURIComponent(name)}?network_id=${netId}`, {
304
+ headers: { Authorization: `Bearer ${token}` },
305
+ });
306
+ if (res.status !== 200) throw new Error(`SSE subscribe failed (${res.status}) for ${name}@${netId}`);
307
+ const reader = res.body!.getReader();
308
+ await reader.read();
309
+ openReaders.push(reader);
310
+ return reader;
311
+ };
312
+
313
+ const startObserverSse = async (base: string, netId: string, token: string) => {
314
+ // The observer endpoint is /events/network/:networkId — carries no
315
+ // per-agent alias; server registers with the special observer key
316
+ // shape `\0netobs:<networkId>` (see push.ts observerKey()).
317
+ const res = await fetch(`${base}/events/network/${encodeURIComponent(netId)}`, {
318
+ headers: { Authorization: `Bearer ${token}` },
319
+ });
320
+ if (res.status !== 200) throw new Error(`observer SSE subscribe failed (${res.status}) for ${netId}`);
321
+ const reader = res.body!.getReader();
322
+ await reader.read();
323
+ openReaders.push(reader);
324
+ return reader;
325
+ };
326
+
327
+ beforeAll(async () => {
328
+ const suffix = `${Date.now()}_${Math.floor(Math.random() * 1000)}`;
329
+ const pw = "BootstrapPw123Aa!";
330
+
331
+ // userA — own network, plain member
332
+ mUserAName = `cr3_userA_${suffix}`;
333
+ const rA = register(mUserAName, pw, undefined, "seed");
334
+ if (!rA.ok || !rA.token || !rA.network_id) throw new Error("userA register failed");
335
+ mUserAToken = rA.token;
336
+ mUserANet = rA.network_id;
337
+ db.run("UPDATE users SET role = 'user' WHERE username = ?1", [mUserAName]);
338
+ mUserAId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", [mUserAName])!.user_id;
339
+
340
+ // userB — different own network, plain member
341
+ mUserBName = `cr3_userB_${suffix}`;
342
+ const rB = register(mUserBName, pw, undefined, "seed");
343
+ if (!rB.ok || !rB.token || !rB.network_id) throw new Error("userB register failed");
344
+ mUserBToken = rB.token;
345
+ mUserBNet = rB.network_id;
346
+ db.run("UPDATE users SET role = 'user' WHERE username = ?1", [mUserBName]);
347
+
348
+ // alice — own network + will also be added to userA's N_A, so
349
+ // membership set = { mAliceNet (owner), mUserANet (member) }.
350
+ // Her ntok_ (from register) is bound to mAliceNet only — the M3/M4
351
+ // scope-enforcement test target.
352
+ mAliceName = `cr3_alice_${suffix}`;
353
+ const rAl = register(mAliceName, pw, undefined, "seed");
354
+ if (!rAl.ok || !rAl.token || !rAl.network_token || !rAl.network_id) throw new Error("alice register failed");
355
+ mAliceToken = rAl.token;
356
+ mAliceNtok = rAl.network_token;
357
+ mAliceNet = rAl.network_id;
358
+ db.run("UPDATE users SET role = 'user' WHERE username = ?1", [mAliceName]);
359
+ mAliceId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", [mAliceName])!.user_id;
360
+ const addAlice = addNetworkMember(mUserANet, mAliceId, "member", mUserAId);
361
+ if (!addAlice.ok) throw new Error("addNetworkMember(alice→N_A) failed: " + addAlice.error);
362
+
363
+ // carol — own network + will also be added to N_A, then revoked
364
+ // in M5.
365
+ mCarolName = `cr3_carol_${suffix}`;
366
+ const rC = register(mCarolName, pw, undefined, "seed");
367
+ if (!rC.ok || !rC.token || !rC.network_id) throw new Error("carol register failed");
368
+ mCarolToken = rC.token;
369
+ mCarolNet = rC.network_id;
370
+ db.run("UPDATE users SET role = 'user' WHERE username = ?1", [mCarolName]);
371
+ mCarolId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", [mCarolName])!.user_id;
372
+ const addCarol = addNetworkMember(mUserANet, mCarolId, "member", mUserAId);
373
+ if (!addCarol.ok) throw new Error("addNetworkMember(carol→N_A) failed: " + addCarol.error);
374
+
375
+ // Live SSE sessions — one per network — so the filter has real
376
+ // keys to include/exclude.
377
+ await startSse(BASE, mUserAName, mUserANet, mUserAToken);
378
+ await startSse(BASE, mUserBName, mUserBNet, mUserBToken);
379
+ await startSse(BASE, mAliceName, mAliceNet, mAliceToken);
380
+ await startSse(BASE, mCarolName, mCarolNet, mCarolToken);
381
+
382
+ // M6 — observer stream for N_A (produces `\0netobs:<mUserANet>` key)
383
+ await startObserverSse(BASE, mUserANet, mUserAToken);
384
+
385
+ // M7 note: The dashboard-user SSE gate only lets a caller subscribe
386
+ // as their own username (path 3, gate 4a). Standing up two sessions
387
+ // named the same alias in two different networks would need two
388
+ // separate "helper" users per network — significantly larger setup
389
+ // than the property we're testing. M1/M2 already witness the shape
390
+ // invariant (`<netId>:<alias>` keying) that would defeat collision:
391
+ // cross-network same-alias would land at `<N_A>:<alias>` vs
392
+ // `<N_B>:<alias>`, and M2's `expect(body.sse_sessions[<N_B>:...]).toBeUndefined()`
393
+ // pins exactly the "wrong-tenant key is invisible" property. M7 is
394
+ // therefore covered structurally; skipping the physical fixture.
395
+ mSameAliasName = "";
396
+ });
397
+
398
+ afterAll(() => {
399
+ for (const r of openReaders) { try { r.cancel(); } catch {} }
400
+ });
401
+
402
+ // ── M1 + M2 — utok_ member sees own network only ────────────────
403
+ test("M1: utok_ member of N_A sees N_A sessions", async () => {
404
+ const res = await fetch(`${BASE}/health`, { headers: { Authorization: `Bearer ${mUserAToken}` } });
405
+ expect(res.status).toBe(200);
406
+ const body = await res.json() as any;
407
+ expect("sse_sessions" in body).toBe(true);
408
+ expect(body.sse_sessions[`${mUserANet}:${mUserAName}`]).toBeGreaterThanOrEqual(1);
409
+ });
410
+
411
+ test("M2: utok_ member of N_A does NOT see N_B sessions (cross-network invisibility)", async () => {
412
+ const res = await fetch(`${BASE}/health`, { headers: { Authorization: `Bearer ${mUserAToken}` } });
413
+ const body = await res.json() as any;
414
+ expect("sse_sessions" in body).toBe(true);
415
+ // The N_B session (userB's own network) must not surface here —
416
+ // this is the assertion the reviewer's "delete member filter"
417
+ // mutation must break (currently was silently green).
418
+ expect(body.sse_sessions[`${mUserBNet}:${mUserBName}`]).toBeUndefined();
419
+ });
420
+
421
+ test("M2b: utok_ member of N_B sees own but not N_A (reverse of M2)", async () => {
422
+ const res = await fetch(`${BASE}/health`, { headers: { Authorization: `Bearer ${mUserBToken}` } });
423
+ const body = await res.json() as any;
424
+ expect(body.sse_sessions[`${mUserBNet}:${mUserBName}`]).toBeGreaterThanOrEqual(1);
425
+ expect(body.sse_sessions[`${mUserANet}:${mUserAName}`]).toBeUndefined();
426
+ });
427
+
428
+ // ── M3 + M4 — ntok_ single-network enforce ──────────────────────
429
+ test("M3: alice utok_ (user-scoped) sees union — both mAliceNet and mUserANet", async () => {
430
+ const res = await fetch(`${BASE}/health`, { headers: { Authorization: `Bearer ${mAliceToken}` } });
431
+ expect(res.status).toBe(200);
432
+ const body = await res.json() as any;
433
+ expect(body.sse_sessions[`${mAliceNet}:${mAliceName}`]).toBeGreaterThanOrEqual(1);
434
+ expect(body.sse_sessions[`${mUserANet}:${mUserAName}`]).toBeGreaterThanOrEqual(1);
435
+ });
436
+
437
+ test("M4: alice ntok_ (network-scoped, bound to mAliceNet) sees ONLY mAliceNet — N_A invisible even though she is a member", async () => {
438
+ // This is the CR3 P1 fix's mutation-red target: if the /health
439
+ // filter falls through to the `db.all(...)` union for ntok_
440
+ // callers (i.e. drops the `healthAuth.networkId ? [...] : ...`
441
+ // branch), this test flips because alice would suddenly see
442
+ // mUserANet sessions through a token that only granted mAliceNet.
443
+ const res = await fetch(`${BASE}/health`, { headers: { Authorization: `Bearer ${mAliceNtok}` } });
444
+ expect(res.status).toBe(200);
445
+ const body = await res.json() as any;
446
+ expect("sse_sessions" in body).toBe(true);
447
+ // Own network — must be present.
448
+ expect(body.sse_sessions[`${mAliceNet}:${mAliceName}`]).toBeGreaterThanOrEqual(1);
449
+ // The other network she belongs to — ntok_ must NOT show it.
450
+ expect(body.sse_sessions[`${mUserANet}:${mUserAName}`]).toBeUndefined();
451
+ // Sanity: an unrelated network she's not in must also be absent.
452
+ expect(body.sse_sessions[`${mUserBNet}:${mUserBName}`]).toBeUndefined();
453
+ });
454
+
455
+ // ── M5 — membership revocation invalidates visibility ───────────
456
+ test("M5: removing carol from N_A → next /health hides N_A sessions", async () => {
457
+ // Pre-revocation: carol utok_ sees own network + N_A (userA lives
458
+ // in N_A). Filter is per-request, no cache — so a revoke followed
459
+ // by an immediate /health must reflect the new state.
460
+ const before = await (await fetch(`${BASE}/health`, {
461
+ headers: { Authorization: `Bearer ${mCarolToken}` },
462
+ })).json() as any;
463
+ expect(before.sse_sessions[`${mCarolNet}:${mCarolName}`]).toBeGreaterThanOrEqual(1);
464
+ expect(before.sse_sessions[`${mUserANet}:${mUserAName}`]).toBeGreaterThanOrEqual(1);
465
+
466
+ const rm = removeNetworkMember(mUserANet, mCarolId);
467
+ if (!rm.ok) throw new Error("revoke failed: " + rm.error);
468
+
469
+ const after = await (await fetch(`${BASE}/health`, {
470
+ headers: { Authorization: `Bearer ${mCarolToken}` },
471
+ })).json() as any;
472
+ expect(after.sse_sessions[`${mCarolNet}:${mCarolName}`]).toBeGreaterThanOrEqual(1);
473
+ // The revoked N_A must be gone. Mutation-red for any caching of
474
+ // the memberNets Set across requests.
475
+ expect(after.sse_sessions[`${mUserANet}:${mUserAName}`]).toBeUndefined();
476
+ });
477
+
478
+ // ── M6 — observer key format via shared constant ────────────────
479
+ test("M6: observer keys (\\0netobs:<net>) are classified through the shared prefix constant, member sees own network's observer entry", async () => {
480
+ const res = await fetch(`${BASE}/health`, {
481
+ headers: { Authorization: `Bearer ${mUserAToken}` },
482
+ });
483
+ const body = await res.json() as any;
484
+ // Observer key surfaces with the printable prefix (`\\0netobs:`)
485
+ // because push.ts's printableKey renders the NUL byte as `\\0`.
486
+ // The filter must classify this as belonging to `mUserANet` and
487
+ // include it in userA's view.
488
+ const observerKey = `\\0netobs:${mUserANet}`;
489
+ expect(body.sse_sessions[observerKey]).toBeGreaterThanOrEqual(1);
490
+ });
491
+
492
+ test("M6b: userB (not in N_A) does NOT see N_A's observer key — mutation-red for prefix constant drift", async () => {
493
+ const res = await fetch(`${BASE}/health`, {
494
+ headers: { Authorization: `Bearer ${mUserBToken}` },
495
+ });
496
+ const body = await res.json() as any;
497
+ const observerKey = `\\0netobs:${mUserANet}`;
498
+ // If the prefix constant drifts (e.g. server.ts checks "netobs:"
499
+ // without `\\0`), the classifier falls through to `key.split(":")[0]`
500
+ // which for a `\\0netobs:` key returns the literal `\\0netobs` string
501
+ // — that would never match memberNets, so the observer key gets
502
+ // over-filtered but silently. That is the pre-CR3 bug shape;
503
+ // this assertion pins the "not in wrong tenant's view" side.
504
+ expect(body.sse_sessions[observerKey]).toBeUndefined();
505
+ });
506
+
507
+ // M7 removed — see beforeAll note. Cross-network same-alias
508
+ // invisibility is covered structurally by M1/M2: keys are
509
+ // `<netId>:<alias>`, so a `<N_A>:foo` vs `<N_B>:foo` collision
510
+ // resolves via network prefix, which is what M2's cross-network
511
+ // absence assertion already pins.
512
+ });
513
+
514
+ // ── #506 — same alias, two networks: /health sse_sessions must not cross ──
515
+ //
516
+ // #505 restored auth-gated `sse_sessions`. Its member filter parses the
517
+ // `{networkId}:{alias}` key prefix and keeps only the caller's networks.
518
+ // That was verified at merge time by a standalone validator, but a
519
+ // validator does not re-run on future refactors — this pins it.
520
+ //
521
+ // Construction: the dashboard-user SSE gate only lets a caller subscribe
522
+ // to its own username (gate 4a), so two users can never share one key that
523
+ // way. Gate 4b is the usable path: any member may subscribe to an alias
524
+ // that exists as a session IN THEIR network. So we seed a session row with
525
+ // the SAME alias in two different networks and have each network's own
526
+ // member subscribe to it — producing `{N_A}:{alias}` and `{N_B}:{alias}`
527
+ // live at once, which is exactly the collision the filter must separate.
528
+ //
529
+ // Mutation-red (verified by hand, see the PR): replacing the member filter
530
+ // with `scopedSessions = sse.sessions` turns the two "must NOT contain the
531
+ // other network's key" assertions red.
532
+ describe("#506 cross-network isolation of /health sse_sessions (same alias, two networks)", () => {
533
+ const SHARED_ALIAS = "shared-alias-506";
534
+ let aToken = "", aNet = "", aName = "";
535
+ let bToken = "", bNet = "", bName = "";
536
+ let readerA: ReadableStreamDefaultReader<Uint8Array> | null = null;
537
+ let readerB: ReadableStreamDefaultReader<Uint8Array> | null = null;
538
+
539
+ beforeAll(async () => {
540
+ const sfx = `${Date.now()}_${Math.floor(Math.random() * 1000)}`;
541
+ const pw = "BootstrapPw123Aa!";
542
+
543
+ // Probe users are registered AFTER the suite's seed user, so neither is
544
+ // the auto-admin first user. Asserted below rather than assumed: an
545
+ // admin caller takes the unfiltered branch and would make these tests
546
+ // pass while proving nothing.
547
+ const ra = register(`h506_a_${sfx}`, pw, undefined, "seed");
548
+ const rb = register(`h506_b_${sfx}`, pw, undefined, "seed");
549
+ if (!ra.ok || !ra.token || !rb.ok || !rb.token) throw new Error("506 register failed");
550
+ aName = `h506_a_${sfx}`; aToken = ra.token; aNet = ra.network_id ?? "";
551
+ bName = `h506_b_${sfx}`; bToken = rb.token; bNet = rb.network_id ?? "";
552
+ if (!aNet || !bNet || aNet === bNet) throw new Error("506 needs two distinct networks");
553
+ db.run("UPDATE users SET role = 'user' WHERE username IN (?1, ?2)", [aName, bName]);
554
+
555
+ // Same alias name, one session row per network → gate 4b lets each
556
+ // network's own member subscribe to it.
557
+ for (const [net, tag] of [[aNet, "a"], [bNet, "b"]] as const) {
558
+ db.run(
559
+ `INSERT INTO sessions (resume_id, alias, network_id, last_seen_at, status)
560
+ VALUES (?1, ?2, ?3, datetime('now'), 'idle')`,
561
+ [`s_506_${tag}_${sfx}`, SHARED_ALIAS, net],
562
+ );
563
+ }
564
+
565
+ const sub = async (token: string, net: string) => {
566
+ const res = await fetch(
567
+ `${BASE}/events/${encodeURIComponent(SHARED_ALIAS)}?network_id=${encodeURIComponent(net)}`,
568
+ { headers: { Authorization: `Bearer ${token}` } },
569
+ );
570
+ if (res.status !== 200) throw new Error(`506 SSE subscribe failed (${net}): ${res.status}`);
571
+ const rd = res.body!.getReader();
572
+ await rd.read(); // connected frame → key registered
573
+ return rd;
574
+ };
575
+ readerA = await sub(aToken, aNet);
576
+ readerB = await sub(bToken, bNet);
577
+ });
578
+
579
+ afterAll(() => {
580
+ try { readerA?.cancel(); } catch {}
581
+ try { readerB?.cancel(); } catch {}
582
+ });
583
+
584
+ test("probe users are NOT admin (an admin caller would bypass the filter and prove nothing)", () => {
585
+ for (const n of [aName, bName]) {
586
+ const row = db.get<{ role: string }>("SELECT role FROM users WHERE username = ?1", n);
587
+ expect(row?.role).not.toBe("admin");
588
+ }
589
+ });
590
+
591
+ test("both same-alias keys are live at once (the collision actually exists)", async () => {
592
+ // Read through the ops endpoint with the admin token: if this shows only
593
+ // one of the two keys, the fixture never built the collision and the
594
+ // isolation assertions below would be vacuous.
595
+ const res = await fetch(`${BASE}/api/stats/sse`, { headers: { Authorization: `Bearer ${adminToken}` } });
596
+ expect(res.status).toBe(200);
597
+ const body = await res.json() as { sessions?: Record<string, number> };
598
+ const keys = Object.keys(body.sessions ?? {});
599
+ expect(keys).toContain(`${aNet}:${SHARED_ALIAS}`);
600
+ expect(keys).toContain(`${bNet}:${SHARED_ALIAS}`);
601
+ });
602
+
603
+ test("member of network A sees only A's copy of the shared alias", async () => {
604
+ const res = await fetch(`${BASE}/health`, { headers: { Authorization: `Bearer ${aToken}` } });
605
+ expect(res.status).toBe(200);
606
+ const body = await res.json() as { sse_sessions?: Record<string, number> };
607
+ expect(body.sse_sessions).toBeDefined();
608
+ const keys = Object.keys(body.sse_sessions!);
609
+ expect(keys).toContain(`${aNet}:${SHARED_ALIAS}`);
610
+ expect(keys).not.toContain(`${bNet}:${SHARED_ALIAS}`);
611
+ // Nothing from the other network at all, by any key shape.
612
+ expect(keys.some((k) => k.startsWith(`${bNet}:`))).toBe(false);
613
+ });
614
+
615
+ test("member of network B sees only B's copy (reverse direction)", async () => {
616
+ const res = await fetch(`${BASE}/health`, { headers: { Authorization: `Bearer ${bToken}` } });
617
+ expect(res.status).toBe(200);
618
+ const body = await res.json() as { sse_sessions?: Record<string, number> };
619
+ expect(body.sse_sessions).toBeDefined();
620
+ const keys = Object.keys(body.sse_sessions!);
621
+ expect(keys).toContain(`${bNet}:${SHARED_ALIAS}`);
622
+ expect(keys).not.toContain(`${aNet}:${SHARED_ALIAS}`);
623
+ expect(keys.some((k) => k.startsWith(`${aNet}:`))).toBe(false);
624
+ });
625
+ });
package/src/push.ts CHANGED
@@ -110,6 +110,15 @@ function clientKey(sessionName: string, networkId?: string | null): string {
110
110
  // legacy ?network_id= path, and master token is root already.
111
111
  const OBSERVER_KEY_PREFIX = "\u0000netobs:";
112
112
 
113
+ /** Printable form of `OBSERVER_KEY_PREFIX` — the shape emitted by
114
+ * `printableKey()` (below) and therefore the shape callers see via
115
+ * `getSSEStats().sessions`. Exported so downstream consumers (e.g.
116
+ * `/health` filter in server.ts) key off the same literal instead of
117
+ * each side re-writing the escape sequence — CR3 audit 4703b0e7
118
+ * found server.ts had `"netobs:"` (no `\\0`) which never matched and
119
+ * silently over-filtered observer keys. */
120
+ export const PRINTABLE_OBSERVER_KEY_PREFIX = "\\0netobs:";
121
+
113
122
  function observerKey(networkId: string): string {
114
123
  return `${OBSERVER_KEY_PREFIX}${networkId}`;
115
124
  }
package/src/server.ts CHANGED
@@ -3,7 +3,7 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/
3
3
  import { z } from "zod/v4";
4
4
  import { registerTools } from "./tools.js";
5
5
  import { db, logTaskEvent, logAudit } from "./db.js";
6
- import { createSSEStream, createNetworkObserverStream, pushEvent, pushNetworkObserverEvent, getSSEStats } from "./push.js";
6
+ import { createSSEStream, createNetworkObserverStream, pushEvent, pushNetworkObserverEvent, getSSEStats, PRINTABLE_OBSERVER_KEY_PREFIX } from "./push.js";
7
7
  import { assertNodeActive } from "./lifecycle-guard.js";
8
8
  import { validateAvatarUrl } from "./avatar-validate.js";
9
9
  import { narrowTags, parseStoredTags, validateScalarAttr } from "./node-attrs-validate.js";
@@ -1197,10 +1197,86 @@ return Bun.serve({
1197
1197
  // now lives behind auth at GET /api/stats/sse.
1198
1198
  // `version` stays: it does help fingerprinting, but ops needs it
1199
1199
  // to verify deploys land, and the tradeoff was accepted in review.
1200
+ //
1201
+ // #495-followup / f28a6c1b — auth-gated `sse_sessions` restored so
1202
+ // the dashboard's own "online" widget can compute per-agent status
1203
+ // in one round-trip. Rules:
1204
+ // - anonymous (no valid token): NO sse_sessions field → watchdog
1205
+ // contract untouched, no topology leak (mutation removing this
1206
+ // gate turns the anonymous test RED — see health-redaction.test)
1207
+ // - admin utok_ / legacy master / DEV_OPEN: full sessions map
1208
+ // (parity with /api/stats/sse ops path)
1209
+ // - regular utok_ member: sessions map FILTERED to network_ids
1210
+ // the caller is a member of — safe against the "any account
1211
+ // sees all-network topology" regression the #473 fix closed;
1212
+ // dashboard user proxies with the logged-in user's utok_ so
1213
+ // their own network's aliases surface; strangers' networks do
1214
+ // not
1200
1215
  const count = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM sessions");
1201
1216
  const sse = getSSEStats();
1202
1217
  const license = db.get<any>("SELECT type, expires_at FROM licenses LIMIT 1");
1203
- return withCors(req, Response.json({
1218
+
1219
+ // Non-fatal auth probe (no 401 — /health must stay 200 anonymous).
1220
+ const healthAuth = resolveRequestAuth(req);
1221
+ const healthIsMaster = !healthAuth && isLegacyAuthToken(req);
1222
+ const healthIsDevOpen = !healthAuth && !AUTH_TOKEN && DEV_OPEN;
1223
+ const healthIsAdmin = !!(healthAuth?.username && db.get<any>(
1224
+ "SELECT role FROM users WHERE username = ?1", healthAuth.username,
1225
+ )?.role === "admin");
1226
+
1227
+ let scopedSessions: Record<string, number> | undefined;
1228
+ if (healthIsMaster || healthIsDevOpen || healthIsAdmin) {
1229
+ // Ops parity with /api/stats/sse — full map.
1230
+ scopedSessions = sse.sessions;
1231
+ } else if (healthAuth?.userId) {
1232
+ // Regular authenticated member — filter to networks they belong
1233
+ // to. Keys are `{networkId}:{alias}` (or observer keys shaped
1234
+ // `\0netobs:{networkId}`); parse the network prefix and keep
1235
+ // only entries whose network is in the member's set.
1236
+ //
1237
+ // 🔴 ntok_ (network-scoped token) MUST be forced to its single
1238
+ // bound network (parity with resolveRestNetworkScope L277:
1239
+ // `if (authCtx.networkId) return { networkId: authCtx.networkId }`).
1240
+ // Falling through to the utok_ union query would return
1241
+ // sessions from every network the underlying user belongs to,
1242
+ // silently escalating a network-scoped token to a user-scoped
1243
+ // token — the exact "认证 ≠ 已授权" mistake this whole change
1244
+ // is meant to prevent. Same shape as f28a6c1b root cause; see
1245
+ // CR3 audit 4703b0e7.
1246
+ const memberNets = new Set<string>(
1247
+ healthAuth.networkId
1248
+ ? [healthAuth.networkId] // ntok_ single-network enforce
1249
+ : db.all<{ network_id: string }>(
1250
+ "SELECT network_id FROM network_members WHERE user_id = ?1",
1251
+ healthAuth.userId,
1252
+ ).map((r) => r.network_id),
1253
+ );
1254
+ // If member has no networks at all, keep field present but empty
1255
+ // so the dashboard can distinguish "authenticated + none active"
1256
+ // from "not authenticated at all".
1257
+ const filtered: Record<string, number> = {};
1258
+ for (const [key, n] of Object.entries(sse.sessions)) {
1259
+ // Observer keys are shape `\0netobs:<networkId>` raw; keyed
1260
+ // through `printableKey()` in push.ts, they surface here as
1261
+ // the literal string `\\0netobs:<networkId>` (backslash-zero,
1262
+ // not NUL byte). Use the shared `PRINTABLE_OBSERVER_KEY_PREFIX`
1263
+ // export so this side and the emitter side stay locked to the
1264
+ // same literal — CR3 audit 4703b0e7 caught prior check
1265
+ // `startsWith("netobs:")` was always false → observer keys
1266
+ // silently over-filtered out of member views.
1267
+ const isObserver = key.startsWith(PRINTABLE_OBSERVER_KEY_PREFIX);
1268
+ const netId = isObserver
1269
+ ? key.slice(PRINTABLE_OBSERVER_KEY_PREFIX.length)
1270
+ : key.split(":")[0];
1271
+ if (memberNets.has(netId)) filtered[key] = n;
1272
+ }
1273
+ scopedSessions = filtered;
1274
+ }
1275
+ // healthAuth == null AND no legacy master AND no DEV_OPEN → leave
1276
+ // scopedSessions undefined → sse_sessions field omitted entirely
1277
+ // (watchdog + arbitrary internet strangers see aggregates only).
1278
+
1279
+ const body: Record<string, unknown> = {
1204
1280
  ok: true,
1205
1281
  version: SERVER_VERSION,
1206
1282
  api_version: "v3",
@@ -1214,7 +1290,9 @@ return Bun.serve({
1214
1290
  multi_network: true,
1215
1291
  license: license?.type || "none",
1216
1292
  uptime: Math.floor(process.uptime()),
1217
- }));
1293
+ };
1294
+ if (scopedSessions !== undefined) body.sse_sessions = scopedSessions;
1295
+ return withCors(req, Response.json(body));
1218
1296
  }
1219
1297
 
1220
1298
  // ── All REST /api endpoints require auth (if token configured) ──