@sleep2agi/commhub-server 0.9.0-preview.7 → 0.9.0-preview.71
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 +10 -2
- package/package.json +5 -5
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-delete-network-http.test.ts +177 -0
- package/src/admin-networks-http.test.ts +88 -0
- package/src/agent-access.ts +461 -0
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-acl-http.test.ts +510 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/agent-groups-perf.test.ts +167 -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 +423 -29
- 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/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 +51 -1
- package/src/dashboard-slash-routing-http.test.ts +107 -0
- package/src/db-adapter-guard.test.ts +85 -0
- package/src/db-adapter-translate.test.ts +112 -0
- package/src/db-adapter.ts +246 -83
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +799 -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/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 +73 -0
- package/src/http-gzip.ts +60 -0
- package/src/hub-timestamp.test.ts +38 -0
- package/src/hub-timestamp.ts +27 -0
- package/src/human-dm.ts +149 -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-write-network-resolution.test.ts +429 -0
- package/src/messages-ack-by-agent-http.test.ts +142 -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 +204 -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-tags.ts +16 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +708 -0
- package/src/requirements-mcp.test.ts +142 -0
- package/src/requirements-migrate.ts +83 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +85 -0
- package/src/requirements.ts +899 -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 +10 -1
- 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-run-terminal.test.ts +268 -0
- package/src/scheduled-skip-blocking-task.test.ts +120 -0
- package/src/scheduled-tasks-http.test.ts +625 -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 +4385 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/side-thread-command-transport.test.ts +302 -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 +2495 -0
- package/src/skillhub-http.test.ts +92 -0
- package/src/start-node.test.ts +109 -0
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/stop-delete-node.test.ts +1164 -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 +3446 -257
- 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 +130 -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,120 @@
|
|
|
1
|
+
// node_rules_requests content retention (privacy).
|
|
2
|
+
//
|
|
3
|
+
// The rules-file / skills (and project-folder) queue carries **node file
|
|
4
|
+
// contents** through the hub: `result_content` holds what the node sent back
|
|
5
|
+
// (CLAUDE.md / AGENTS.md, SKILL.md, directory listings, file bodies) and
|
|
6
|
+
// `content` holds write payloads (the full new rules file). Before this module
|
|
7
|
+
// nothing ever cleared or deleted those rows — every file anyone ever opened in
|
|
8
|
+
// the desktop app stayed in the hub DB forever.
|
|
9
|
+
//
|
|
10
|
+
// Policy (metadata — status, op, file_name, file_exists, error, timestamps,
|
|
11
|
+
// requester — is kept for audit; only file bytes are dropped):
|
|
12
|
+
//
|
|
13
|
+
// 1. Read-once + grace: the first time an authorized requester reads a
|
|
14
|
+
// TERMINAL result (done/failed/timeout) the row is stamped `first_read_at`.
|
|
15
|
+
// CONTENT_GRACE_MS later the content is purged. Not immediate, because the
|
|
16
|
+
// desktop app has a real second-reader path: when enqueue is rejected with
|
|
17
|
+
// `request_in_flight` the app FOLLOWS the existing request id
|
|
18
|
+
// (requestIdToFollow → waitForRulesFileResult, NodeRulesSection /
|
|
19
|
+
// NodeSkillsSection / NodeFilesSection). Two windows (or a remount) polling
|
|
20
|
+
// the same request reach terminal within one poll interval (≤ 3 s) of each
|
|
21
|
+
// other; an immediate purge would hand the second one `done` with no
|
|
22
|
+
// content and NodeRulesSection would load an EMPTY editor (`content ?? ''`)
|
|
23
|
+
// that a Save would then write back to the node. 60 s is 20× the slowest
|
|
24
|
+
// poll interval.
|
|
25
|
+
// 2. TTL: anything older than CONTENT_TTL_MS gets its content purged whether
|
|
26
|
+
// or not anyone ever read it (e.g. the app was closed mid-request). A
|
|
27
|
+
// still-pending/in_progress row that old is first flipped to `timeout`, so
|
|
28
|
+
// a node that comes back after a day can never pull a write whose payload
|
|
29
|
+
// was just nulled (it would otherwise receive `content: ""` and write an
|
|
30
|
+
// empty rules file).
|
|
31
|
+
// 3. Rows older than ROW_TTL_MS are deleted outright.
|
|
32
|
+
// 4. Run logs (op = logs_tail) are stricter: read-once with NO grace — the
|
|
33
|
+
// first authorized terminal read purges them in the same call
|
|
34
|
+
// (purgeLogsResultNow), because the app never follows another request's
|
|
35
|
+
// logs result (it simply asks again) and a process log is the most
|
|
36
|
+
// sensitive thing on this queue. Unread terminal results are purged after
|
|
37
|
+
// LOGS_CONTENT_TTL_MS (the requester closed the page). The filter JSON in
|
|
38
|
+
// `content` (it can hold a user-typed grep) goes with it.
|
|
39
|
+
//
|
|
40
|
+
// Every statement is bounded (LIMIT via `request_id IN (SELECT … LIMIT n)` —
|
|
41
|
+
// SQLite's UPDATE/DELETE … LIMIT needs a compile flag we don't control) and
|
|
42
|
+
// uses an index (idx_nrr_created / idx_nrr_first_read). It runs
|
|
43
|
+
// opportunistically on enqueue and on terminal reads, from a one-shot timer
|
|
44
|
+
// armed at the first terminal read, and from the hourly retention sweeper
|
|
45
|
+
// (retention.ts) — so the first sweep after deploy also clears every old row
|
|
46
|
+
// that predates this module.
|
|
47
|
+
|
|
48
|
+
import { db } from "./db.js";
|
|
49
|
+
|
|
50
|
+
export const CONTENT_GRACE_MS = 60_000;
|
|
51
|
+
export const CONTENT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
52
|
+
export const ROW_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
53
|
+
export const SWEEP_BATCH = 500;
|
|
54
|
+
export const LOGS_CONTENT_TTL_MS = 5 * 60 * 1000;
|
|
55
|
+
|
|
56
|
+
export type NodeRequestSweepResult = { timedOut: number; purged: number; deleted: number };
|
|
57
|
+
|
|
58
|
+
// `content` is only a file body for op=write. For skill_read / file_read it is
|
|
59
|
+
// the skill name / relative path the requester asked for — audit metadata, kept.
|
|
60
|
+
// For logs_tail it is the filter JSON, which may carry a user-typed grep — purged.
|
|
61
|
+
const PURGE_SET =
|
|
62
|
+
"result_content = NULL, content = CASE WHEN op IN ('write', 'logs_tail') THEN NULL ELSE content END, content_purged_at = ?1";
|
|
63
|
+
|
|
64
|
+
function purgeWhere(where: string, params: (string | number)[], now: number): number {
|
|
65
|
+
const r = db.run(
|
|
66
|
+
`UPDATE node_rules_requests SET ${PURGE_SET} WHERE request_id IN (SELECT request_id FROM node_rules_requests WHERE content_purged_at IS NULL AND ${where} LIMIT ${SWEEP_BATCH})`,
|
|
67
|
+
[now, ...params],
|
|
68
|
+
);
|
|
69
|
+
return r.changes ?? 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function sweepNodeRequestContent(now: number = Date.now()): NodeRequestSweepResult {
|
|
73
|
+
const out: NodeRequestSweepResult = { timedOut: 0, purged: 0, deleted: 0 };
|
|
74
|
+
try {
|
|
75
|
+
// Delete first so rows past the row TTL aren't pointlessly purged, then
|
|
76
|
+
// expire stale non-terminal rows, then purge.
|
|
77
|
+
out.deleted = db.run(
|
|
78
|
+
`DELETE FROM node_rules_requests WHERE request_id IN (SELECT request_id FROM node_rules_requests WHERE created_at < ?1 LIMIT ${SWEEP_BATCH})`,
|
|
79
|
+
[now - ROW_TTL_MS],
|
|
80
|
+
).changes ?? 0;
|
|
81
|
+
const ttlCutoff = now - CONTENT_TTL_MS;
|
|
82
|
+
out.timedOut = db.run(
|
|
83
|
+
`UPDATE node_rules_requests SET status = 'timeout', acked_at = ?1, error = ?2 WHERE request_id IN (SELECT request_id FROM node_rules_requests WHERE created_at < ?3 AND status IN ('pending', 'in_progress') LIMIT ${SWEEP_BATCH})`,
|
|
84
|
+
[now, "expired by retention sweep (node never answered)", ttlCutoff],
|
|
85
|
+
).changes ?? 0;
|
|
86
|
+
// ?1 = now (PURGE_SET), ?2… = where params.
|
|
87
|
+
out.purged += purgeWhere("first_read_at IS NOT NULL AND first_read_at <= ?2 AND status IN ('done', 'failed', 'timeout')", [now - CONTENT_GRACE_MS], now);
|
|
88
|
+
out.purged += purgeWhere("created_at < ?2 AND status IN ('done', 'failed', 'timeout')", [ttlCutoff], now);
|
|
89
|
+
out.purged += purgeWhere("op = 'logs_tail' AND created_at < ?2 AND status IN ('done', 'failed', 'timeout')", [now - LOGS_CONTENT_TTL_MS], now);
|
|
90
|
+
} catch (e: any) {
|
|
91
|
+
console.log(`[commhub retention] node_rules_requests sweep failed: ${e?.message ?? e}`);
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Called by get_rules_file_result AFTER the requester passed every auth check
|
|
97
|
+
* and only for a terminal status. Stamps the first terminal read, arms a
|
|
98
|
+
* one-shot purge for after the grace window, and reports whether this row's
|
|
99
|
+
* content is already gone. */
|
|
100
|
+
export function noteTerminalResultRead(requestId: string, now: number = Date.now()): { purged: boolean } {
|
|
101
|
+
const r = db.run(
|
|
102
|
+
"UPDATE node_rules_requests SET first_read_at = ?1 WHERE request_id = ?2 AND first_read_at IS NULL",
|
|
103
|
+
[now, requestId],
|
|
104
|
+
);
|
|
105
|
+
if ((r.changes ?? 0) > 0) {
|
|
106
|
+
const t = setTimeout(() => { sweepNodeRequestContent(); }, CONTENT_GRACE_MS + 1_000);
|
|
107
|
+
(t as any).unref?.();
|
|
108
|
+
}
|
|
109
|
+
const row = db.get<{ content_purged_at: number | null }>(
|
|
110
|
+
"SELECT content_purged_at FROM node_rules_requests WHERE request_id = ?1",
|
|
111
|
+
requestId,
|
|
112
|
+
);
|
|
113
|
+
return { purged: row?.content_purged_at != null };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Run-log results are read-once: called by get_rules_file_result right after it
|
|
117
|
+
* handed a terminal logs_tail result to its (authorized) requester. */
|
|
118
|
+
export function purgeLogsResultNow(requestId: string, now: number = Date.now()): void {
|
|
119
|
+
db.run(`UPDATE node_rules_requests SET ${PURGE_SET} WHERE request_id = ?2 AND op = 'logs_tail' AND content_purged_at IS NULL`, [now, requestId]);
|
|
120
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Node skills view — list_node_skills / read_node_skill ride the rules-file
|
|
2
|
+
// queue + doorbell (ops skills_list / skill_read).
|
|
3
|
+
//
|
|
4
|
+
// Hub-side contract verified here:
|
|
5
|
+
// 1. report_status { skills_capable: true } is sticky and set only by the
|
|
6
|
+
// session's own bound node token.
|
|
7
|
+
// 2. list_node_skills / read_node_skill reach a session with no nodes row by
|
|
8
|
+
// alias; the node pulls with its own token; read carries the skill NAME
|
|
9
|
+
// (never a path) in the pulled request; results come back through
|
|
10
|
+
// get_rules_file_result for both ops.
|
|
11
|
+
// 3. Bad skill names are refused before any request row exists.
|
|
12
|
+
// 4. A session that can serve rules files but did not report skills_capable is
|
|
13
|
+
// refused up front for skills ops (skills_target_not_found).
|
|
14
|
+
// 5. Separate single-flight lanes: a pending rules read does not block a skills
|
|
15
|
+
// list and vice versa; a second skills request while one is pending is refused.
|
|
16
|
+
// 6. Network isolation and foreign acks are ignored.
|
|
17
|
+
//
|
|
18
|
+
// 跑法:cd server && COMMHUB_DB=/tmp/skills-session.db bun test src/node-skills-transport.test.ts
|
|
19
|
+
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
|
20
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
21
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
22
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
23
|
+
import { db } from "./db.js";
|
|
24
|
+
import { registerTools } from "./tools.js";
|
|
25
|
+
|
|
26
|
+
const NET = "net_skills_session";
|
|
27
|
+
const OTHER_NET = "net_skills_session_other";
|
|
28
|
+
const USER = "u_skills_session_owner";
|
|
29
|
+
const SESSION_ALIAS = "skills-session-cc";
|
|
30
|
+
const SESSION_RESUME = "cc-skills-session-1";
|
|
31
|
+
const OTHER_ALIAS = "skills-session-peer";
|
|
32
|
+
const NODE_ROW_ALIAS = "skills-session-noderow";
|
|
33
|
+
const NODE_ROW_ID = "node_skills_session_noderow";
|
|
34
|
+
const T_SESSION = "tok_skills_session_cc";
|
|
35
|
+
const T_PEER = "tok_skills_session_peer";
|
|
36
|
+
const T_OTHER_NET_SAME_ALIAS = "tok_skills_session_other_net";
|
|
37
|
+
const T_NODE_ROW = "tok_skills_session_noderow";
|
|
38
|
+
|
|
39
|
+
function cleanup() {
|
|
40
|
+
for (const net of [NET, OTHER_NET]) {
|
|
41
|
+
try { db.run("DELETE FROM node_rules_requests WHERE network_id = ?1", [net]); } catch {}
|
|
42
|
+
try { db.run("DELETE FROM sessions WHERE network_id = ?1", [net]); } catch {}
|
|
43
|
+
try { db.run("DELETE FROM nodes WHERE network_id = ?1", [net]); } catch {}
|
|
44
|
+
try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [net]); } catch {}
|
|
45
|
+
try { db.run("DELETE FROM network_members WHERE network_id = ?1", [net]); } catch {}
|
|
46
|
+
try { db.run("DELETE FROM networks WHERE network_id = ?1", [net]); } catch {}
|
|
47
|
+
}
|
|
48
|
+
try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function seedWorld() {
|
|
52
|
+
db.run(`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`, [USER, USER]);
|
|
53
|
+
for (const net of [NET, OTHER_NET]) {
|
|
54
|
+
db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [net, net, USER]);
|
|
55
|
+
db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [USER, net]);
|
|
56
|
+
}
|
|
57
|
+
const tok = (id: string, net: string, alias: string) => db.run(
|
|
58
|
+
`INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at) VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
|
|
59
|
+
[id, USER, net, `node:${alias}`, `hash_${id}`],
|
|
60
|
+
);
|
|
61
|
+
tok(T_SESSION, NET, SESSION_ALIAS);
|
|
62
|
+
tok(T_PEER, NET, OTHER_ALIAS);
|
|
63
|
+
tok(T_OTHER_NET_SAME_ALIAS, OTHER_NET, SESSION_ALIAS);
|
|
64
|
+
tok(T_NODE_ROW, NET, NODE_ROW_ALIAS);
|
|
65
|
+
db.run(
|
|
66
|
+
`INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, datetime('now'), datetime('now'))`,
|
|
67
|
+
[NODE_ROW_ID, NODE_ROW_ALIAS, NODE_ROW_ALIAS, NET],
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type Identity = { net: string; alias: string; isNetworkToken: boolean; tokenId: string };
|
|
72
|
+
const asUser: Identity = { net: NET, alias: USER, isNetworkToken: false, tokenId: "tok_skills_session_user" };
|
|
73
|
+
const asOtherNetUser: Identity = { net: OTHER_NET, alias: USER, isNetworkToken: false, tokenId: "tok_skills_session_user2" };
|
|
74
|
+
const asSession: Identity = { net: NET, alias: SESSION_ALIAS, isNetworkToken: true, tokenId: T_SESSION };
|
|
75
|
+
const asPeer: Identity = { net: NET, alias: OTHER_ALIAS, isNetworkToken: true, tokenId: T_PEER };
|
|
76
|
+
const asOtherNetSameAlias: Identity = { net: OTHER_NET, alias: SESSION_ALIAS, isNetworkToken: true, tokenId: T_OTHER_NET_SAME_ALIAS };
|
|
77
|
+
const asNodeRow: Identity = { net: NET, alias: NODE_ROW_ALIAS, isNetworkToken: true, tokenId: T_NODE_ROW };
|
|
78
|
+
|
|
79
|
+
async function connect(id: Identity) {
|
|
80
|
+
const server = new McpServer({ name: "skills-session-test", version: "1" });
|
|
81
|
+
registerTools(server, undefined, id.net, USER, id.alias, id.isNetworkToken, id.tokenId);
|
|
82
|
+
const client = new Client({ name: "skills-session-client", version: "1" });
|
|
83
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
84
|
+
await server.connect(st);
|
|
85
|
+
await client.connect(ct);
|
|
86
|
+
const call = async (name: string, args: Record<string, unknown>) => {
|
|
87
|
+
const r: any = await client.callTool({ name, arguments: args });
|
|
88
|
+
const first = r.content?.[0];
|
|
89
|
+
if (!first || first.type !== "text") throw new Error(`no text result from ${name}: ${JSON.stringify(r).slice(0, 300)}`);
|
|
90
|
+
return JSON.parse(first.text);
|
|
91
|
+
};
|
|
92
|
+
const close = async () => { await client.close(); await server.close(); };
|
|
93
|
+
return { call, close };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const capable = (alias: string, net = NET) =>
|
|
97
|
+
db.get<{ c: number }>("SELECT skills_capable AS c FROM sessions WHERE alias = ?1 AND network_id = ?2", alias, net)?.c;
|
|
98
|
+
const rowCount = () => db.get<{ n: number }>("SELECT COUNT(*) AS n FROM node_rules_requests WHERE network_id = ?1", NET)!.n;
|
|
99
|
+
|
|
100
|
+
async function registerSession(id: Identity, alias: string, resume: string, flag: boolean) {
|
|
101
|
+
const c = await connect(id);
|
|
102
|
+
try {
|
|
103
|
+
return await c.call("report_status", { resume_id: resume, alias, status: "idle", agent: "claude-code", ...(flag ? { rules_file_capable: true, skills_capable: true } : {}) });
|
|
104
|
+
} finally { await c.close(); }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
beforeEach(() => { cleanup(); seedWorld(); });
|
|
108
|
+
afterAll(cleanup);
|
|
109
|
+
|
|
110
|
+
async function reportRulesOnly(id: Identity, alias: string, resume: string) {
|
|
111
|
+
const c = await connect(id);
|
|
112
|
+
try { return await c.call("report_status", { resume_id: resume, alias, status: "idle", agent: "claude-code", rules_file_capable: true }); }
|
|
113
|
+
finally { await c.close(); }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
describe("skills_capable is set only by the session's own node token", () => {
|
|
117
|
+
test("bound token sets it; a report without it does not clear it; a peer cannot set it", async () => {
|
|
118
|
+
await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
|
|
119
|
+
expect(capable(SESSION_ALIAS)).toBe(1);
|
|
120
|
+
await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, false);
|
|
121
|
+
expect(capable(SESSION_ALIAS)).toBe(1);
|
|
122
|
+
const r = await registerSession(asPeer, SESSION_ALIAS, SESSION_RESUME, true);
|
|
123
|
+
expect(r).toMatchObject({ ok: false });
|
|
124
|
+
}, 20_000);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("list / read by alias reach a session with no nodes row", () => {
|
|
128
|
+
test("list round trip: enqueue → pull (no content) → ack JSON → result carries it", async () => {
|
|
129
|
+
await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
|
|
130
|
+
const u = await connect(asUser);
|
|
131
|
+
const s = await connect(asSession);
|
|
132
|
+
try {
|
|
133
|
+
const enq = await u.call("list_node_skills", { alias: SESSION_ALIAS });
|
|
134
|
+
expect(enq).toMatchObject({ ok: true, op: "skills_list" });
|
|
135
|
+
const pulled = await s.call("get_rules_file_request", {});
|
|
136
|
+
expect(pulled.request).toEqual({ request_id: enq.request_id, op: "skills_list" });
|
|
137
|
+
const payload = JSON.stringify({ skills: [{ name: "ship", scope: "project", path_rel: ".claude/skills/ship/SKILL.md", description: "d" }] });
|
|
138
|
+
expect(await s.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", file_name: "skills", exists: true, content: payload })).toMatchObject({ ok: true, status: "done" });
|
|
139
|
+
const res = await u.call("get_rules_file_result", { request_id: enq.request_id });
|
|
140
|
+
expect(res).toMatchObject({ ok: true, op: "skills_list", status: "done", content: payload });
|
|
141
|
+
} finally { await u.close(); await s.close(); }
|
|
142
|
+
}, 20_000);
|
|
143
|
+
|
|
144
|
+
test("read round trip: the pulled request carries the skill name only", async () => {
|
|
145
|
+
await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
|
|
146
|
+
const u = await connect(asUser);
|
|
147
|
+
const s = await connect(asSession);
|
|
148
|
+
try {
|
|
149
|
+
const enq = await u.call("read_node_skill", { alias: SESSION_ALIAS, name: "ship" });
|
|
150
|
+
expect(enq).toMatchObject({ ok: true, op: "skill_read" });
|
|
151
|
+
const pulled = await s.call("get_rules_file_request", {});
|
|
152
|
+
expect(pulled.request).toEqual({ request_id: enq.request_id, op: "skill_read", content: "ship" });
|
|
153
|
+
const payload = JSON.stringify({ name: "ship", scope: "project", path_rel: ".claude/skills/ship/SKILL.md", description: "d", content: "# ship" });
|
|
154
|
+
await s.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", file_name: "ship", exists: true, content: payload });
|
|
155
|
+
expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ status: "done", content: payload });
|
|
156
|
+
} finally { await u.close(); await s.close(); }
|
|
157
|
+
}, 20_000);
|
|
158
|
+
|
|
159
|
+
test("bad names are refused before any row is written", async () => {
|
|
160
|
+
await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
|
|
161
|
+
const u = await connect(asUser);
|
|
162
|
+
try {
|
|
163
|
+
for (const name of ["..", ".", "../CLAUDE.md", "a/b", "a b"]) {
|
|
164
|
+
expect(await u.call("read_node_skill", { alias: SESSION_ALIAS, name })).toMatchObject({ ok: false, error: "invalid_skill_name" });
|
|
165
|
+
}
|
|
166
|
+
expect(rowCount()).toBe(0);
|
|
167
|
+
} finally { await u.close(); }
|
|
168
|
+
}, 20_000);
|
|
169
|
+
|
|
170
|
+
test("rules-capable but not skills-capable → skills_target_not_found, no row", async () => {
|
|
171
|
+
await reportRulesOnly(asSession, SESSION_ALIAS, SESSION_RESUME);
|
|
172
|
+
const u = await connect(asUser);
|
|
173
|
+
try {
|
|
174
|
+
expect(await u.call("list_node_skills", { alias: SESSION_ALIAS })).toMatchObject({ ok: false, error: "skills_target_not_found" });
|
|
175
|
+
expect(rowCount()).toBe(0);
|
|
176
|
+
expect(await u.call("read_node_rules_file", { alias: SESSION_ALIAS })).toMatchObject({ ok: true });
|
|
177
|
+
} finally { await u.close(); }
|
|
178
|
+
}, 20_000);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("single-flight lanes", () => {
|
|
182
|
+
test("a pending rules read does not block a skills list; a second skills request is refused", async () => {
|
|
183
|
+
await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
|
|
184
|
+
const u = await connect(asUser);
|
|
185
|
+
try {
|
|
186
|
+
expect(await u.call("read_node_rules_file", { alias: SESSION_ALIAS })).toMatchObject({ ok: true });
|
|
187
|
+
const list = await u.call("list_node_skills", { alias: SESSION_ALIAS });
|
|
188
|
+
expect(list).toMatchObject({ ok: true });
|
|
189
|
+
expect(await u.call("read_node_skill", { alias: SESSION_ALIAS, name: "ship" })).toMatchObject({ ok: false, error: "request_in_flight", existing_request_id: list.request_id });
|
|
190
|
+
expect(await u.call("read_node_rules_file", { alias: SESSION_ALIAS })).toMatchObject({ ok: false, error: "request_in_flight" });
|
|
191
|
+
} finally { await u.close(); }
|
|
192
|
+
}, 20_000);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe("isolation", () => {
|
|
196
|
+
test("same alias in another network neither pulls nor acks; a peer's ack is ignored", async () => {
|
|
197
|
+
await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
|
|
198
|
+
const u = await connect(asUser);
|
|
199
|
+
const other = await connect(asOtherNetSameAlias);
|
|
200
|
+
const peer = await connect(asPeer);
|
|
201
|
+
try {
|
|
202
|
+
const enq = await u.call("list_node_skills", { alias: SESSION_ALIAS });
|
|
203
|
+
expect((await other.call("get_rules_file_request", {})).request).toBeNull();
|
|
204
|
+
expect(await other.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: "{}" })).toMatchObject({ ok: true, ignored: "unknown_or_foreign_request" });
|
|
205
|
+
expect(await peer.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: "{}" })).toMatchObject({ ok: true, ignored: "unknown_or_foreign_request" });
|
|
206
|
+
expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ status: "pending" });
|
|
207
|
+
} finally { await u.close(); await other.close(); await peer.close(); }
|
|
208
|
+
}, 20_000);
|
|
209
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// 节点令牌不继承签发者的账号权限与 Hub 管理员身份。
|
|
2
|
+
//
|
|
3
|
+
// 生产上几乎所有节点令牌(ntok_)都由管理员签发,resolveToken 把它解析成那个管理员用户。
|
|
4
|
+
// 以前一部分端点直接看 `resolved.user.role === "admin"` 或把令牌当成用户本人,于是任何一个节点
|
|
5
|
+
// 都能:列全部用户、读服务端日志与审计日志、给签发者再签用户令牌 / 节点令牌、改签发者资料与密码、
|
|
6
|
+
// 建 / 改 / 删网络、管成员与邀请、读别的网络详情、看全部网络的 SSE 拓扑。
|
|
7
|
+
// 这里每一条在修复前(main)都是红的;管理员自己的用户令牌作为正控,照常可用。
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
10
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
import { tmpdir } from "os";
|
|
13
|
+
import { register, createNetwork, createNetworkTokenForNode } from "./auth.js";
|
|
14
|
+
import { db } from "./db.js";
|
|
15
|
+
|
|
16
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-ntok-priv-"));
|
|
17
|
+
let BASE = "";
|
|
18
|
+
let hub: any = null;
|
|
19
|
+
const PW = "NtokPrivPassw0rd!";
|
|
20
|
+
let adminToken = "", adminId = "", NET = "", OTHER_NET = "", ntok = "";
|
|
21
|
+
|
|
22
|
+
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
|
23
|
+
async function call(token: string, method: string, path: string, body?: unknown) {
|
|
24
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
25
|
+
method,
|
|
26
|
+
headers: { ...auth(token), ...(body === undefined ? {} : { "Content-Type": "application/json" }) },
|
|
27
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
28
|
+
});
|
|
29
|
+
return { status: res.status, body: await res.json().catch(() => null) as any };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
beforeAll(async () => {
|
|
33
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
34
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
35
|
+
process.env.HOST = "127.0.0.1";
|
|
36
|
+
const admin = register(`ntok_admin_${Date.now()}`, PW);
|
|
37
|
+
adminToken = admin.token!; adminId = admin.user!.user_id; NET = admin.network_id!;
|
|
38
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [adminId]);
|
|
39
|
+
const other = createNetwork(adminId, "ntok-other");
|
|
40
|
+
OTHER_NET = (other as any).network_id;
|
|
41
|
+
const minted = createNetworkTokenForNode(adminId, NET, "ntok-node", "node_ntok_priv");
|
|
42
|
+
expect(minted.ok).toBe(true);
|
|
43
|
+
ntok = minted.token!;
|
|
44
|
+
register(`ntok_someone_${Date.now()}`, PW);
|
|
45
|
+
const mod: any = await import("./server.js");
|
|
46
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
47
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
48
|
+
}, 30_000);
|
|
49
|
+
|
|
50
|
+
afterAll(() => {
|
|
51
|
+
try { hub?.stop?.(true); } catch {}
|
|
52
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("hub-admin powers need a user token", () => {
|
|
56
|
+
test("GET /api/users", async () => {
|
|
57
|
+
expect((await call(ntok, "GET", "/api/users")).status).toBe(403);
|
|
58
|
+
expect((await call(adminToken, "GET", "/api/users")).status).toBe(200);
|
|
59
|
+
});
|
|
60
|
+
test("GET /api/server-logs", async () => {
|
|
61
|
+
expect((await call(ntok, "GET", "/api/server-logs")).status).toBe(403);
|
|
62
|
+
expect((await call(adminToken, "GET", "/api/server-logs")).status).toBe(200);
|
|
63
|
+
});
|
|
64
|
+
test("GET /api/audit-log", async () => {
|
|
65
|
+
expect((await call(ntok, "GET", "/api/audit-log")).status).toBe(403);
|
|
66
|
+
expect((await call(adminToken, "GET", "/api/audit-log")).status).toBe(200);
|
|
67
|
+
});
|
|
68
|
+
test("GET /api/networks/:id of a network the token is not bound to", async () => {
|
|
69
|
+
expect((await call(ntok, "GET", `/api/networks/${OTHER_NET}`)).status).toBe(403);
|
|
70
|
+
expect((await call(ntok, "GET", `/api/networks/${NET}`)).status).toBe(200);
|
|
71
|
+
});
|
|
72
|
+
test("GET /api/stats/sse is limited to the bound network", async () => {
|
|
73
|
+
const ctrl = new AbortController();
|
|
74
|
+
const sub = await fetch(`${BASE}/events/some-agent?network_id=${OTHER_NET}`, { headers: auth(adminToken), signal: ctrl.signal }).catch(() => null);
|
|
75
|
+
const r = await call(ntok, "GET", "/api/stats/sse");
|
|
76
|
+
ctrl.abort();
|
|
77
|
+
expect(r.status).toBe(200);
|
|
78
|
+
expect(Object.keys(r.body.sessions).every((k) => k.split(":").includes(NET))).toBe(true);
|
|
79
|
+
expect(JSON.stringify(r.body)).not.toContain(OTHER_NET);
|
|
80
|
+
void sub;
|
|
81
|
+
});
|
|
82
|
+
test("POST /api/admin/users and agent-grants", async () => {
|
|
83
|
+
expect((await call(ntok, "POST", "/api/admin/users", { username: "ntok_made", password: PW })).status).toBe(403);
|
|
84
|
+
expect((await call(ntok, "GET", `/api/networks/${NET}/members/${adminId}/agent-grants`)).status).toBe(403);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("account and network management need a user token", () => {
|
|
89
|
+
test("token management: cannot list, mint user tokens, mint node tokens or revoke", async () => {
|
|
90
|
+
expect((await call(ntok, "GET", "/api/auth/tokens")).status).toBe(403);
|
|
91
|
+
const minted = await call(ntok, "POST", "/api/auth/tokens", { name: "escalate" });
|
|
92
|
+
expect(minted.status).toBe(403);
|
|
93
|
+
expect(minted.body.token).toBeUndefined();
|
|
94
|
+
expect((await call(ntok, "POST", "/api/auth/node-token", { network_id: OTHER_NET, node_name: "x" })).status).toBe(403);
|
|
95
|
+
expect((await call(ntok, "DELETE", "/api/auth/tokens/tok_whatever")).status).toBe(403);
|
|
96
|
+
expect((await call(adminToken, "GET", "/api/auth/tokens")).status).toBe(200);
|
|
97
|
+
});
|
|
98
|
+
test("cannot edit the owner's profile or password", async () => {
|
|
99
|
+
expect((await call(ntok, "PUT", "/api/auth/me", { display_name: "pwned" })).status).toBe(403);
|
|
100
|
+
expect(db.get<{ display_name: string }>("SELECT display_name FROM users WHERE user_id = ?1", adminId)?.display_name).not.toBe("pwned");
|
|
101
|
+
expect((await call(ntok, "POST", "/api/auth/password", { old_password: PW, new_password: "AnotherPassw0rd!" })).status).toBe(403);
|
|
102
|
+
});
|
|
103
|
+
test("cannot create, rename or delete networks", async () => {
|
|
104
|
+
expect((await call(ntok, "POST", "/api/networks", { name: "ntok-made" })).status).toBe(403);
|
|
105
|
+
expect((await call(ntok, "PUT", `/api/networks/${OTHER_NET}`, { name: "renamed" })).status).toBe(403);
|
|
106
|
+
expect((await call(ntok, "DELETE", `/api/networks/${OTHER_NET}`)).status).toBe(403);
|
|
107
|
+
expect(db.get("SELECT 1 FROM networks WHERE network_id = ?1", OTHER_NET)).toBeTruthy();
|
|
108
|
+
});
|
|
109
|
+
test("cannot manage members, invites or join", async () => {
|
|
110
|
+
expect((await call(ntok, "GET", `/api/networks/${NET}/members`)).status).toBe(403);
|
|
111
|
+
expect((await call(ntok, "POST", `/api/networks/${NET}/members`, { user_id: "u_x" })).status).toBe(403);
|
|
112
|
+
expect((await call(ntok, "POST", `/api/networks/${NET}/invite`, { role: "admin" })).status).toBe(403);
|
|
113
|
+
expect((await call(ntok, "POST", "/api/networks/join", { invite_code: "inv_x" })).status).toBe(403);
|
|
114
|
+
expect((await call(adminToken, "GET", `/api/networks/${NET}/members`)).status).toBe(200);
|
|
115
|
+
});
|
|
116
|
+
test("renames: a node token may only rename ITSELF in its bound network", async () => {
|
|
117
|
+
// 别的网络 / 别的节点 → user_token_required
|
|
118
|
+
expect((await call(ntok, "POST", "/api/node-rename/prepare", { network_id: OTHER_NET, old_alias: "ntok-node", new_alias: "b" })).status).toBe(403);
|
|
119
|
+
db.run(`INSERT INTO sessions (resume_id, alias, status, network_id) VALUES ('r_other_node', 'other-node', 'idle', ?1)`, [NET]);
|
|
120
|
+
expect((await call(ntok, "POST", "/api/node-rename/prepare", { network_id: NET, old_alias: "other-node", new_alias: "b" })).status).toBe(403);
|
|
121
|
+
// 另一个节点(管理员用户令牌)开的事务,本节点不能提交
|
|
122
|
+
const foreign = await call(adminToken, "POST", "/api/node-rename/prepare", { network_id: NET, old_alias: "other-node", new_alias: "other-node-2" });
|
|
123
|
+
expect(foreign.body.ok).toBe(true);
|
|
124
|
+
expect((await call(ntok, "POST", "/api/node-rename/commit", { txn_id: foreign.body.txn_id })).status).toBe(403);
|
|
125
|
+
expect((await call(adminToken, "POST", "/api/node-rename/abort", { txn_id: foreign.body.txn_id })).body.ok).toBe(true);
|
|
126
|
+
// 自己:照常(anet node rename 就是这么用的)
|
|
127
|
+
db.run(`INSERT INTO sessions (resume_id, alias, status, network_id) VALUES ('r_self_node', 'ntok-node', 'idle', ?1)`, [NET]);
|
|
128
|
+
const mine = await call(ntok, "POST", "/api/node-rename/prepare", { network_id: NET, old_alias: "ntok-node", new_alias: "ntok-node-2" });
|
|
129
|
+
expect(mine.body.ok).toBe(true);
|
|
130
|
+
expect((await call(ntok, "POST", "/api/node-rename/abort", { txn_id: mine.body.txn_id })).body.ok).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("/api/auth/me says what the credential is", () => {
|
|
135
|
+
test("node token: owner user for compatibility, but credential.kind=node and only the bound network", async () => {
|
|
136
|
+
const r = await call(ntok, "GET", "/api/auth/me");
|
|
137
|
+
expect(r.status).toBe(200);
|
|
138
|
+
expect(r.body.credential).toMatchObject({ kind: "node", network_id: NET, node_alias: "ntok-node", acts_as_owner: false });
|
|
139
|
+
expect(r.body.current_network).toBe(NET);
|
|
140
|
+
expect(r.body.networks.map((n: any) => n.network_id)).toEqual([NET]);
|
|
141
|
+
const u = await call(adminToken, "GET", "/api/auth/me");
|
|
142
|
+
expect(u.body.credential).toEqual({ kind: "user" });
|
|
143
|
+
expect(u.body.networks.map((n: any) => n.network_id).sort()).toEqual([NET, OTHER_NET].sort());
|
|
144
|
+
});
|
|
145
|
+
test("node token keeps its own network powers (still a member): status and tasks read fine", async () => {
|
|
146
|
+
expect((await call(ntok, "GET", "/api/status")).status).toBe(200);
|
|
147
|
+
expect((await call(ntok, "GET", "/api/tasks")).status).toBe(200);
|
|
148
|
+
});
|
|
149
|
+
});
|