@sleep2agi/commhub-server 0.9.0-preview.13 → 0.9.0-preview.130
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/README.md +130 -10
- package/bin/commhub.ts +99 -11
- package/package.json +7 -7
- package/src/ack-create-request-transport.test.ts +182 -0
- package/src/ack-create-request.test.ts +50 -7
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/adoption-candidates-http.test.ts +137 -0
- package/src/adoption-candidates.ts +158 -0
- package/src/agent-access-batch.test.ts +210 -0
- package/src/agent-access.ts +487 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +659 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +223 -0
- package/src/agent-notice.ts +48 -0
- package/src/agent-team-whoami-http.test.ts +98 -0
- package/src/agent-teams-http.test.ts +323 -0
- package/src/agent-teams.ts +244 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +42 -0
- package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
- package/src/api-host-supervisors-fallback.test.ts +271 -0
- package/src/api-host-supervisors-runtime-readiness.test.ts +195 -0
- package/src/api-nodes-shape.test.ts +26 -10
- package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth-weak-password-login.test.ts +176 -0
- package/src/auth.ts +598 -49
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/binding-generation-http.test.ts +121 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/clone-node.test.ts +248 -0
- package/src/clone-node.ts +289 -0
- package/src/codex-config-flags.ts +77 -0
- package/src/codex-fork-contract.ts +23 -0
- package/src/codex-fork-migration.test.ts +38 -0
- package/src/commhub-bin-args.test.ts +85 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +134 -1
- package/src/config-apply-validate.ts +111 -5
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-network-id.test.ts +79 -0
- package/src/create-node-tool-schema.test.ts +48 -0
- package/src/create-node-unicode-name.test.ts +120 -0
- package/src/create-node-validate.test.ts +193 -7
- package/src/create-node-validate.ts +227 -15
- package/src/create-node-workdir.test.ts +289 -0
- package/src/create-node.ts +77 -23
- package/src/daemon-provider-snapshots.test.ts +421 -0
- package/src/daemon-provider-snapshots.ts +327 -0
- package/src/daemon-token-owner-http.test.ts +236 -0
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +148 -0
- package/src/db-adapter-translate.test.ts +128 -0
- package/src/db-adapter.ts +323 -87
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +930 -35
- package/src/delivered-without-push.test.ts +140 -0
- package/src/department-groups-http.test.ts +209 -0
- package/src/department-groups-sync-http.test.ts +328 -0
- package/src/department-groups.ts +434 -0
- package/src/department-heads-http.test.ts +330 -0
- package/src/department-heads.ts +226 -0
- package/src/departments-http.test.ts +185 -0
- package/src/departments.ts +260 -0
- package/src/desktop-message-seam.test.ts +150 -0
- package/src/desktop-message.test.ts +368 -0
- package/src/dm-file-scope-http.test.ts +178 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +321 -0
- package/src/external-schedule-edits-timestamps.test.ts +64 -0
- package/src/external-schedule-edits.ts +327 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +404 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +732 -0
- package/src/group-membership-events-http.test.ts +264 -0
- package/src/group-messages-http.test.ts +482 -0
- package/src/group-messages.ts +297 -0
- package/src/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +94 -0
- package/src/http-gzip.ts +112 -0
- package/src/hub-provider-config.test.ts +139 -0
- package/src/hub-provider-config.ts +105 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +215 -0
- package/src/inbox-caller.ts +49 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2362
- package/src/lifecycle-guard.ts +1 -1
- package/src/mcp-context-cost-http.test.ts +123 -0
- package/src/mcp-projects-events-http.test.ts +162 -0
- package/src/mcp-route-label-http.test.ts +120 -0
- package/src/mcp-write-network-resolution.test.ts +432 -0
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +210 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -0
- package/src/migrate-sqlite-to-pg.test.ts +47 -0
- package/src/migrate-sqlite-to-pg.ts +263 -0
- package/src/model-auth-notify-http.test.ts +181 -0
- package/src/model-auth-notify.ts +116 -0
- package/src/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +276 -0
- package/src/network-token-membership-http.test.ts +139 -0
- package/src/node-alias-control.ts +12 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-caller-identity-http.test.ts +781 -0
- package/src/node-create-request-status-http.test.ts +105 -0
- package/src/node-daemon-bindings.test.ts +132 -0
- package/src/node-daemon-bindings.ts +130 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-health-codex-login.test.ts +120 -0
- package/src/node-health-dispatch-http.test.ts +279 -0
- package/src/node-health-guard.ts +105 -0
- package/src/node-health-passthrough.test.ts +115 -0
- package/src/node-health-store.ts +101 -0
- package/src/node-id-alias.test.ts +190 -0
- package/src/node-identity-conflict-http.test.ts +269 -0
- package/src/node-identity-conflict.ts +142 -0
- package/src/node-identity-mismatch.test.ts +74 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +53 -0
- package/src/node-lifecycle-read-http.test.ts +245 -0
- package/src/node-lifecycle-read.ts +121 -0
- package/src/node-logs-transport.test.ts +263 -0
- package/src/node-permissions-http.test.ts +276 -0
- package/src/node-permissions.ts +285 -0
- package/src/node-request-retention.test.ts +281 -0
- package/src/node-request-retention.ts +120 -0
- package/src/node-skills-transport.test.ts +209 -0
- package/src/node-token-ownership.ts +51 -0
- package/src/node-token-privilege-http.test.ts +158 -0
- package/src/observer-avatar-http.test.ts +431 -0
- package/src/observer-push.test.ts +252 -0
- package/src/opencode-v2-catalog.test.ts +268 -0
- package/src/opencode-v2-catalog.ts +352 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/pg-worker.ts +87 -0
- package/src/project-dir-warn-clear-http.test.ts +132 -0
- package/src/project-viewer-can-http.test.ts +114 -0
- package/src/push.ts +429 -10
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +22 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-handover-capabilities-http.test.ts +124 -0
- package/src/report-status-host-schema.test.ts +235 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-due-reminders-http.test.ts +620 -0
- package/src/requirement-due-reminders.ts +416 -0
- package/src/requirement-events-http.test.ts +268 -0
- package/src/requirement-events.test.ts +45 -0
- package/src/requirement-events.ts +144 -0
- package/src/requirement-notify.ts +165 -0
- package/src/requirement-participant-notify-http.test.ts +309 -0
- package/src/requirement-tag-ops-http.test.ts +266 -0
- package/src/requirement-tags.ts +89 -0
- package/src/requirements-abandoned-http.test.ts +236 -0
- package/src/requirements-comment-http.test.ts +167 -0
- package/src/requirements-completed-at-migration.test.ts +70 -0
- package/src/requirements-errors-mcp-http.test.ts +164 -0
- package/src/requirements-errors.ts +143 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +709 -0
- package/src/requirements-last-event-http.test.ts +349 -0
- package/src/requirements-list-mcp-slim-http.test.ts +155 -0
- package/src/requirements-list-slim-http.test.ts +319 -0
- package/src/requirements-mcp.test.ts +163 -0
- package/src/requirements-migrate.ts +259 -0
- package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
- package/src/requirements-people-http.test.ts +114 -0
- package/src/requirements-people-mcp-http.test.ts +225 -0
- package/src/requirements-people.ts +127 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-search-http.test.ts +182 -0
- package/src/requirements-search.test.ts +32 -0
- package/src/requirements-search.ts +145 -0
- package/src/requirements-seq-http.test.ts +193 -0
- package/src/requirements-seq-migration.test.ts +63 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-stats-http.test.ts +288 -0
- package/src/requirements-stats.ts +167 -0
- package/src/requirements-status-participants-http.test.ts +222 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +98 -0
- package/src/requirements.ts +1806 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +222 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +125 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +76 -0
- package/src/retention.ts +15 -3
- package/src/route-callers-http.test.ts +120 -0
- package/src/route-timing.test.ts +211 -0
- package/src/route-timing.ts +258 -0
- package/src/rules-file-duplicate-alias.test.ts +234 -0
- package/src/rules-file-session-target.test.ts +207 -0
- package/src/rules-file-transport.test.ts +237 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/runtime-readiness.ts +90 -0
- package/src/schedule-agent-mcp-http.test.ts +381 -0
- package/src/schedule-agent.ts +192 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
- package/src/scheduled-failures-http.test.ts +233 -0
- package/src/scheduled-failures.ts +179 -0
- package/src/scheduled-offline.test.ts +131 -0
- package/src/scheduled-offline.ts +66 -0
- package/src/scheduled-reply-unread-http.test.ts +212 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-stuck-http.test.ts +238 -0
- package/src/scheduled-stuck.ts +153 -0
- package/src/scheduled-tasks-http.test.ts +638 -0
- package/src/scheduled-tasks.ts +766 -0
- package/src/scoped-secrets.ts +124 -0
- package/src/send-dedup-request-id.test.ts +229 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +458 -0
- package/src/send_dedup.test.ts +54 -0
- package/src/send_dedup.ts +52 -6
- package/src/serve-error.test.ts +38 -0
- package/src/serve-error.ts +27 -0
- package/src/server.ts +5257 -0
- package/src/session-task-on-dispatch.ts +14 -0
- package/src/session-token-ownership-http.test.ts +122 -0
- package/src/shared/codex-inventory.ts +41 -0
- package/src/shared/daemon-provider-login.ts +31 -0
- package/src/shared/daemon-provider-snapshot.ts +52 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/node-name.test.ts +62 -0
- package/src/shared/node-name.ts +161 -0
- package/src/shared/provider-application.ts +29 -0
- package/src/shared/provider-collection.ts +14 -0
- package/src/shared/provider-initialization.ts +8 -0
- package/src/shared/reserved-env-drift.test.ts +10 -0
- package/src/shared/scoped-secret-contract.ts +5 -0
- package/src/shared/task-content-limit-drift.test.ts +28 -0
- package/src/shared/task-content-limit.ts +25 -0
- package/src/side-thread-command-transport.test.ts +312 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +233 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2499 -0
- package/src/skillhub-http.test.ts +108 -0
- package/src/sqlite-close-checkpoint.test.ts +78 -0
- package/src/start-node.test.ts +264 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/status-alias-resolver-http.test.ts +163 -0
- package/src/status-alias-resolver.ts +19 -0
- package/src/status-node-id-filter-http.test.ts +161 -0
- package/src/status-read-cache-http.test.ts +229 -0
- package/src/status-read-cache.ts +122 -0
- package/src/stop-delete-node.test.ts +509 -1
- package/src/task-access-http.test.ts +402 -0
- package/src/task-access.ts +420 -0
- package/src/task-auth-origin-http.test.ts +91 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +482 -0
- package/src/task-diagnostic-http.test.ts +149 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-expiry-notice-http.test.ts +287 -0
- package/src/task-expiry-notice.ts +315 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-late-replies.ts +54 -0
- package/src/task-late-reply-http.test.ts +152 -0
- package/src/task-lifecycle-watcher.test.ts +204 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +141 -0
- package/src/task-orphan-http.test.ts +287 -0
- package/src/task-orphan.ts +119 -0
- package/src/task-queue-ahead-http.test.ts +309 -0
- package/src/task-queue-ahead-scale.test.ts +236 -0
- package/src/task-queue-ahead.ts +186 -0
- package/src/task-rejection-log-http.test.ts +160 -0
- package/src/task-rejection-log.ts +49 -0
- package/src/task-stale-open-http.test.ts +290 -0
- package/src/task-stale-open.ts +103 -0
- package/src/tasks-pagination-http.test.ts +141 -0
- package/src/tool-audience-http.test.ts +220 -0
- package/src/tool-audience.ts +118 -0
- package/src/tools-list-json-schema.test.ts +67 -0
- package/src/tools.ts +3927 -323
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.ts +103 -6
- package/src/user-avatar-http.test.ts +74 -0
- package/src/user-avatar-migration.test.ts +25 -0
- package/src/user-inbox-by-agent-http.test.ts +161 -0
- package/src/user-inbox-netid-notnull.test.ts +122 -0
- package/src/user-inbox-read-http.test.ts +223 -0
- package/src/user-inbox-schema.test.ts +54 -0
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
// #473 — /health information-leak redaction.
|
|
2
|
+
//
|
|
3
|
+
// Public-hub audit (通信龙, 2026-07-30) showed anonymous GET /health
|
|
4
|
+
// returning the full SSE key breakdown: `{networkId}:{alias}` for every
|
|
5
|
+
// live connection — network id + all 95 agent aliases on the audited
|
|
6
|
+
// hub. The fix keeps /health anonymous (HARD CONSTRAINT: the central
|
|
7
|
+
// watchdog curls it every minute; auth here would blind it) but strips
|
|
8
|
+
// the per-key detail down to aggregate counts; the detail moved behind
|
|
9
|
+
// auth at GET /api/stats/sse (master token or admin only).
|
|
10
|
+
//
|
|
11
|
+
// Witnessed-red: on pre-fix main (8c11edf5) the "no leak" test below is
|
|
12
|
+
// red — the /health body contains the connected alias and network id.
|
|
13
|
+
|
|
14
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
15
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
16
|
+
import { join } from "path";
|
|
17
|
+
import { tmpdir } from "os";
|
|
18
|
+
import { register, addNetworkMember, removeNetworkMember } from "./auth.js";
|
|
19
|
+
import { db } from "./db.js";
|
|
20
|
+
import { MAX_REQUEST_CONTENT_LENGTH, MAX_UPLOAD_BYTES } from "./uploads.js";
|
|
21
|
+
|
|
22
|
+
const SERVER_DB = mkdtempSync(join(tmpdir(), "anet-health-red-db-")) + "/commhub.db";
|
|
23
|
+
|
|
24
|
+
let BASE = "";
|
|
25
|
+
let server: any = null;
|
|
26
|
+
let sseReader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
|
27
|
+
|
|
28
|
+
let userToken = "";
|
|
29
|
+
let userNetworkId = "";
|
|
30
|
+
let userName = "";
|
|
31
|
+
let adminToken = "";
|
|
32
|
+
|
|
33
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
34
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
35
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
36
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
37
|
+
beforeAll(async () => {
|
|
38
|
+
process.env.COMMHUB_DB = process.env.COMMHUB_DB || SERVER_DB;
|
|
39
|
+
process.env.HOST = "127.0.0.1";
|
|
40
|
+
|
|
41
|
+
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
|
42
|
+
const pw = "BootstrapPw123Aa!";
|
|
43
|
+
|
|
44
|
+
// Burn a seed registration first so our member is NOT auto-admin (the
|
|
45
|
+
// "first registered user → admin" rule only bites when this suite owns
|
|
46
|
+
// the DB; per-file runs need this guard). We set roles explicitly below.
|
|
47
|
+
register(`health_seed_${suffix}`, pw, undefined, "seed");
|
|
48
|
+
|
|
49
|
+
userName = `health_user_${suffix}`;
|
|
50
|
+
const r = register(userName, pw, undefined, "seed");
|
|
51
|
+
if (!r.ok || !r.token) throw new Error("register failed: " + JSON.stringify(r));
|
|
52
|
+
userToken = r.token;
|
|
53
|
+
userNetworkId = r.network_id ?? "";
|
|
54
|
+
if (!userNetworkId) throw new Error("no network for user");
|
|
55
|
+
// Pin to plain member — defend against any residual auto-admin.
|
|
56
|
+
db.run("UPDATE users SET role = 'user' WHERE username = ?1", [userName]);
|
|
57
|
+
|
|
58
|
+
const adminName = `health_admin_${suffix}`;
|
|
59
|
+
const a = register(adminName, pw, undefined, "seed");
|
|
60
|
+
if (!a.ok || !a.token) throw new Error("admin register failed: " + JSON.stringify(a));
|
|
61
|
+
adminToken = a.token;
|
|
62
|
+
// Explicit promotion — "first registered user is auto-admin" does not
|
|
63
|
+
// hold in aggregate runs (see api-host-supervisors-fallback.test.ts).
|
|
64
|
+
db.run("UPDATE users SET role = 'admin' WHERE username = ?1", [adminName]);
|
|
65
|
+
|
|
66
|
+
const { bootServer } = await import("./server.js");
|
|
67
|
+
server = bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
68
|
+
BASE = `http://127.0.0.1:${server.port}`;
|
|
69
|
+
|
|
70
|
+
// Open a real SSE connection so the stats have a key that WOULD leak:
|
|
71
|
+
// the dashboard-user channel (sessionName === own username, Path 3
|
|
72
|
+
// gate 4a) registers `${networkId}:${username}` in the clients map.
|
|
73
|
+
const res = await fetch(`${BASE}/events/${encodeURIComponent(userName)}?network_id=${userNetworkId}`, {
|
|
74
|
+
headers: { Authorization: `Bearer ${userToken}` },
|
|
75
|
+
});
|
|
76
|
+
if (res.status !== 200) throw new Error("SSE subscribe failed: " + res.status);
|
|
77
|
+
sseReader = res.body!.getReader();
|
|
78
|
+
await sseReader.read(); // consume the connected frame → registration done
|
|
79
|
+
}, 30_000);
|
|
80
|
+
|
|
81
|
+
afterAll(() => {
|
|
82
|
+
try { sseReader?.cancel(); } catch {}
|
|
83
|
+
try { server?.stop?.(true); } catch {}
|
|
84
|
+
try { rmSync(SERVER_DB, { recursive: true, force: true }); } catch {}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("#473 anonymous /health — watchdog contract intact, no topology leak", () => {
|
|
88
|
+
test("stays anonymous 200 with parseable aggregates (watchdog judge: curl -sf gets 2xx)", async () => {
|
|
89
|
+
const res = await fetch(`${BASE}/health`);
|
|
90
|
+
expect(res.status).toBe(200);
|
|
91
|
+
const body = await res.json() as any;
|
|
92
|
+
expect(body.ok).toBe(true);
|
|
93
|
+
expect(typeof body.version).toBe("string");
|
|
94
|
+
expect(typeof body.sessions_count).toBe("number");
|
|
95
|
+
expect(typeof body.sse_connections).toBe("number");
|
|
96
|
+
expect(body.sse_connections).toBeGreaterThanOrEqual(1);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("exposes stable upload limits anonymously without exposing topology", async () => {
|
|
100
|
+
const res = await fetch(`${BASE}/health`);
|
|
101
|
+
expect(res.status).toBe(200);
|
|
102
|
+
const body = await res.json() as any;
|
|
103
|
+
expect(body.limits).toEqual({
|
|
104
|
+
max_upload_bytes: MAX_UPLOAD_BYTES,
|
|
105
|
+
max_request_content_length: MAX_REQUEST_CONTENT_LENGTH,
|
|
106
|
+
});
|
|
107
|
+
expect(body.limits.max_request_content_length).toBeGreaterThan(body.limits.max_upload_bytes);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("CLI count source stays truthful: health.sse_connections == /api/stats/sse total (no fake 0)", async () => {
|
|
111
|
+
// The CLI's `anet doctor` line reads health.sse_connections now
|
|
112
|
+
// (was Object.keys(sse_sessions).length). This pins that aggregate to
|
|
113
|
+
// the same number the auth-gated detail endpoint reports — so a live
|
|
114
|
+
// hub with N connections shows N, never the "0 active" regression the
|
|
115
|
+
// review caught. There is exactly one live SSE connection in setup.
|
|
116
|
+
const health = await (await fetch(`${BASE}/health`)).json() as any;
|
|
117
|
+
const detail = await (await fetch(`${BASE}/api/stats/sse`, { headers: { Authorization: `Bearer ${adminToken}` } })).json() as any;
|
|
118
|
+
expect(health.sse_connections).toBe(detail.total);
|
|
119
|
+
expect(health.sse_connections).toBeGreaterThanOrEqual(1);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("does NOT expose SSE key detail: no sse_sessions field, no alias, no network id", async () => {
|
|
123
|
+
const res = await fetch(`${BASE}/health`);
|
|
124
|
+
const text = await res.text();
|
|
125
|
+
const body = JSON.parse(text);
|
|
126
|
+
expect("sse_sessions" in body).toBe(false);
|
|
127
|
+
// The live connection's identifying strings must not appear anywhere
|
|
128
|
+
// in the anonymous body.
|
|
129
|
+
expect(text).not.toContain(userName);
|
|
130
|
+
expect(text).not.toContain(userNetworkId);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("f28a6c1b /health sse_sessions — auth-gated restoration for dashboard 'online' widget", () => {
|
|
135
|
+
// The dashboard's server-side Next.js proxy (agent-network-dashboard
|
|
136
|
+
// `app/api/hub/health/route.ts` → `hubFetch('/health')`) forwards the
|
|
137
|
+
// logged-in user's V3 utok_ as `Authorization: Bearer …`. Anonymous
|
|
138
|
+
// callers (public watchdog, internet strangers) still get NO
|
|
139
|
+
// sse_sessions — that leak stays closed. Authenticated callers get
|
|
140
|
+
// the sessions map scoped to their networks (ops sees all).
|
|
141
|
+
|
|
142
|
+
test("regular member with valid utok_: sse_sessions present, filtered to member's networks", async () => {
|
|
143
|
+
const res = await fetch(`${BASE}/health`, {
|
|
144
|
+
headers: { Authorization: `Bearer ${userToken}` },
|
|
145
|
+
});
|
|
146
|
+
expect(res.status).toBe(200);
|
|
147
|
+
const body = await res.json() as any;
|
|
148
|
+
// Field must be present when authenticated — otherwise dashboard
|
|
149
|
+
// "online" widget shows 0.
|
|
150
|
+
expect("sse_sessions" in body).toBe(true);
|
|
151
|
+
// Must contain the member's own SSE connection key.
|
|
152
|
+
const ownKey = `${userNetworkId}:${userName}`;
|
|
153
|
+
expect(body.sse_sessions[ownKey]).toBeGreaterThanOrEqual(1);
|
|
154
|
+
// Watchdog aggregate contract unchanged.
|
|
155
|
+
expect(body.sse_connections).toBeGreaterThanOrEqual(1);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("admin with valid utok_: sse_sessions present, full unfiltered map (ops parity with /api/stats/sse)", async () => {
|
|
159
|
+
const res = await fetch(`${BASE}/health`, {
|
|
160
|
+
headers: { Authorization: `Bearer ${adminToken}` },
|
|
161
|
+
});
|
|
162
|
+
expect(res.status).toBe(200);
|
|
163
|
+
const body = await res.json() as any;
|
|
164
|
+
expect("sse_sessions" in body).toBe(true);
|
|
165
|
+
// Admin sees the member's key even though they live in a different
|
|
166
|
+
// network (admin scope spans everything, same as /api/stats/sse).
|
|
167
|
+
const memberKey = `${userNetworkId}:${userName}`;
|
|
168
|
+
expect(body.sse_sessions[memberKey]).toBeGreaterThanOrEqual(1);
|
|
169
|
+
// Sanity: admin's view must match the auth-gated ops endpoint.
|
|
170
|
+
const opsRes = await fetch(`${BASE}/api/stats/sse`, {
|
|
171
|
+
headers: { Authorization: `Bearer ${adminToken}` },
|
|
172
|
+
});
|
|
173
|
+
const ops = await opsRes.json() as any;
|
|
174
|
+
expect(body.sse_sessions).toEqual(ops.sessions);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("anonymous still gets NO sse_sessions (leak stays closed, mutation-red witness)", async () => {
|
|
178
|
+
// This is the mutation-red assertion for the auth gate: if the
|
|
179
|
+
// handler stops distinguishing anonymous from authenticated and
|
|
180
|
+
// starts including sse_sessions unconditionally, this test turns
|
|
181
|
+
// RED. Distinct from the older `does NOT expose SSE key detail`
|
|
182
|
+
// test — that one guards against any regression restoring the
|
|
183
|
+
// OLD unconditional leak; this one guards against a well-meaning
|
|
184
|
+
// "just always send it if authenticated is optional" bug.
|
|
185
|
+
const res = await fetch(`${BASE}/health`);
|
|
186
|
+
const body = await res.json() as any;
|
|
187
|
+
expect("sse_sessions" in body).toBe(false);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("invalid/garbage token: treated as anonymous — no sse_sessions", async () => {
|
|
191
|
+
const res = await fetch(`${BASE}/health`, {
|
|
192
|
+
headers: { Authorization: "Bearer utok_completely_fake_nonexistent" },
|
|
193
|
+
});
|
|
194
|
+
expect(res.status).toBe(200);
|
|
195
|
+
const body = await res.json() as any;
|
|
196
|
+
// resolveRequestAuth returns null for unresolvable tokens, so the
|
|
197
|
+
// handler must NOT fall through to "authenticated" branch. Otherwise
|
|
198
|
+
// a bad token would degrade to "anonymous with sse_sessions" — that
|
|
199
|
+
// would be the leak in a different disguise.
|
|
200
|
+
expect("sse_sessions" in body).toBe(false);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe("#473 GET /api/stats/sse — detail survives, behind ops auth", () => {
|
|
205
|
+
test("anonymous → 401", async () => {
|
|
206
|
+
const res = await fetch(`${BASE}/api/stats/sse`);
|
|
207
|
+
expect(res.status).toBe(401);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("regular member token → 403 (detail spans every network)", async () => {
|
|
211
|
+
const res = await fetch(`${BASE}/api/stats/sse`, { headers: { Authorization: `Bearer ${userToken}` } });
|
|
212
|
+
expect(res.status).toBe(403);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("admin → 200 with the full key breakdown (ops capability preserved)", async () => {
|
|
216
|
+
const res = await fetch(`${BASE}/api/stats/sse`, { headers: { Authorization: `Bearer ${adminToken}` } });
|
|
217
|
+
expect(res.status).toBe(200);
|
|
218
|
+
const body = await res.json() as any;
|
|
219
|
+
expect(body.ok).toBe(true);
|
|
220
|
+
expect(typeof body.total).toBe("number");
|
|
221
|
+
expect(body.sessions[`${userNetworkId}:${userName}`]).toBeGreaterThanOrEqual(1);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("legacy master token → 200 (ops path — restAuth null, isLegacyAuthToken)", () => {
|
|
225
|
+
// COMMHUB_AUTH_TOKEN is captured at module import, so this path needs
|
|
226
|
+
// a fresh process with the env set before boot (aggregate order can't
|
|
227
|
+
// guarantee this file imports server.ts first). Subprocess, like the
|
|
228
|
+
// patrol test. Reviewer flagged this path had code but no test.
|
|
229
|
+
const script = `
|
|
230
|
+
process.env.COMMHUB_AUTH_TOKEN = "master-secret-xyz";
|
|
231
|
+
const { startHub } = await import("./src/server.js");
|
|
232
|
+
const hub = startHub({ port: 0, hostname: "127.0.0.1" });
|
|
233
|
+
const base = "http://127.0.0.1:" + hub.port;
|
|
234
|
+
const anon = await fetch(base + "/api/stats/sse");
|
|
235
|
+
const master = await fetch(base + "/api/stats/sse", { headers: { Authorization: "Bearer master-secret-xyz" } });
|
|
236
|
+
const health = await fetch(base + "/health");
|
|
237
|
+
const hb = await health.json();
|
|
238
|
+
console.log("RESULT:" + JSON.stringify({
|
|
239
|
+
anon: anon.status,
|
|
240
|
+
master: master.status,
|
|
241
|
+
masterOk: (await master.json()).ok,
|
|
242
|
+
healthHasSseSessions: "sse_sessions" in hb,
|
|
243
|
+
}));
|
|
244
|
+
hub.stop(true);
|
|
245
|
+
process.exit(0);
|
|
246
|
+
`;
|
|
247
|
+
const child = Bun.spawnSync({
|
|
248
|
+
cmd: ["bun", "-e", script],
|
|
249
|
+
cwd: `${import.meta.dir}/..`,
|
|
250
|
+
env: { ...process.env, COMMHUB_DB: process.env.COMMHUB_DB || "/tmp/anet-473-master.db" },
|
|
251
|
+
timeout: 15_000,
|
|
252
|
+
});
|
|
253
|
+
const out = new TextDecoder().decode(child.stdout);
|
|
254
|
+
const m = out.match(/RESULT:(\{.*\})/);
|
|
255
|
+
expect(m).not.toBeNull();
|
|
256
|
+
const r = JSON.parse(m![1]);
|
|
257
|
+
expect(r.anon).toBe(401); // token configured → anonymous rejected
|
|
258
|
+
expect(r.master).toBe(200); // legacy master token → ops access
|
|
259
|
+
expect(r.masterOk).toBe(true);
|
|
260
|
+
expect(r.healthHasSseSessions).toBe(false); // /health still redacted with a token set
|
|
261
|
+
}, 20_000);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
265
|
+
// CR3 (4703b0e7) — M1-M7 matrix tests
|
|
266
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
267
|
+
//
|
|
268
|
+
// Independent audit on aef48a3d showed the existing f28a6c1b tests
|
|
269
|
+
// pinned only the "anonymous vs authenticated" gate. The reviewer
|
|
270
|
+
// mutated `scopedSessions = sse.sessions` (unconditional) and got
|
|
271
|
+
// 11/11 green — meaning the *tenant-scope* invariant (a member sees
|
|
272
|
+
// only their own networks, cross-network is invisible) was not
|
|
273
|
+
// witnessed by any assertion, and a future refactor that broke the
|
|
274
|
+
// filter would ship silently.
|
|
275
|
+
//
|
|
276
|
+
// This block adds explicit tests for every shape the fork's auth
|
|
277
|
+
// decision distinguishes:
|
|
278
|
+
// M1: utok_ member sees own network's sessions
|
|
279
|
+
// M2: utok_ member does NOT see other network's sessions
|
|
280
|
+
// M3: ntok_ (network-scoped token) is forced to the token's bound
|
|
281
|
+
// network — the user's other networks are invisible even
|
|
282
|
+
// though the underlying user is a member
|
|
283
|
+
// M4: same user, utok_ vs ntok_ — utok_ sees union, ntok_ sees only
|
|
284
|
+
// the token's bound network (mutation-red for the ntok_
|
|
285
|
+
// "healthAuth.networkId ? [...] : db.all(...)" branch)
|
|
286
|
+
// M5: membership revocation invalidates visibility immediately —
|
|
287
|
+
// no cache; the next /health request reflects new membership
|
|
288
|
+
// M6: observer keys (shape `\0netobs:<networkId>`; the getSSEStats
|
|
289
|
+
// serializer renders leading NUL as printable `\\0`) are
|
|
290
|
+
// classified through the shared PRINTABLE_OBSERVER_KEY_PREFIX
|
|
291
|
+
// constant, not a re-typed literal. Mutation-red for the
|
|
292
|
+
// constant drift (server.ts had `"netobs:"` without `\\0`
|
|
293
|
+
// before this CR, so all observer keys silently fell through).
|
|
294
|
+
// M7: two networks with the same alias — invisibility survives
|
|
295
|
+
// key collision. Explicit assertion; M1/M2 hide-cover this
|
|
296
|
+
// implicitly but M7 makes the property visible in review.
|
|
297
|
+
describe("f28a6c1b CR3 — network-scoped filter matrix (M1-M7)", () => {
|
|
298
|
+
let mUserAToken = "";
|
|
299
|
+
let mUserANet = "";
|
|
300
|
+
let mUserAName = "";
|
|
301
|
+
let mUserBToken = "";
|
|
302
|
+
let mUserBNet = "";
|
|
303
|
+
let mUserBName = "";
|
|
304
|
+
let mAliceToken = ""; // utok_ (user-scoped)
|
|
305
|
+
let mAliceNtok = ""; // ntok_ bound to mAliceNet
|
|
306
|
+
let mAliceNet = "";
|
|
307
|
+
let mAliceName = "";
|
|
308
|
+
let mCarolToken = "";
|
|
309
|
+
let mCarolNet = "";
|
|
310
|
+
let mCarolName = "";
|
|
311
|
+
let mUserAId = "";
|
|
312
|
+
let mAliceId = "";
|
|
313
|
+
let mCarolId = "";
|
|
314
|
+
let mSameAliasName = ""; // used by M7 — two sessions, one per network
|
|
315
|
+
|
|
316
|
+
const openReaders: ReadableStreamDefaultReader<Uint8Array>[] = [];
|
|
317
|
+
|
|
318
|
+
const startSse = async (base: string, name: string, netId: string, token: string) => {
|
|
319
|
+
const res = await fetch(`${base}/events/${encodeURIComponent(name)}?network_id=${netId}`, {
|
|
320
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
321
|
+
});
|
|
322
|
+
if (res.status !== 200) throw new Error(`SSE subscribe failed (${res.status}) for ${name}@${netId}`);
|
|
323
|
+
const reader = res.body!.getReader();
|
|
324
|
+
await reader.read();
|
|
325
|
+
openReaders.push(reader);
|
|
326
|
+
return reader;
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const startObserverSse = async (base: string, netId: string, token: string) => {
|
|
330
|
+
// The observer endpoint is /events/network/:networkId — carries no
|
|
331
|
+
// per-agent alias; server registers with the special observer key
|
|
332
|
+
// shape `\0netobs:<networkId>` (see push.ts observerKey()).
|
|
333
|
+
const res = await fetch(`${base}/events/network/${encodeURIComponent(netId)}`, {
|
|
334
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
335
|
+
});
|
|
336
|
+
if (res.status !== 200) throw new Error(`observer SSE subscribe failed (${res.status}) for ${netId}`);
|
|
337
|
+
const reader = res.body!.getReader();
|
|
338
|
+
await reader.read();
|
|
339
|
+
openReaders.push(reader);
|
|
340
|
+
return reader;
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
beforeAll(async () => {
|
|
344
|
+
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
|
345
|
+
const pw = "BootstrapPw123Aa!";
|
|
346
|
+
|
|
347
|
+
// userA — own network, plain member
|
|
348
|
+
mUserAName = `cr3_userA_${suffix}`;
|
|
349
|
+
const rA = register(mUserAName, pw, undefined, "seed");
|
|
350
|
+
if (!rA.ok || !rA.token || !rA.network_id) throw new Error("userA register failed");
|
|
351
|
+
mUserAToken = rA.token;
|
|
352
|
+
mUserANet = rA.network_id;
|
|
353
|
+
db.run("UPDATE users SET role = 'user' WHERE username = ?1", [mUserAName]);
|
|
354
|
+
mUserAId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", [mUserAName])!.user_id;
|
|
355
|
+
|
|
356
|
+
// userB — different own network, plain member
|
|
357
|
+
mUserBName = `cr3_userB_${suffix}`;
|
|
358
|
+
const rB = register(mUserBName, pw, undefined, "seed");
|
|
359
|
+
if (!rB.ok || !rB.token || !rB.network_id) throw new Error("userB register failed");
|
|
360
|
+
mUserBToken = rB.token;
|
|
361
|
+
mUserBNet = rB.network_id;
|
|
362
|
+
db.run("UPDATE users SET role = 'user' WHERE username = ?1", [mUserBName]);
|
|
363
|
+
|
|
364
|
+
// alice — own network + will also be added to userA's N_A, so
|
|
365
|
+
// membership set = { mAliceNet (owner), mUserANet (member) }.
|
|
366
|
+
// Her ntok_ (from register) is bound to mAliceNet only — the M3/M4
|
|
367
|
+
// scope-enforcement test target.
|
|
368
|
+
mAliceName = `cr3_alice_${suffix}`;
|
|
369
|
+
const rAl = register(mAliceName, pw, undefined, "seed");
|
|
370
|
+
if (!rAl.ok || !rAl.token || !rAl.network_token || !rAl.network_id) throw new Error("alice register failed");
|
|
371
|
+
mAliceToken = rAl.token;
|
|
372
|
+
mAliceNtok = rAl.network_token;
|
|
373
|
+
mAliceNet = rAl.network_id;
|
|
374
|
+
db.run("UPDATE users SET role = 'user' WHERE username = ?1", [mAliceName]);
|
|
375
|
+
mAliceId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", [mAliceName])!.user_id;
|
|
376
|
+
// 多用户 Agent 权限:新成员默认只看授权 Agent;本测试测的是「完全信任的成员」旧语义,显式 agent_access=all。
|
|
377
|
+
const addAlice = addNetworkMember(mUserANet, mAliceId, "member", mUserAId, { agentAccess: "all" });
|
|
378
|
+
if (!addAlice.ok) throw new Error("addNetworkMember(alice→N_A) failed: " + addAlice.error);
|
|
379
|
+
|
|
380
|
+
// carol — own network + will also be added to N_A, then revoked
|
|
381
|
+
// in M5.
|
|
382
|
+
mCarolName = `cr3_carol_${suffix}`;
|
|
383
|
+
const rC = register(mCarolName, pw, undefined, "seed");
|
|
384
|
+
if (!rC.ok || !rC.token || !rC.network_id) throw new Error("carol register failed");
|
|
385
|
+
mCarolToken = rC.token;
|
|
386
|
+
mCarolNet = rC.network_id;
|
|
387
|
+
db.run("UPDATE users SET role = 'user' WHERE username = ?1", [mCarolName]);
|
|
388
|
+
mCarolId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", [mCarolName])!.user_id;
|
|
389
|
+
const addCarol = addNetworkMember(mUserANet, mCarolId, "member", mUserAId, { agentAccess: "all" });
|
|
390
|
+
if (!addCarol.ok) throw new Error("addNetworkMember(carol→N_A) failed: " + addCarol.error);
|
|
391
|
+
|
|
392
|
+
// Live SSE sessions — one per network — so the filter has real
|
|
393
|
+
// keys to include/exclude.
|
|
394
|
+
await startSse(BASE, mUserAName, mUserANet, mUserAToken);
|
|
395
|
+
await startSse(BASE, mUserBName, mUserBNet, mUserBToken);
|
|
396
|
+
await startSse(BASE, mAliceName, mAliceNet, mAliceToken);
|
|
397
|
+
await startSse(BASE, mCarolName, mCarolNet, mCarolToken);
|
|
398
|
+
|
|
399
|
+
// M6 — observer stream for N_A (produces `\0netobs:<mUserANet>` key)
|
|
400
|
+
await startObserverSse(BASE, mUserANet, mUserAToken);
|
|
401
|
+
|
|
402
|
+
// M7 note: The dashboard-user SSE gate only lets a caller subscribe
|
|
403
|
+
// as their own username (path 3, gate 4a). Standing up two sessions
|
|
404
|
+
// named the same alias in two different networks would need two
|
|
405
|
+
// separate "helper" users per network — significantly larger setup
|
|
406
|
+
// than the property we're testing. M1/M2 already witness the shape
|
|
407
|
+
// invariant (`<netId>:<alias>` keying) that would defeat collision:
|
|
408
|
+
// cross-network same-alias would land at `<N_A>:<alias>` vs
|
|
409
|
+
// `<N_B>:<alias>`, and M2's `expect(body.sse_sessions[<N_B>:...]).toBeUndefined()`
|
|
410
|
+
// pins exactly the "wrong-tenant key is invisible" property. M7 is
|
|
411
|
+
// therefore covered structurally; skipping the physical fixture.
|
|
412
|
+
mSameAliasName = "";
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
afterAll(() => {
|
|
416
|
+
for (const r of openReaders) { try { r.cancel(); } catch {} }
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// ── M1 + M2 — utok_ member sees own network only ────────────────
|
|
420
|
+
test("M1: utok_ member of N_A sees N_A sessions", async () => {
|
|
421
|
+
const res = await fetch(`${BASE}/health`, { headers: { Authorization: `Bearer ${mUserAToken}` } });
|
|
422
|
+
expect(res.status).toBe(200);
|
|
423
|
+
const body = await res.json() as any;
|
|
424
|
+
expect("sse_sessions" in body).toBe(true);
|
|
425
|
+
expect(body.sse_sessions[`${mUserANet}:${mUserAName}`]).toBeGreaterThanOrEqual(1);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test("M2: utok_ member of N_A does NOT see N_B sessions (cross-network invisibility)", async () => {
|
|
429
|
+
const res = await fetch(`${BASE}/health`, { headers: { Authorization: `Bearer ${mUserAToken}` } });
|
|
430
|
+
const body = await res.json() as any;
|
|
431
|
+
expect("sse_sessions" in body).toBe(true);
|
|
432
|
+
// The N_B session (userB's own network) must not surface here —
|
|
433
|
+
// this is the assertion the reviewer's "delete member filter"
|
|
434
|
+
// mutation must break (currently was silently green).
|
|
435
|
+
expect(body.sse_sessions[`${mUserBNet}:${mUserBName}`]).toBeUndefined();
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("M2b: utok_ member of N_B sees own but not N_A (reverse of M2)", async () => {
|
|
439
|
+
const res = await fetch(`${BASE}/health`, { headers: { Authorization: `Bearer ${mUserBToken}` } });
|
|
440
|
+
const body = await res.json() as any;
|
|
441
|
+
expect(body.sse_sessions[`${mUserBNet}:${mUserBName}`]).toBeGreaterThanOrEqual(1);
|
|
442
|
+
expect(body.sse_sessions[`${mUserANet}:${mUserAName}`]).toBeUndefined();
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// ── M3 + M4 — ntok_ single-network enforce ──────────────────────
|
|
446
|
+
test("M3: alice utok_ (user-scoped) sees union — both mAliceNet and mUserANet", async () => {
|
|
447
|
+
const res = await fetch(`${BASE}/health`, { headers: { Authorization: `Bearer ${mAliceToken}` } });
|
|
448
|
+
expect(res.status).toBe(200);
|
|
449
|
+
const body = await res.json() as any;
|
|
450
|
+
expect(body.sse_sessions[`${mAliceNet}:${mAliceName}`]).toBeGreaterThanOrEqual(1);
|
|
451
|
+
expect(body.sse_sessions[`${mUserANet}:${mUserAName}`]).toBeGreaterThanOrEqual(1);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("M4: alice ntok_ (network-scoped, bound to mAliceNet) sees ONLY mAliceNet — N_A invisible even though she is a member", async () => {
|
|
455
|
+
// This is the CR3 P1 fix's mutation-red target: if the /health
|
|
456
|
+
// filter falls through to the `db.all(...)` union for ntok_
|
|
457
|
+
// callers (i.e. drops the `healthAuth.networkId ? [...] : ...`
|
|
458
|
+
// branch), this test flips because alice would suddenly see
|
|
459
|
+
// mUserANet sessions through a token that only granted mAliceNet.
|
|
460
|
+
const res = await fetch(`${BASE}/health`, { headers: { Authorization: `Bearer ${mAliceNtok}` } });
|
|
461
|
+
expect(res.status).toBe(200);
|
|
462
|
+
const body = await res.json() as any;
|
|
463
|
+
expect("sse_sessions" in body).toBe(true);
|
|
464
|
+
// Own network — must be present.
|
|
465
|
+
expect(body.sse_sessions[`${mAliceNet}:${mAliceName}`]).toBeGreaterThanOrEqual(1);
|
|
466
|
+
// The other network she belongs to — ntok_ must NOT show it.
|
|
467
|
+
expect(body.sse_sessions[`${mUserANet}:${mUserAName}`]).toBeUndefined();
|
|
468
|
+
// Sanity: an unrelated network she's not in must also be absent.
|
|
469
|
+
expect(body.sse_sessions[`${mUserBNet}:${mUserBName}`]).toBeUndefined();
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
// ── M5 — membership revocation invalidates visibility ───────────
|
|
473
|
+
test("M5: removing carol from N_A → next /health hides N_A sessions", async () => {
|
|
474
|
+
// Pre-revocation: carol utok_ sees own network + N_A (userA lives
|
|
475
|
+
// in N_A). Filter is per-request, no cache — so a revoke followed
|
|
476
|
+
// by an immediate /health must reflect the new state.
|
|
477
|
+
const before = await (await fetch(`${BASE}/health`, {
|
|
478
|
+
headers: { Authorization: `Bearer ${mCarolToken}` },
|
|
479
|
+
})).json() as any;
|
|
480
|
+
expect(before.sse_sessions[`${mCarolNet}:${mCarolName}`]).toBeGreaterThanOrEqual(1);
|
|
481
|
+
expect(before.sse_sessions[`${mUserANet}:${mUserAName}`]).toBeGreaterThanOrEqual(1);
|
|
482
|
+
|
|
483
|
+
const rm = removeNetworkMember(mUserANet, mCarolId);
|
|
484
|
+
if (!rm.ok) throw new Error("revoke failed: " + rm.error);
|
|
485
|
+
|
|
486
|
+
const after = await (await fetch(`${BASE}/health`, {
|
|
487
|
+
headers: { Authorization: `Bearer ${mCarolToken}` },
|
|
488
|
+
})).json() as any;
|
|
489
|
+
expect(after.sse_sessions[`${mCarolNet}:${mCarolName}`]).toBeGreaterThanOrEqual(1);
|
|
490
|
+
// The revoked N_A must be gone. Mutation-red for any caching of
|
|
491
|
+
// the memberNets Set across requests.
|
|
492
|
+
expect(after.sse_sessions[`${mUserANet}:${mUserAName}`]).toBeUndefined();
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
// ── M6 — observer key format via shared constant ────────────────
|
|
496
|
+
test("M6: observer keys (\\0netobs:<net>) are classified through the shared prefix constant, member sees own network's observer entry", async () => {
|
|
497
|
+
const res = await fetch(`${BASE}/health`, {
|
|
498
|
+
headers: { Authorization: `Bearer ${mUserAToken}` },
|
|
499
|
+
});
|
|
500
|
+
const body = await res.json() as any;
|
|
501
|
+
// Observer key surfaces with the printable prefix (`\\0netobs:`)
|
|
502
|
+
// because push.ts's printableKey renders the NUL byte as `\\0`.
|
|
503
|
+
// The filter must classify this as belonging to `mUserANet` and
|
|
504
|
+
// include it in userA's view.
|
|
505
|
+
const observerKey = `\\0netobs:${mUserANet}`;
|
|
506
|
+
expect(body.sse_sessions[observerKey]).toBeGreaterThanOrEqual(1);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test("M6b: userB (not in N_A) does NOT see N_A's observer key — mutation-red for prefix constant drift", async () => {
|
|
510
|
+
const res = await fetch(`${BASE}/health`, {
|
|
511
|
+
headers: { Authorization: `Bearer ${mUserBToken}` },
|
|
512
|
+
});
|
|
513
|
+
const body = await res.json() as any;
|
|
514
|
+
const observerKey = `\\0netobs:${mUserANet}`;
|
|
515
|
+
// If the prefix constant drifts (e.g. server.ts checks "netobs:"
|
|
516
|
+
// without `\\0`), the classifier falls through to `key.split(":")[0]`
|
|
517
|
+
// which for a `\\0netobs:` key returns the literal `\\0netobs` string
|
|
518
|
+
// — that would never match memberNets, so the observer key gets
|
|
519
|
+
// over-filtered but silently. That is the pre-CR3 bug shape;
|
|
520
|
+
// this assertion pins the "not in wrong tenant's view" side.
|
|
521
|
+
expect(body.sse_sessions[observerKey]).toBeUndefined();
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
// M7 removed — see beforeAll note. Cross-network same-alias
|
|
525
|
+
// invisibility is covered structurally by M1/M2: keys are
|
|
526
|
+
// `<netId>:<alias>`, so a `<N_A>:foo` vs `<N_B>:foo` collision
|
|
527
|
+
// resolves via network prefix, which is what M2's cross-network
|
|
528
|
+
// absence assertion already pins.
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
// ── #506 — same alias, two networks: /health sse_sessions must not cross ──
|
|
532
|
+
//
|
|
533
|
+
// #505 restored auth-gated `sse_sessions`. Its member filter parses the
|
|
534
|
+
// `{networkId}:{alias}` key prefix and keeps only the caller's networks.
|
|
535
|
+
// That was verified at merge time by a standalone validator, but a
|
|
536
|
+
// validator does not re-run on future refactors — this pins it.
|
|
537
|
+
//
|
|
538
|
+
// Construction: the dashboard-user SSE gate only lets a caller subscribe
|
|
539
|
+
// to its own username (gate 4a), so two users can never share one key that
|
|
540
|
+
// way. Gate 4b is the usable path: any member may subscribe to an alias
|
|
541
|
+
// that exists as a session IN THEIR network. So we seed a session row with
|
|
542
|
+
// the SAME alias in two different networks and have each network's own
|
|
543
|
+
// member subscribe to it — producing `{N_A}:{alias}` and `{N_B}:{alias}`
|
|
544
|
+
// live at once, which is exactly the collision the filter must separate.
|
|
545
|
+
//
|
|
546
|
+
// Mutation-red (verified by hand, see the PR): replacing the member filter
|
|
547
|
+
// with `scopedSessions = sse.sessions` turns the two "must NOT contain the
|
|
548
|
+
// other network's key" assertions red.
|
|
549
|
+
describe("#506 cross-network isolation of /health sse_sessions (same alias, two networks)", () => {
|
|
550
|
+
const SHARED_ALIAS = "shared-alias-506";
|
|
551
|
+
let aToken = "", aNet = "", aName = "";
|
|
552
|
+
let bToken = "", bNet = "", bName = "";
|
|
553
|
+
let readerA: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
|
554
|
+
let readerB: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
|
555
|
+
|
|
556
|
+
beforeAll(async () => {
|
|
557
|
+
const sfx = `${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
|
558
|
+
const pw = "BootstrapPw123Aa!";
|
|
559
|
+
|
|
560
|
+
// Probe users are registered AFTER the suite's seed user, so neither is
|
|
561
|
+
// the auto-admin first user. Asserted below rather than assumed: an
|
|
562
|
+
// admin caller takes the unfiltered branch and would make these tests
|
|
563
|
+
// pass while proving nothing.
|
|
564
|
+
const ra = register(`h506_a_${sfx}`, pw, undefined, "seed");
|
|
565
|
+
const rb = register(`h506_b_${sfx}`, pw, undefined, "seed");
|
|
566
|
+
if (!ra.ok || !ra.token || !rb.ok || !rb.token) throw new Error("506 register failed");
|
|
567
|
+
aName = `h506_a_${sfx}`; aToken = ra.token; aNet = ra.network_id ?? "";
|
|
568
|
+
bName = `h506_b_${sfx}`; bToken = rb.token; bNet = rb.network_id ?? "";
|
|
569
|
+
if (!aNet || !bNet || aNet === bNet) throw new Error("506 needs two distinct networks");
|
|
570
|
+
db.run("UPDATE users SET role = 'user' WHERE username IN (?1, ?2)", [aName, bName]);
|
|
571
|
+
|
|
572
|
+
// Same alias name, one session row per network → gate 4b lets each
|
|
573
|
+
// network's own member subscribe to it.
|
|
574
|
+
for (const [net, tag] of [[aNet, "a"], [bNet, "b"]] as const) {
|
|
575
|
+
db.run(
|
|
576
|
+
`INSERT INTO sessions (resume_id, alias, network_id, last_seen_at, status)
|
|
577
|
+
VALUES (?1, ?2, ?3, datetime('now'), 'idle')`,
|
|
578
|
+
[`s_506_${tag}_${sfx}`, SHARED_ALIAS, net],
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const sub = async (token: string, net: string) => {
|
|
583
|
+
const res = await fetch(
|
|
584
|
+
`${BASE}/events/${encodeURIComponent(SHARED_ALIAS)}?network_id=${encodeURIComponent(net)}`,
|
|
585
|
+
{ headers: { Authorization: `Bearer ${token}` } },
|
|
586
|
+
);
|
|
587
|
+
if (res.status !== 200) throw new Error(`506 SSE subscribe failed (${net}): ${res.status}`);
|
|
588
|
+
const rd = res.body!.getReader();
|
|
589
|
+
await rd.read(); // connected frame → key registered
|
|
590
|
+
return rd;
|
|
591
|
+
};
|
|
592
|
+
readerA = await sub(aToken, aNet);
|
|
593
|
+
readerB = await sub(bToken, bNet);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
afterAll(() => {
|
|
597
|
+
try { readerA?.cancel(); } catch {}
|
|
598
|
+
try { readerB?.cancel(); } catch {}
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
test("probe users are NOT admin (an admin caller would bypass the filter and prove nothing)", () => {
|
|
602
|
+
for (const n of [aName, bName]) {
|
|
603
|
+
const row = db.get<{ role: string }>("SELECT role FROM users WHERE username = ?1", n);
|
|
604
|
+
expect(row?.role).not.toBe("admin");
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
test("both same-alias keys are live at once (the collision actually exists)", async () => {
|
|
609
|
+
// Read through the ops endpoint with the admin token: if this shows only
|
|
610
|
+
// one of the two keys, the fixture never built the collision and the
|
|
611
|
+
// isolation assertions below would be vacuous.
|
|
612
|
+
const res = await fetch(`${BASE}/api/stats/sse`, { headers: { Authorization: `Bearer ${adminToken}` } });
|
|
613
|
+
expect(res.status).toBe(200);
|
|
614
|
+
const body = await res.json() as { sessions?: Record<string, number> };
|
|
615
|
+
const keys = Object.keys(body.sessions ?? {});
|
|
616
|
+
expect(keys).toContain(`${aNet}:${SHARED_ALIAS}`);
|
|
617
|
+
expect(keys).toContain(`${bNet}:${SHARED_ALIAS}`);
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
test("member of network A sees only A's copy of the shared alias", async () => {
|
|
621
|
+
const res = await fetch(`${BASE}/health`, { headers: { Authorization: `Bearer ${aToken}` } });
|
|
622
|
+
expect(res.status).toBe(200);
|
|
623
|
+
const body = await res.json() as { sse_sessions?: Record<string, number> };
|
|
624
|
+
expect(body.sse_sessions).toBeDefined();
|
|
625
|
+
const keys = Object.keys(body.sse_sessions!);
|
|
626
|
+
expect(keys).toContain(`${aNet}:${SHARED_ALIAS}`);
|
|
627
|
+
expect(keys).not.toContain(`${bNet}:${SHARED_ALIAS}`);
|
|
628
|
+
// Nothing from the other network at all, by any key shape.
|
|
629
|
+
expect(keys.some((k) => k.startsWith(`${bNet}:`))).toBe(false);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
test("member of network B sees only B's copy (reverse direction)", async () => {
|
|
633
|
+
const res = await fetch(`${BASE}/health`, { headers: { Authorization: `Bearer ${bToken}` } });
|
|
634
|
+
expect(res.status).toBe(200);
|
|
635
|
+
const body = await res.json() as { sse_sessions?: Record<string, number> };
|
|
636
|
+
expect(body.sse_sessions).toBeDefined();
|
|
637
|
+
const keys = Object.keys(body.sse_sessions!);
|
|
638
|
+
expect(keys).toContain(`${bNet}:${SHARED_ALIAS}`);
|
|
639
|
+
expect(keys).not.toContain(`${aNet}:${SHARED_ALIAS}`);
|
|
640
|
+
expect(keys.some((k) => k.startsWith(`${aNet}:`))).toBe(false);
|
|
641
|
+
});
|
|
642
|
+
});
|