@sleep2agi/commhub-server 0.9.0-preview.8 → 0.9.0-preview.80
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 +92 -11
- package/package.json +5 -5
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +5 -5
- package/src/admin-delete-network-http.test.ts +228 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/agent-access-batch.test.ts +210 -0
- package/src/agent-access.ts +478 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +520 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +190 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -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-nodes-shape.test.ts +26 -10
- package/src/auth-sessions-http.test.ts +250 -0
- package/src/auth-validate.test.ts +5 -1
- package/src/auth.ts +450 -31
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/blocked-exit-on-reply.test.ts +122 -0
- package/src/boot-side-effect.test.ts +124 -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 +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node-blocked-gate.test.ts +356 -0
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +89 -1
- package/src/create-node-validate.ts +89 -7
- package/src/create-node-workdir.test.ts +255 -0
- package/src/create-node.ts +46 -1
- 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 +805 -33
- package/src/delivered-without-push.test.ts +140 -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/health-redaction.test.ts +642 -0
- package/src/http-gzip.test.ts +94 -0
- package/src/http-gzip.ts +110 -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-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2331
- package/src/lifecycle-guard.ts +50 -0
- package/src/mcp-route-label-http.test.ts +120 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/member-audit-network-id-http.test.ts +93 -0
- package/src/member-presence-http.test.ts +206 -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/network-name-http.test.ts +75 -0
- package/src/network-scope.ts +224 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +217 -0
- package/src/node-create-request-status-http.test.ts +92 -0
- package/src/node-files-transport.test.ts +344 -0
- package/src/node-id-alias.test.ts +188 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/node-logs-transport.test.ts +263 -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-privilege-http.test.ts +149 -0
- package/src/observer-avatar-http.test.ts +429 -0
- package/src/observer-push.test.ts +252 -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/push.ts +284 -7
- package/src/redact-tokens.test.ts +65 -0
- package/src/redact-tokens.ts +40 -0
- package/src/rename.ts +7 -2
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/report-status-host-schema.test.ts +211 -0
- package/src/report-status-resume-id-handover.test.ts +150 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/requirement-events-http.test.ts +257 -0
- package/src/requirement-events.test.ts +45 -0
- package/src/requirement-events.ts +143 -0
- package/src/requirement-notify.ts +134 -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-completed-at-migration.test.ts +70 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +709 -0
- package/src/requirements-list-slim-http.test.ts +315 -0
- package/src/requirements-mcp.test.ts +163 -0
- package/src/requirements-migrate.ts +225 -0
- package/src/requirements-people-http.test.ts +114 -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 +138 -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 +164 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +98 -0
- package/src/requirements.ts +1439 -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 +216 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +68 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/restricted-files.ts +73 -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/scheduled-doorbell-nodeless-session.test.ts +130 -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-tasks-http.test.ts +638 -0
- package/src/scheduled-tasks.ts +597 -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 +4522 -0
- package/src/shared/external-schedule-contract.ts +60 -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 +109 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/status-node-id-filter-http.test.ts +161 -0
- package/src/stop-delete-node.test.ts +1164 -0
- package/src/task-access-http.test.ts +379 -0
- package/src/task-access.ts +307 -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-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -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/tasks-pagination-http.test.ts +141 -0
- package/src/tools-list-json-schema.test.ts +66 -0
- package/src/tools.ts +3534 -343
- package/src/update-provider.test.ts +65 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +97 -15
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +138 -5
- 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,50 @@
|
|
|
1
|
+
// RFC-027 §2.3 race-free invariant — shared helper for the inbox-
|
|
2
|
+
// enqueue lifecycle guard.
|
|
3
|
+
//
|
|
4
|
+
// PR1.1 (#346) added an `assertNodeActive` helper inside the
|
|
5
|
+
// registerTools() closure and applied it at the 6 INSERT INTO inbox
|
|
6
|
+
// sites in server/src/tools.ts. PR1.1 review (通信龙 #346 ack) caught
|
|
7
|
+
// that the closure scope made the helper unreachable from REST
|
|
8
|
+
// handlers in server/src/index.ts — POST /api/task (:1631) and
|
|
9
|
+
// POST /api/broadcast (:1706) are dashboard's "Dispatch" entry
|
|
10
|
+
// points and bypassed the guard, leaving the §2.3 race open on the
|
|
11
|
+
// REST path.
|
|
12
|
+
//
|
|
13
|
+
// PR1.2a (#346 follow-up) extracts the helper to this module so both
|
|
14
|
+
// the MCP tools and the REST handlers can import the same code path,
|
|
15
|
+
// per team rule (grep every write site before adding a guard): any SQL-level
|
|
16
|
+
// guard's helper lives at module scope so every write site
|
|
17
|
+
// (MCP + REST + internal db.ts helpers) can use it.
|
|
18
|
+
//
|
|
19
|
+
// Behaviour:
|
|
20
|
+
// - no nodes row for the alias → ok:true (brand-new alias allowed;
|
|
21
|
+
// could be the first INSERT for a child about to register)
|
|
22
|
+
// - lifecycle_state = 'active' OR NULL (pre-RFC-027 row) → ok:true
|
|
23
|
+
// - anything else (stopping / stopped / stop_failed / deleting) →
|
|
24
|
+
// ok:false + structured `node_not_active` reply
|
|
25
|
+
//
|
|
26
|
+
// COALESCE on the WHERE keeps NULL-network inbox rows / legacy nodes
|
|
27
|
+
// rows correctly scoped (PR1 SF-5 lineage).
|
|
28
|
+
|
|
29
|
+
import { db } from "./db.js";
|
|
30
|
+
|
|
31
|
+
export type LifecycleGuardResult =
|
|
32
|
+
| { ok: true }
|
|
33
|
+
| { ok: false; error: "node_not_active"; lifecycle_state: string; alias: string };
|
|
34
|
+
|
|
35
|
+
export function assertNodeActive(
|
|
36
|
+
sessionAlias: string,
|
|
37
|
+
networkId: string | null,
|
|
38
|
+
): LifecycleGuardResult {
|
|
39
|
+
if (!sessionAlias) return { ok: true };
|
|
40
|
+
const row = db.get<{ lifecycle_state: string | null }>(
|
|
41
|
+
networkId
|
|
42
|
+
? `SELECT lifecycle_state FROM nodes WHERE alias = ?1 AND COALESCE(network_id, ?2) = ?2 LIMIT 1`
|
|
43
|
+
: `SELECT lifecycle_state FROM nodes WHERE alias = ?1 LIMIT 1`,
|
|
44
|
+
...(networkId ? [sessionAlias, networkId] : [sessionAlias]),
|
|
45
|
+
);
|
|
46
|
+
if (!row) return { ok: true };
|
|
47
|
+
const st = row.lifecycle_state ?? "active";
|
|
48
|
+
if (st === "active") return { ok: true };
|
|
49
|
+
return { ok: false, error: "node_not_active", lifecycle_state: st, alias: sessionAlias };
|
|
50
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// POST /mcp in /api/stats/routes carries the JSON-RPC method and, for tools/call, the tool name
|
|
2
|
+
// ("POST /mcp tools/call report_status"). Production showed POST /mcp as the biggest route (15 req/s,
|
|
3
|
+
// ~13% of a core) with no way to tell which tool. This suite pins, over real HTTP:
|
|
4
|
+
// - the labels (tools/call <name>, tools/list, batch) and that MCP responses are unchanged in kind;
|
|
5
|
+
// - arguments never reach the stats (a marker in arguments / a hostile tool name never shows up);
|
|
6
|
+
// - odd bodies still get the transport's own answers (invalid JSON → -32700, oversized → parsed by
|
|
7
|
+
// the transport itself) and are labelled "?";
|
|
8
|
+
// - unauthenticated /mcp stays plain "POST /mcp" (the body is never read before auth).
|
|
9
|
+
|
|
10
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
11
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-mcp-route-label-"));
|
|
16
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
17
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
18
|
+
|
|
19
|
+
const PW = "McpRouteLabelPassw0rd!";
|
|
20
|
+
const MARKER = "zz-argument-marker-7f3a";
|
|
21
|
+
let BASE = "";
|
|
22
|
+
let hub: any = null;
|
|
23
|
+
let admin = "";
|
|
24
|
+
|
|
25
|
+
const MCP_HEADERS = { "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" };
|
|
26
|
+
async function mcpRaw(token: string, body: string): Promise<{ status: number; text: string }> {
|
|
27
|
+
const res = await fetch(`${BASE}/mcp`, { method: "POST", headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...MCP_HEADERS }, body });
|
|
28
|
+
return { status: res.status, text: await res.text() };
|
|
29
|
+
}
|
|
30
|
+
function rpc(text: string): any {
|
|
31
|
+
const lines = text.split("\n").filter(x => x.startsWith("data:"));
|
|
32
|
+
return lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(text);
|
|
33
|
+
}
|
|
34
|
+
async function routes(): Promise<{ text: string; keys: string[]; rows: any[] }> {
|
|
35
|
+
const res = await fetch(`${BASE}/api/stats/routes?minutes=5`, { headers: { Authorization: `Bearer ${admin}` } });
|
|
36
|
+
const text = await res.text();
|
|
37
|
+
const rows = JSON.parse(text).routes as any[];
|
|
38
|
+
return { text, keys: rows.map(r => r.route), rows };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
beforeAll(async () => {
|
|
42
|
+
process.env.HOST = "127.0.0.1";
|
|
43
|
+
const { register } = await import("./auth.js");
|
|
44
|
+
const { db } = await import("./db.js");
|
|
45
|
+
const a = register(`mrl_admin_${Date.now()}`, PW);
|
|
46
|
+
admin = a.token!;
|
|
47
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [a.user!.user_id]);
|
|
48
|
+
const mod: any = await import("./server.js");
|
|
49
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
50
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
51
|
+
}, 30_000);
|
|
52
|
+
|
|
53
|
+
afterAll(() => {
|
|
54
|
+
try { hub?.stop?.(true); } catch {}
|
|
55
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("POST /mcp route label", () => {
|
|
59
|
+
test("tools/call is labelled with the tool name and still answers normally", async () => {
|
|
60
|
+
const r = await mcpRaw(admin, JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "get_all_status", arguments: { note: MARKER } } }));
|
|
61
|
+
expect(r.status).toBe(200);
|
|
62
|
+
expect(rpc(r.text).result).toBeDefined();
|
|
63
|
+
const { keys } = await routes();
|
|
64
|
+
expect(keys).toContain("POST /mcp tools/call get_all_status");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("tools/list and a batch get their own labels", async () => {
|
|
68
|
+
const list = await mcpRaw(admin, JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list" }));
|
|
69
|
+
expect(list.status).toBe(200);
|
|
70
|
+
expect(rpc(list.text).result.tools.length).toBeGreaterThan(10);
|
|
71
|
+
await mcpRaw(admin, JSON.stringify([{ jsonrpc: "2.0", id: 3, method: "tools/list" }]));
|
|
72
|
+
const { keys } = await routes();
|
|
73
|
+
expect(keys).toContain("POST /mcp tools/list");
|
|
74
|
+
expect(keys).toContain("POST /mcp batch");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("arguments and hostile tool names never reach the stats", async () => {
|
|
78
|
+
await mcpRaw(admin, JSON.stringify({ jsonrpc: "2.0", id: 4, method: "tools/call", params: { name: `x ${MARKER}`, arguments: { a: MARKER } } }));
|
|
79
|
+
await mcpRaw(admin, JSON.stringify({ jsonrpc: "2.0", id: 5, method: "tools/call", params: { name: "n".repeat(500), arguments: {} } }));
|
|
80
|
+
await mcpRaw(admin, JSON.stringify({ jsonrpc: "2.0", id: 6, method: `tools/${MARKER} with spaces` }));
|
|
81
|
+
const { text, keys } = await routes();
|
|
82
|
+
expect(text).not.toContain(MARKER);
|
|
83
|
+
expect(text).not.toContain("n".repeat(65));
|
|
84
|
+
expect(keys).toContain("POST /mcp tools/call ?");
|
|
85
|
+
expect(keys).toContain("POST /mcp ?");
|
|
86
|
+
for (const k of keys) expect(k.length).toBeLessThan(200);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("invalid JSON still gets the transport's parse error, labelled ?", async () => {
|
|
90
|
+
const r = await mcpRaw(admin, `{"jsonrpc":"2.0","method":"tools/list",`);
|
|
91
|
+
expect(r.status).toBe(400);
|
|
92
|
+
expect(rpc(r.text).error.code).toBe(-32700);
|
|
93
|
+
expect(rpc(r.text).error.message).toBe("Parse error: Invalid JSON");
|
|
94
|
+
const notRpc = await mcpRaw(admin, "null");
|
|
95
|
+
expect(notRpc.status).toBe(400);
|
|
96
|
+
expect(rpc(notRpc.text).error.code).toBe(-32700);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("a body over the label limit is left to the transport and still works, labelled ?", async () => {
|
|
100
|
+
const { __resetRouteTimingForTest } = await import("./route-timing.js");
|
|
101
|
+
__resetRouteTimingForTest();
|
|
102
|
+
const padded = JSON.stringify({ jsonrpc: "2.0", id: 7, method: "tools/list" }).replace("{", "{" + " ".repeat(1024 * 1024 + 10));
|
|
103
|
+
const r = await mcpRaw(admin, padded);
|
|
104
|
+
expect(r.status).toBe(200);
|
|
105
|
+
expect(rpc(r.text).result.tools.length).toBeGreaterThan(10);
|
|
106
|
+
const { keys } = await routes();
|
|
107
|
+
expect(keys).toContain("POST /mcp ?");
|
|
108
|
+
expect(keys).not.toContain("POST /mcp tools/list");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("unauthenticated /mcp is refused as before and not labelled", async () => {
|
|
112
|
+
const { __resetRouteTimingForTest } = await import("./route-timing.js");
|
|
113
|
+
__resetRouteTimingForTest();
|
|
114
|
+
const r = await mcpRaw("", JSON.stringify({ jsonrpc: "2.0", id: 8, method: "tools/list" }));
|
|
115
|
+
expect(r.status).toBe(401);
|
|
116
|
+
const { keys } = await routes();
|
|
117
|
+
expect(keys).toContain("POST /mcp");
|
|
118
|
+
expect(keys.filter(k => k.startsWith("POST /mcp "))).toEqual([]);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
// #517 — MCP write-path network resolution (utok single-network fallback).
|
|
2
|
+
//
|
|
3
|
+
// Root cause (issue #517): utok_ token rows are created with network_id=null
|
|
4
|
+
// BY DESIGN (auth.ts login), and the MCP write path (tools.ts canWrite) had
|
|
5
|
+
// no single-network fallback — unlike REST POST /api/task (server.ts), which
|
|
6
|
+
// auto-resolves via singleNetworkId(restScope) when the user belongs to
|
|
7
|
+
// exactly one network. Result: a utok node could READ everything
|
|
8
|
+
// (resolveReadScope falls back to all memberships) but could not WRITE
|
|
9
|
+
// anything, and the error mislabeled the failure as permission_denied.
|
|
10
|
+
//
|
|
11
|
+
// Fix under test:
|
|
12
|
+
// - getNetworkId falls back to the user's single membership via the SAME
|
|
13
|
+
// shared singleNetworkId/getUserNetworkIds used by REST (network-scope.ts)
|
|
14
|
+
// - canWrite delegates to the shared canRestWriteNetwork
|
|
15
|
+
// - the 7 write tools that had NO network_id input gain an optional one:
|
|
16
|
+
// send_message / send_reply / ack_inbox / send_ack / retry_task /
|
|
17
|
+
// cancel_task / reassign_task
|
|
18
|
+
// - writeDeniedReply names the REAL cause: network_id_required (0 or ≥2
|
|
19
|
+
// memberships) / access_denied (not a member of the requested network) /
|
|
20
|
+
// permission_denied (viewer role)
|
|
21
|
+
// - ntok_ (network-bound) behavior: ZERO change — pinned below
|
|
22
|
+
//
|
|
23
|
+
// Assertion discipline: error replies are asserted with toEqual (accepted
|
|
24
|
+
// set == spec-allowed set, no not.toBe / toContain wideners).
|
|
25
|
+
//
|
|
26
|
+
// Run: COMMHUB_DB=/tmp/517-net-scope-test.db bun test src/mcp-write-network-resolution.test.ts
|
|
27
|
+
|
|
28
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
29
|
+
import { db, uuidv4 } from "./db.js";
|
|
30
|
+
import { deleteNetwork } from "./auth.js";
|
|
31
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
32
|
+
import { registerTools } from "./tools.js";
|
|
33
|
+
|
|
34
|
+
const NET_A = "net_517_a";
|
|
35
|
+
const NET_B = "net_517_b";
|
|
36
|
+
const U_SINGLE = "u517_single"; // owner of NET_A only → fallback resolves
|
|
37
|
+
const U_MULTI = "u517_multi"; // member of NET_A + NET_B → ambiguous
|
|
38
|
+
const U_VIEWER = "u517_viewer"; // viewer of NET_A only → resolves, then role-denied
|
|
39
|
+
const U_NONE = "u517_none"; // no memberships
|
|
40
|
+
const U_OUTSIDER = "u517_outsider"; // member of NET_B only, targets NET_A
|
|
41
|
+
const A1 = "peer-517-a1";
|
|
42
|
+
const A2 = "peer-517-a2";
|
|
43
|
+
const ALL_USERS = [U_SINGLE, U_MULTI, U_VIEWER, U_NONE, U_OUTSIDER];
|
|
44
|
+
|
|
45
|
+
interface ToolHandler {
|
|
46
|
+
(args: any, extra?: any): Promise<{ content: Array<{ type: "text"; text: string }> }>;
|
|
47
|
+
}
|
|
48
|
+
interface Reply { ok?: boolean; error?: string; message?: string; [k: string]: unknown }
|
|
49
|
+
|
|
50
|
+
function cleanup() {
|
|
51
|
+
for (const t of ["tasks", "inbox", "task_events"]) {
|
|
52
|
+
try { db.run(`DELETE FROM ${t} WHERE network_id IN (?1, ?2)`, [NET_A, NET_B]); } catch {}
|
|
53
|
+
try { db.run(`DELETE FROM ${t} WHERE content LIKE 'x517%'`); } catch {}
|
|
54
|
+
}
|
|
55
|
+
try { db.run("DELETE FROM sessions WHERE network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {}
|
|
56
|
+
try { db.run("DELETE FROM nodes WHERE network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {}
|
|
57
|
+
try { db.run("DELETE FROM network_members WHERE network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {}
|
|
58
|
+
try { db.run("DELETE FROM networks WHERE network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {}
|
|
59
|
+
for (const u of ALL_USERS) {
|
|
60
|
+
try { db.run("DELETE FROM users WHERE user_id = ?1", [u]); } catch {}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function seed() {
|
|
65
|
+
for (const u of ALL_USERS) {
|
|
66
|
+
db.run(
|
|
67
|
+
`INSERT INTO users (user_id, username, password_hash, role, created_at)
|
|
68
|
+
VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
|
|
69
|
+
[u, u],
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET_A, U_SINGLE]);
|
|
73
|
+
db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET_B, U_MULTI]);
|
|
74
|
+
const member = (u: string, net: string, role: string) =>
|
|
75
|
+
db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, ?3, datetime('now'))`, [u, net, role]);
|
|
76
|
+
member(U_SINGLE, NET_A, "owner");
|
|
77
|
+
member(U_MULTI, NET_A, "member");
|
|
78
|
+
member(U_MULTI, NET_B, "owner");
|
|
79
|
+
member(U_VIEWER, NET_A, "viewer");
|
|
80
|
+
member(U_OUTSIDER, NET_B, "member");
|
|
81
|
+
for (const alias of [A1, A2]) {
|
|
82
|
+
db.run(
|
|
83
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state)
|
|
84
|
+
VALUES (?1, ?2, ?2, ?3, ?4, datetime('now'), datetime('now'), 'active')`,
|
|
85
|
+
[`node_${alias}`, alias, NET_A, `host-${alias}`],
|
|
86
|
+
);
|
|
87
|
+
db.run(
|
|
88
|
+
`INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
|
|
89
|
+
VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
|
|
90
|
+
[`res_${alias}`, alias, `node_${alias}`, NET_A],
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
beforeEach(() => { cleanup(); seed(); });
|
|
96
|
+
afterAll(cleanup);
|
|
97
|
+
|
|
98
|
+
type HandlerMap = { tools: Record<string, ToolHandler>; schemas: Record<string, Record<string, unknown>> };
|
|
99
|
+
|
|
100
|
+
function buildHandlers(opts: { netId?: string | null; userId?: string | null; alias?: string | null; isNetwork?: boolean }): HandlerMap {
|
|
101
|
+
const server = new McpServer({ name: "test", version: "0" }) as any;
|
|
102
|
+
const tools: Record<string, ToolHandler> = {};
|
|
103
|
+
const schemas: Record<string, Record<string, unknown>> = {};
|
|
104
|
+
const origTool = server.tool.bind(server);
|
|
105
|
+
server.tool = (name: string, _desc: string, schema: any, handler: ToolHandler) => {
|
|
106
|
+
tools[name] = handler;
|
|
107
|
+
schemas[name] = schema;
|
|
108
|
+
return origTool(name, _desc, schema, handler);
|
|
109
|
+
};
|
|
110
|
+
registerTools(
|
|
111
|
+
server,
|
|
112
|
+
undefined,
|
|
113
|
+
opts.netId ?? null,
|
|
114
|
+
opts.userId ?? null,
|
|
115
|
+
opts.alias ?? opts.userId ?? null,
|
|
116
|
+
opts.isNetwork ?? false,
|
|
117
|
+
null,
|
|
118
|
+
);
|
|
119
|
+
return { tools, schemas };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function call(handler: ToolHandler, args: any): Promise<Reply> {
|
|
123
|
+
const r = await handler(args);
|
|
124
|
+
return JSON.parse(r.content[0].text) as Reply;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function makeTask(to: string, status: string, content: string, net: string | null = NET_A): string {
|
|
128
|
+
const id = uuidv4();
|
|
129
|
+
db.run(
|
|
130
|
+
`INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, network_id)
|
|
131
|
+
VALUES (?1, 'dispatcher', ?2, 'normal', ?3, ?4, 'reply', datetime('now'), ?5)`,
|
|
132
|
+
[id, to, status, content, net],
|
|
133
|
+
);
|
|
134
|
+
return id;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function makeInbox(to: string, content: string, net: string | null = NET_A): string {
|
|
138
|
+
const id = uuidv4();
|
|
139
|
+
db.run(
|
|
140
|
+
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id)
|
|
141
|
+
VALUES (?1, ?2, ?3, 'message', 'normal', ?4, 'dispatcher', ?5)`,
|
|
142
|
+
[id, to, `node_${to}`, content, net],
|
|
143
|
+
);
|
|
144
|
+
return id;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const taskRow = (content: string) =>
|
|
148
|
+
db.get<{ task_id: string; status: string; to_name: string; network_id: string | null }>(
|
|
149
|
+
"SELECT task_id, status, to_name, network_id FROM tasks WHERE content = ?1", content);
|
|
150
|
+
const taskById = (id: string) =>
|
|
151
|
+
db.get<{ status: string; to_name: string; network_id: string | null }>(
|
|
152
|
+
"SELECT status, to_name, network_id FROM tasks WHERE task_id = ?1", id);
|
|
153
|
+
const inboxRow = (content: string) =>
|
|
154
|
+
db.get<{ id: string; acked: number; network_id: string | null }>(
|
|
155
|
+
"SELECT id, acked, network_id FROM inbox WHERE content = ?1", content);
|
|
156
|
+
|
|
157
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
158
|
+
// Group 1 — utok, single network, NO network_id argument anywhere.
|
|
159
|
+
// The hub must auto-resolve to the user's only membership (REST parity
|
|
160
|
+
// with server.ts POST /api/task → singleNetworkId fallback).
|
|
161
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
162
|
+
describe("utok single-network auto-resolve (no network_id argument)", () => {
|
|
163
|
+
test("send_task resolves to the only membership", async () => {
|
|
164
|
+
const { tools } = buildHandlers({ userId: U_SINGLE });
|
|
165
|
+
const r = await call(tools["send_task"], { alias: A1, task: "x517 g1 send_task", priority: "normal" });
|
|
166
|
+
expect(r.ok).toBe(true);
|
|
167
|
+
expect(taskRow("x517 g1 send_task")?.network_id).toBe(NET_A);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("send_message resolves to the only membership", async () => {
|
|
171
|
+
const { tools } = buildHandlers({ userId: U_SINGLE });
|
|
172
|
+
const r = await call(tools["send_message"], { alias: A1, message: "x517 g1 send_message" });
|
|
173
|
+
expect(r.ok).toBe(true);
|
|
174
|
+
expect(inboxRow("x517 g1 send_message")?.network_id).toBe(NET_A);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("send_reply resolves to the only membership", async () => {
|
|
178
|
+
const { tools } = buildHandlers({ userId: U_SINGLE });
|
|
179
|
+
const t = makeTask(A1, "delivered", "x517 g1 send_reply parent");
|
|
180
|
+
const r = await call(tools["send_reply"], { alias: A1, text: "x517 g1 send_reply", in_reply_to: t, status: "replied" });
|
|
181
|
+
expect(r.ok).toBe(true);
|
|
182
|
+
expect(taskById(t)?.status).toBe("replied");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("ack_inbox resolves to the only membership", async () => {
|
|
186
|
+
const { tools } = buildHandlers({ userId: U_SINGLE });
|
|
187
|
+
const ib = makeInbox(A1, "x517 g1 ack_inbox");
|
|
188
|
+
const r = await call(tools["ack_inbox"], { alias: A1, message_id: ib });
|
|
189
|
+
expect(r.ok).toBe(true);
|
|
190
|
+
expect(inboxRow("x517 g1 ack_inbox")?.acked).toBe(1);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("send_ack resolves to the only membership", async () => {
|
|
194
|
+
const { tools } = buildHandlers({ userId: U_SINGLE });
|
|
195
|
+
const t = makeTask(A1, "delivered", "x517 g1 send_ack");
|
|
196
|
+
const r = await call(tools["send_ack"], { task_id: t });
|
|
197
|
+
expect(r.ok).toBe(true);
|
|
198
|
+
expect(taskById(t)?.status).toBe("acked");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("retry_task resolves to the only membership", async () => {
|
|
202
|
+
const { tools } = buildHandlers({ userId: U_SINGLE });
|
|
203
|
+
const t = makeTask(A1, "failed", "x517 g1 retry_task");
|
|
204
|
+
const r = await call(tools["retry_task"], { task_id: t });
|
|
205
|
+
expect(r.ok).toBe(true);
|
|
206
|
+
expect(taskById(t)?.status).toBe("delivered");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("cancel_task resolves to the only membership", async () => {
|
|
210
|
+
const { tools } = buildHandlers({ userId: U_SINGLE });
|
|
211
|
+
const t = makeTask(A1, "delivered", "x517 g1 cancel_task");
|
|
212
|
+
const r = await call(tools["cancel_task"], { task_id: t });
|
|
213
|
+
expect(r.ok).toBe(true);
|
|
214
|
+
expect(taskById(t)?.status).toBe("cancelled");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("reassign_task resolves to the only membership", async () => {
|
|
218
|
+
const { tools } = buildHandlers({ userId: U_SINGLE });
|
|
219
|
+
const t = makeTask(A1, "delivered", "x517 g1 reassign_task");
|
|
220
|
+
const r = await call(tools["reassign_task"], { task_id: t, new_alias: A2 });
|
|
221
|
+
expect(r.ok).toBe(true);
|
|
222
|
+
expect(taskById(t)?.to_name).toBe(A2);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
227
|
+
// Group 2 — utok, MULTI network. Without network_id: precise
|
|
228
|
+
// network_id_required error (not permission_denied). With explicit
|
|
229
|
+
// network_id: every formerly param-less tool must thread it through.
|
|
230
|
+
// One case per changed tool (通信龙 acceptance rule: 改了三个只测一个,
|
|
231
|
+
// 剩下两个等于没验).
|
|
232
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
233
|
+
describe("utok multi-network", () => {
|
|
234
|
+
test("send_task without network_id → exact network_id_required error", async () => {
|
|
235
|
+
const { tools } = buildHandlers({ userId: U_MULTI });
|
|
236
|
+
const r = await call(tools["send_task"], { alias: A1, task: "x517 g2 ambiguous", priority: "normal" });
|
|
237
|
+
expect(r).toEqual({
|
|
238
|
+
ok: false,
|
|
239
|
+
error: "network_id_required",
|
|
240
|
+
message: "user token spans 2 networks; pass network_id explicitly (see /api/auth/me networks[].network_id)",
|
|
241
|
+
});
|
|
242
|
+
expect(taskRow("x517 g2 ambiguous")).toBeNull();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("send_message threads explicit network_id", async () => {
|
|
246
|
+
const { tools } = buildHandlers({ userId: U_MULTI });
|
|
247
|
+
const r = await call(tools["send_message"], { alias: A1, message: "x517 g2 send_message", network_id: NET_A });
|
|
248
|
+
expect(r.ok).toBe(true);
|
|
249
|
+
expect(inboxRow("x517 g2 send_message")?.network_id).toBe(NET_A);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("send_reply threads explicit network_id", async () => {
|
|
253
|
+
const { tools } = buildHandlers({ userId: U_MULTI });
|
|
254
|
+
const t = makeTask(A1, "delivered", "x517 g2 send_reply parent");
|
|
255
|
+
const r = await call(tools["send_reply"], { alias: A1, text: "x517 g2 send_reply", in_reply_to: t, status: "replied", network_id: NET_A });
|
|
256
|
+
expect(r.ok).toBe(true);
|
|
257
|
+
expect(taskById(t)?.status).toBe("replied");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("ack_inbox threads explicit network_id", async () => {
|
|
261
|
+
const { tools } = buildHandlers({ userId: U_MULTI });
|
|
262
|
+
const ib = makeInbox(A1, "x517 g2 ack_inbox");
|
|
263
|
+
const r = await call(tools["ack_inbox"], { alias: A1, message_id: ib, network_id: NET_A });
|
|
264
|
+
expect(r.ok).toBe(true);
|
|
265
|
+
expect(inboxRow("x517 g2 ack_inbox")?.acked).toBe(1);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("send_ack threads explicit network_id", async () => {
|
|
269
|
+
const { tools } = buildHandlers({ userId: U_MULTI });
|
|
270
|
+
const t = makeTask(A1, "delivered", "x517 g2 send_ack");
|
|
271
|
+
const r = await call(tools["send_ack"], { task_id: t, network_id: NET_A });
|
|
272
|
+
expect(r.ok).toBe(true);
|
|
273
|
+
expect(taskById(t)?.status).toBe("acked");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("retry_task threads explicit network_id", async () => {
|
|
277
|
+
const { tools } = buildHandlers({ userId: U_MULTI });
|
|
278
|
+
const t = makeTask(A1, "failed", "x517 g2 retry_task");
|
|
279
|
+
const r = await call(tools["retry_task"], { task_id: t, network_id: NET_A });
|
|
280
|
+
expect(r.ok).toBe(true);
|
|
281
|
+
expect(taskById(t)?.status).toBe("delivered");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("cancel_task threads explicit network_id", async () => {
|
|
285
|
+
const { tools } = buildHandlers({ userId: U_MULTI });
|
|
286
|
+
const t = makeTask(A1, "delivered", "x517 g2 cancel_task");
|
|
287
|
+
const r = await call(tools["cancel_task"], { task_id: t, network_id: NET_A });
|
|
288
|
+
expect(r.ok).toBe(true);
|
|
289
|
+
expect(taskById(t)?.status).toBe("cancelled");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("reassign_task threads explicit network_id", async () => {
|
|
293
|
+
const { tools } = buildHandlers({ userId: U_MULTI });
|
|
294
|
+
const t = makeTask(A1, "delivered", "x517 g2 reassign_task");
|
|
295
|
+
const r = await call(tools["reassign_task"], { task_id: t, new_alias: A2, network_id: NET_A });
|
|
296
|
+
expect(r.ok).toBe(true);
|
|
297
|
+
expect(taskById(t)?.to_name).toBe(A2);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("all 7 formerly param-less write tools expose network_id in their input schema", () => {
|
|
301
|
+
const { schemas } = buildHandlers({ userId: U_MULTI });
|
|
302
|
+
for (const name of ["send_message", "send_reply", "ack_inbox", "send_ack", "retry_task", "cancel_task", "reassign_task"]) {
|
|
303
|
+
expect(Object.keys(schemas[name] ?? {})).toContain("network_id");
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
309
|
+
// Group 3 — precise causes replace the old catch-all message.
|
|
310
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
311
|
+
describe("precise denial causes", () => {
|
|
312
|
+
test("utok with zero memberships → network_id_required naming the real cause", async () => {
|
|
313
|
+
const { tools } = buildHandlers({ userId: U_NONE });
|
|
314
|
+
const r = await call(tools["send_task"], { alias: A1, task: "x517 g3 none", priority: "normal" });
|
|
315
|
+
expect(r).toEqual({
|
|
316
|
+
ok: false,
|
|
317
|
+
error: "network_id_required",
|
|
318
|
+
message: "user token has no network memberships; join or create a network first",
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("utok explicitly targeting a network it is not a member of → access_denied", async () => {
|
|
323
|
+
const { tools } = buildHandlers({ userId: U_OUTSIDER });
|
|
324
|
+
const r = await call(tools["send_task"], { alias: A1, task: "x517 g3 outsider", priority: "normal", network_id: NET_A });
|
|
325
|
+
expect(r).toEqual({
|
|
326
|
+
ok: false,
|
|
327
|
+
error: "access_denied",
|
|
328
|
+
message: "access denied to requested network (not a member)",
|
|
329
|
+
});
|
|
330
|
+
expect(taskRow("x517 g3 outsider")).toBeNull();
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("viewer in a single network → permission_denied (send_task wording)", async () => {
|
|
334
|
+
const { tools } = buildHandlers({ userId: U_VIEWER });
|
|
335
|
+
const r = await call(tools["send_task"], { alias: A1, task: "x517 g3 viewer task", priority: "normal" });
|
|
336
|
+
expect(r).toEqual({
|
|
337
|
+
ok: false,
|
|
338
|
+
error: "permission_denied",
|
|
339
|
+
message: "Viewer role cannot send tasks",
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("viewer in a single network → permission_denied (generic write wording)", async () => {
|
|
344
|
+
const { tools } = buildHandlers({ userId: U_VIEWER });
|
|
345
|
+
const r = await call(tools["send_message"], { alias: A1, message: "x517 g3 viewer msg" });
|
|
346
|
+
expect(r).toEqual({
|
|
347
|
+
ok: false,
|
|
348
|
+
error: "permission_denied",
|
|
349
|
+
message: "Viewer role cannot write to this network",
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
355
|
+
// Group 3b — ghost memberships (PR #519 codex P2): deleteNetwork used to
|
|
356
|
+
// leave network_members rows behind, so a stale membership to a deleted
|
|
357
|
+
// network could (a) make a single-network user look multi-network, or
|
|
358
|
+
// (b) auto-resolve a write INTO the deleted network, creating orphaned
|
|
359
|
+
// rows. getUserNetworkIds now joins networks; deleteNetwork now removes
|
|
360
|
+
// its membership rows.
|
|
361
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
362
|
+
describe("ghost memberships (deleted networks)", () => {
|
|
363
|
+
const GHOST = "net_517_ghost";
|
|
364
|
+
|
|
365
|
+
test("membership to a deleted network does not break single-network auto-resolve", async () => {
|
|
366
|
+
// stale row: membership exists, networks row does not
|
|
367
|
+
db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [U_SINGLE, GHOST]);
|
|
368
|
+
const { tools } = buildHandlers({ userId: U_SINGLE });
|
|
369
|
+
const r = await call(tools["send_task"], { alias: A1, task: "x517 ghost still-single", priority: "normal" });
|
|
370
|
+
expect(r.ok).toBe(true);
|
|
371
|
+
expect(taskRow("x517 ghost still-single")?.network_id).toBe(NET_A);
|
|
372
|
+
db.run("DELETE FROM network_members WHERE network_id = ?1", [GHOST]);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test("sole membership pointing at a deleted network → no-membership error, no orphaned write", async () => {
|
|
376
|
+
db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [U_NONE, GHOST]);
|
|
377
|
+
const { tools } = buildHandlers({ userId: U_NONE });
|
|
378
|
+
const r = await call(tools["send_task"], { alias: A1, task: "x517 ghost orphan", priority: "normal" });
|
|
379
|
+
expect(r).toEqual({
|
|
380
|
+
ok: false,
|
|
381
|
+
error: "network_id_required",
|
|
382
|
+
message: "user token has no network memberships; join or create a network first",
|
|
383
|
+
});
|
|
384
|
+
expect(taskRow("x517 ghost orphan")).toBeNull();
|
|
385
|
+
db.run("DELETE FROM network_members WHERE network_id = ?1", [GHOST]);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test("deleteNetwork removes the network's membership rows (root cause)", () => {
|
|
389
|
+
const NET_DEL = "net_517_del";
|
|
390
|
+
db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET_DEL, U_SINGLE]);
|
|
391
|
+
db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [U_SINGLE, NET_DEL]);
|
|
392
|
+
const res = deleteNetwork(U_SINGLE, NET_DEL);
|
|
393
|
+
expect(res.ok).toBe(true);
|
|
394
|
+
const left = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM network_members WHERE network_id = ?1", NET_DEL);
|
|
395
|
+
expect(left?.cnt).toBe(0);
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
400
|
+
// Group 4 — ntok_ pins: network-bound tokens must see ZERO change.
|
|
401
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
402
|
+
describe("ntok behavior pinned (zero change)", () => {
|
|
403
|
+
test("ntok send_task without network_id stays bound to token network", async () => {
|
|
404
|
+
const { tools } = buildHandlers({ netId: NET_A, userId: U_SINGLE, alias: A1, isNetwork: true });
|
|
405
|
+
const r = await call(tools["send_task"], { alias: A2, task: "x517 g4 ntok plain", priority: "normal" });
|
|
406
|
+
expect(r.ok).toBe(true);
|
|
407
|
+
expect(taskRow("x517 g4 ntok plain")?.network_id).toBe(NET_A);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("ntok send_task ignores a client-supplied foreign network_id (enforced binding wins)", async () => {
|
|
411
|
+
const { tools } = buildHandlers({ netId: NET_A, userId: U_MULTI, alias: A1, isNetwork: true });
|
|
412
|
+
const r = await call(tools["send_task"], { alias: A2, task: "x517 g4 ntok override", priority: "normal", network_id: NET_B });
|
|
413
|
+
expect(r.ok).toBe(true);
|
|
414
|
+
expect(taskRow("x517 g4 ntok override")?.network_id).toBe(NET_A);
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
419
|
+
// Group 5 — legacy global-token mode (no enforceUserId): the fallback
|
|
420
|
+
// must NOT kick in; writes stay unscoped exactly as before.
|
|
421
|
+
// ─────────────────────────────────────────────────────────────────────
|
|
422
|
+
describe("legacy global-token mode pinned", () => {
|
|
423
|
+
test("send_message without auth context stays network-unscoped", async () => {
|
|
424
|
+
const { tools } = buildHandlers({ userId: null });
|
|
425
|
+
const r = await call(tools["send_message"], { alias: A1, message: "x517 g5 legacy" });
|
|
426
|
+
expect(r.ok).toBe(true);
|
|
427
|
+
expect(inboxRow("x517 g5 legacy")?.network_id).toBeNull();
|
|
428
|
+
});
|
|
429
|
+
});
|