@sleep2agi/commhub-server 0.9.0-preview.7 → 0.9.0-preview.70
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 +125 -7
- 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-networks-http.test.ts +88 -0
- package/src/agent-access.ts +267 -0
- package/src/agent-acl-http.test.ts +510 -0
- package/src/agent-acl-slice3-http.test.ts +267 -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 +348 -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.ts +64 -23
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +762 -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/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-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-upgrade-e2e.test.ts +126 -0
- package/src/requirements-upgrade-migration.test.ts +85 -0
- package/src/requirements.ts +865 -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 +574 -0
- package/src/scheduled-tasks.ts +596 -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 +4279 -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/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 +3445 -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,75 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
import { createNetworkTokenForNode, register } from "./auth.js";
|
|
6
|
+
import { db } from "./db.js";
|
|
7
|
+
|
|
8
|
+
const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-network-name-http-"));
|
|
9
|
+
let server: any;
|
|
10
|
+
let base = "";
|
|
11
|
+
let userToken = "";
|
|
12
|
+
let nodeToken = "";
|
|
13
|
+
let networkId = "";
|
|
14
|
+
let username = "";
|
|
15
|
+
|
|
16
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
17
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
18
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
19
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
20
|
+
beforeAll(async () => {
|
|
21
|
+
process.env.COMMHUB_DB = process.env.COMMHUB_DB || join(PRIVATE_DB_DIR, "hub.db");
|
|
22
|
+
username = `network_name_${Date.now()}_${process.pid}`;
|
|
23
|
+
const registered = register(username, "NetworkName123!", undefined, "seed");
|
|
24
|
+
expect(registered.ok).toBe(true);
|
|
25
|
+
userToken = registered.token!;
|
|
26
|
+
networkId = registered.network_id!;
|
|
27
|
+
const userId = db.get<{ user_id: string }>(
|
|
28
|
+
"SELECT user_id FROM users WHERE username = ?1",
|
|
29
|
+
[username],
|
|
30
|
+
)!.user_id;
|
|
31
|
+
const minted = createNetworkTokenForNode(userId, networkId, "network-name-node");
|
|
32
|
+
expect(minted.ok).toBe(true);
|
|
33
|
+
nodeToken = minted.token!;
|
|
34
|
+
const mod: any = await import("./server.js");
|
|
35
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
36
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
37
|
+
}, 30_000);
|
|
38
|
+
|
|
39
|
+
afterAll(() => {
|
|
40
|
+
try { server?.stop?.(true); } catch {}
|
|
41
|
+
try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
async function networks(token: string): Promise<any[]> {
|
|
45
|
+
const response = await fetch(`${base}/api/networks`, {
|
|
46
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
47
|
+
});
|
|
48
|
+
expect(response.status).toBe(200);
|
|
49
|
+
const body = await response.json() as any;
|
|
50
|
+
expect(body.ok).toBe(true);
|
|
51
|
+
return body.networks;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe("GET /api/networks name compatibility (#449)", () => {
|
|
55
|
+
test("utok rows expose equal non-null name and network_name", async () => {
|
|
56
|
+
const rows = await networks(userToken);
|
|
57
|
+
expect(rows).toHaveLength(1);
|
|
58
|
+
expect(rows[0].network_id).toBe(networkId);
|
|
59
|
+
// #449 is about `name` and `network_name` agreeing and being non-null.
|
|
60
|
+
// Pin them to the registered username rather than the literal "default":
|
|
61
|
+
// the auto-created network is named after its owner, so hardcoding the
|
|
62
|
+
// old literal would test the naming scheme instead of the compat shim.
|
|
63
|
+
expect(rows[0].network_name).toBe(username);
|
|
64
|
+
expect(rows[0].name).toBe(username);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("network-bound ntok rows expose the same alias", async () => {
|
|
68
|
+
const rows = await networks(nodeToken);
|
|
69
|
+
expect(rows).toHaveLength(1);
|
|
70
|
+
expect(rows[0].network_id).toBe(networkId);
|
|
71
|
+
expect(rows[0].network_name).toBe(username);
|
|
72
|
+
expect(rows[0].name).toBe(rows[0].network_name);
|
|
73
|
+
expect(rows[0].name).not.toBeNull();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// Network-scope resolution shared by BOTH transports.
|
|
2
|
+
//
|
|
3
|
+
// #517: REST (server.ts) and MCP (tools.ts) each carried their own copy of
|
|
4
|
+
// the "which network does this caller write to" rule. REST grew a
|
|
5
|
+
// single-network fallback (POST /api/task); MCP never did — so a utok_
|
|
6
|
+
// caller could read everything but write nothing, with a misleading
|
|
7
|
+
// permission_denied. Extracted here (same pattern as lifecycle-guard.ts)
|
|
8
|
+
// so the rule has exactly ONE implementation. If you change write-scope
|
|
9
|
+
// semantics, this module is the only place — do NOT re-inline a copy in
|
|
10
|
+
// server.ts or tools.ts.
|
|
11
|
+
|
|
12
|
+
import { db } from "./db.js";
|
|
13
|
+
import { getUserNetworkRole } from "./auth.js";
|
|
14
|
+
import { isAgentRestricted, restrictedNetworkIds, usernameIsAgentAlias, visibleAgents } from "./agent-access.js";
|
|
15
|
+
|
|
16
|
+
export type RestNetworkScope = {
|
|
17
|
+
networkId: string | null;
|
|
18
|
+
networkIds: string[] | null;
|
|
19
|
+
denied?: string;
|
|
20
|
+
// 多用户 Agent 权限:scope 里哪些网络对这个调用者是「只看授权 Agent」的。
|
|
21
|
+
// addNetworkScope 对这些网络 fail-closed(整网不返回);要按授权放行的路径
|
|
22
|
+
// 显式改用 addAgentNetworkScope / addOwnTrafficScope,人类数据用 addHumanNetworkScope。
|
|
23
|
+
agentRestriction?: { userId: string; username: string; networkIds: string[] };
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type ScopeAuthCtx = { userId: string; networkId: string | null; username?: string | null };
|
|
27
|
+
|
|
28
|
+
function usernameOf(userId: string, known?: string | null): string {
|
|
29
|
+
if (known) return known;
|
|
30
|
+
return db.get<{ username: string }>("SELECT username FROM users WHERE user_id = ?1", userId)?.username ?? "";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function withAgentRestriction(scope: RestNetworkScope, authCtx: ScopeAuthCtx): RestNetworkScope {
|
|
34
|
+
const inScope = scope.networkId ? [scope.networkId] : (scope.networkIds ?? []);
|
|
35
|
+
if (inScope.length === 0) return scope;
|
|
36
|
+
const restricted = restrictedNetworkIds(authCtx.userId).filter((id) => inScope.includes(id));
|
|
37
|
+
if (restricted.length === 0) return scope;
|
|
38
|
+
return { ...scope, agentRestriction: { userId: authCtx.userId, username: usernameOf(authCtx.userId, authCtx.username), networkIds: restricted } };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function getUserNetworkIds(userId: string): string[] {
|
|
42
|
+
// JOIN networks: deleteNetwork historically left network_members rows
|
|
43
|
+
// behind (fixed alongside PR #519, but legacy DBs still carry strays).
|
|
44
|
+
// A ghost membership must neither make a single-network user look
|
|
45
|
+
// ambiguous nor let the write fallback resolve INTO a deleted network.
|
|
46
|
+
return db.all<{ network_id: string }>(
|
|
47
|
+
`SELECT m.network_id FROM network_members m
|
|
48
|
+
JOIN networks n ON n.network_id = m.network_id
|
|
49
|
+
WHERE m.user_id = ?1`,
|
|
50
|
+
userId
|
|
51
|
+
).map((row) => row.network_id);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function resolveRestNetworkScope(requested: string | null, authCtx: ScopeAuthCtx | null, isAdmin: boolean): RestNetworkScope {
|
|
55
|
+
// Legacy global token or open dev mode keeps the old global behavior.
|
|
56
|
+
if (!authCtx) return { networkId: requested || null, networkIds: null };
|
|
57
|
+
|
|
58
|
+
// Network tokens are forcibly scoped to their bound network.
|
|
59
|
+
if (authCtx.networkId) return { networkId: authCtx.networkId, networkIds: null };
|
|
60
|
+
|
|
61
|
+
// System admins may intentionally inspect all networks.
|
|
62
|
+
if (isAdmin) return { networkId: requested || null, networkIds: null };
|
|
63
|
+
|
|
64
|
+
if (requested) {
|
|
65
|
+
const role = getUserNetworkRole(authCtx.userId, requested);
|
|
66
|
+
if (!role) return { networkId: null, networkIds: [], denied: "access denied to requested network" };
|
|
67
|
+
return withAgentRestriction({ networkId: requested, networkIds: null }, authCtx);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return withAgentRestriction({ networkId: null, networkIds: getUserNetworkIds(authCtx.userId) }, authCtx);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function unrestrictedNetworks(scope: RestNetworkScope): string[] | null {
|
|
74
|
+
const excluded = scope.agentRestriction?.networkIds ?? [];
|
|
75
|
+
if (scope.networkId) return excluded.includes(scope.networkId) ? [] : [scope.networkId];
|
|
76
|
+
if (scope.networkIds) return scope.networkIds.filter((id) => !excluded.includes(id));
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function placeholders(params: any[], values: string[]): string {
|
|
81
|
+
const start = params.length;
|
|
82
|
+
params.push(...values);
|
|
83
|
+
return values.map((_, i) => `?${start + i + 1}`).join(", ");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 默认的网络作用域 —— 对受限成员的受限网络 **fail-closed**:整网不返回。
|
|
88
|
+
* 没被逐条审过、不知道按授权怎么放行的查询,默认走这里,受限成员什么都看不到。
|
|
89
|
+
*/
|
|
90
|
+
export function addNetworkScope(sql: string, params: any[], scope: RestNetworkScope, column = "network_id"): string {
|
|
91
|
+
if (!scope.agentRestriction) return addHumanNetworkScope(sql, params, scope, column);
|
|
92
|
+
const allowed = unrestrictedNetworks(scope) ?? [];
|
|
93
|
+
if (allowed.length === 0) return `${sql} AND 1=0`;
|
|
94
|
+
return `${sql} AND ${column} IN (${placeholders(params, allowed)})`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 人类数据(自己的私信收件箱、需求看板、项目、网络成员)的作用域:受限成员照常看得到
|
|
99
|
+
* 自己所在网络。只给已确认不含 Agent 内容的查询用。
|
|
100
|
+
*/
|
|
101
|
+
export function addHumanNetworkScope(sql: string, params: any[], scope: RestNetworkScope, column = "network_id"): string {
|
|
102
|
+
if (scope.networkId) {
|
|
103
|
+
sql += ` AND ${column} = ?${params.length + 1}`;
|
|
104
|
+
params.push(scope.networkId);
|
|
105
|
+
} else if (scope.networkIds) {
|
|
106
|
+
if (scope.networkIds.length === 0) {
|
|
107
|
+
sql += " AND 1=0";
|
|
108
|
+
} else {
|
|
109
|
+
const placeholders = scope.networkIds.map((_, i) => `?${params.length + i + 1}`).join(", ");
|
|
110
|
+
sql += ` AND ${column} IN (${placeholders})`;
|
|
111
|
+
params.push(...scope.networkIds);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return sql;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
type AgentColumns = { network?: string; alias: string; nodeId?: string };
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Agent 行(sessions / nodes)的作用域:不受限的网络整网放行;受限网络只放行授权的 Agent。
|
|
121
|
+
* mode="message" 时只放行 can_message 的授权。
|
|
122
|
+
*/
|
|
123
|
+
export function addAgentNetworkScope(sql: string, params: any[], scope: RestNetworkScope, cols: AgentColumns, mode: "see" | "message" = "see"): string {
|
|
124
|
+
const restriction = scope.agentRestriction;
|
|
125
|
+
if (!restriction) return addHumanNetworkScope(sql, params, scope, cols.network ?? "network_id");
|
|
126
|
+
const net = cols.network ?? "network_id";
|
|
127
|
+
const parts: string[] = [];
|
|
128
|
+
const allowed = unrestrictedNetworks(scope) ?? [];
|
|
129
|
+
if (allowed.length) parts.push(`${net} IN (${placeholders(params, allowed)})`);
|
|
130
|
+
for (const networkId of restriction.networkIds) {
|
|
131
|
+
const visible = visibleAgents(restriction.userId, networkId);
|
|
132
|
+
const aliases = mode === "message" ? visible.messageAliases : visible.aliases;
|
|
133
|
+
const nodeIds = mode === "message" ? visible.messageNodeIds : visible.nodeIds;
|
|
134
|
+
const conds: string[] = [];
|
|
135
|
+
if (aliases.length) conds.push(`${cols.alias} IN (${placeholders(params, aliases)})`);
|
|
136
|
+
if (cols.nodeId && nodeIds.length) conds.push(`${cols.nodeId} IN (${placeholders(params, nodeIds)})`);
|
|
137
|
+
if (conds.length === 0) continue;
|
|
138
|
+
parts.push(`(${net} = ${placeholders(params, [networkId])} AND (${conds.join(" OR ")}))`);
|
|
139
|
+
}
|
|
140
|
+
return parts.length ? `${sql} AND (${parts.join(" OR ")})` : `${sql} AND 1=0`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
type TrafficColumns = { network?: string; from: string; to: string; fromNodeId?: string; toNodeId?: string };
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 任务 / inbox 这类「谁发给谁」的行:受限网络里只放行**自己**与**授权 Agent**之间的往来
|
|
147
|
+
* (from=自己 且 to=授权 Agent,或反过来)。别人(包括 owner)和同一个 Agent 的对话看不到。
|
|
148
|
+
*/
|
|
149
|
+
export function addOwnTrafficScope(sql: string, params: any[], scope: RestNetworkScope, cols: TrafficColumns): string {
|
|
150
|
+
const restriction = scope.agentRestriction;
|
|
151
|
+
if (!restriction) return addHumanNetworkScope(sql, params, scope, cols.network ?? "network_id");
|
|
152
|
+
const net = cols.network ?? "network_id";
|
|
153
|
+
const parts: string[] = [];
|
|
154
|
+
const allowed = unrestrictedNetworks(scope) ?? [];
|
|
155
|
+
if (allowed.length) parts.push(`${net} IN (${placeholders(params, allowed)})`);
|
|
156
|
+
if (restriction.username) {
|
|
157
|
+
for (const networkId of restriction.networkIds) {
|
|
158
|
+
// 用户名与本网络某个 Agent 的 alias 撞名时(入网时拦过,但 Agent 之后注册的 alias 拦不住),
|
|
159
|
+
// 「发给我的」无法和「发给那个 Agent 的」区分开 —— 这个网络的往来一律不放行。
|
|
160
|
+
if (usernameIsAgentAlias(networkId, restriction.username)) continue;
|
|
161
|
+
const visible = visibleAgents(restriction.userId, networkId);
|
|
162
|
+
// 先判空再生成占位符:side() 会往 params 里推值,推了又丢弃会让参数个数对不上。
|
|
163
|
+
const useNodeIds = !!(cols.fromNodeId && cols.toNodeId) && visible.nodeIds.length > 0;
|
|
164
|
+
if (visible.aliases.length === 0 && !useNodeIds) continue;
|
|
165
|
+
const side = (aliasCol: string, nodeCol?: string): string | null => {
|
|
166
|
+
const conds: string[] = [];
|
|
167
|
+
if (visible.aliases.length) conds.push(`${aliasCol} IN (${placeholders(params, visible.aliases)})`);
|
|
168
|
+
if (nodeCol && useNodeIds) conds.push(`${nodeCol} IN (${placeholders(params, visible.nodeIds)})`);
|
|
169
|
+
return conds.length ? `(${conds.join(" OR ")})` : null;
|
|
170
|
+
};
|
|
171
|
+
const toAgent = side(cols.to, cols.toNodeId)!;
|
|
172
|
+
const fromAgent = side(cols.from, cols.fromNodeId)!;
|
|
173
|
+
const me = placeholders(params, [restriction.username]);
|
|
174
|
+
parts.push(`(${net} = ${placeholders(params, [networkId])} AND ((${cols.from} = ${me} AND ${toAgent}) OR (${cols.to} = ${me} AND ${fromAgent})))`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return parts.length ? `${sql} AND (${parts.join(" OR ")})` : `${sql} AND 1=0`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function singleNetworkId(scope: RestNetworkScope): string | null {
|
|
181
|
+
if (scope.networkId) return scope.networkId;
|
|
182
|
+
if (scope.networkIds?.length === 1) return scope.networkIds[0];
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Resolve one unambiguous network for a REST write.
|
|
188
|
+
*
|
|
189
|
+
* Admin read scope is intentionally global (`networkIds=null`), so it cannot
|
|
190
|
+
* by itself express that an admin happens to have exactly one membership.
|
|
191
|
+
* Writes must not inherit that ambiguity: use the sole real membership when
|
|
192
|
+
* one exists, and keep requiring an explicit network for zero or 2+.
|
|
193
|
+
*/
|
|
194
|
+
export function resolveRestWriteNetworkId(
|
|
195
|
+
scope: RestNetworkScope,
|
|
196
|
+
authCtx: { userId: string; networkId: string | null } | null,
|
|
197
|
+
isAdmin: boolean,
|
|
198
|
+
): string | null {
|
|
199
|
+
const scoped = singleNetworkId(scope);
|
|
200
|
+
if (scoped) return scoped;
|
|
201
|
+
if (!authCtx || !isAdmin) return null;
|
|
202
|
+
const memberships = getUserNetworkIds(authCtx.userId);
|
|
203
|
+
return memberships.length === 1 ? memberships[0] : null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 能不能往这个网络写**面向 Agent 的东西**(派任务、改节点、排程……)。
|
|
208
|
+
* 受限成员在受限网络里恒为 false(fail-closed);给授权 Agent 发任务的路径另用
|
|
209
|
+
* canMessageAgent 单独放行,人类侧的写入(私信、需求、上传)用 canRestWriteNetworkAsHuman。
|
|
210
|
+
*/
|
|
211
|
+
export function canRestWriteNetwork(authCtx: { userId: string; networkId: string | null } | null, networkId: string | null, isAdmin: boolean): boolean {
|
|
212
|
+
if (!canRestWriteNetworkAsHuman(authCtx, networkId, isAdmin)) return false;
|
|
213
|
+
if (!authCtx || isAdmin || !networkId) return true;
|
|
214
|
+
return !isAgentRestricted(authCtx.userId, networkId);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** 旧的「成员且不是 viewer」判据,不看 Agent 授权。只给人类侧写入用。 */
|
|
218
|
+
export function canRestWriteNetworkAsHuman(authCtx: { userId: string; networkId: string | null } | null, networkId: string | null, isAdmin: boolean): boolean {
|
|
219
|
+
if (!authCtx) return true; // legacy global token or open dev mode
|
|
220
|
+
if (isAdmin) return true;
|
|
221
|
+
if (!networkId) return false;
|
|
222
|
+
const role = getUserNetworkRole(authCtx.userId, networkId);
|
|
223
|
+
return !!role && role !== "viewer";
|
|
224
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Boundary validation for node DISPLAY attributes (display_name / team /
|
|
2
|
+
// tags). Mirrors `avatar-validate.ts` in spirit: this is the only place
|
|
3
|
+
// that decides what a client is allowed to store, and it trusts nothing.
|
|
4
|
+
//
|
|
5
|
+
// Two different postures on purpose:
|
|
6
|
+
//
|
|
7
|
+
// display_name / team — SCALAR fields the user typed. A wrong TYPE here
|
|
8
|
+
// is a client bug, so it is REJECTED (400) rather than coerced: silently
|
|
9
|
+
// turning `{}` into "[object Object]" would persist garbage the user
|
|
10
|
+
// never typed and can't explain.
|
|
11
|
+
//
|
|
12
|
+
// tags — a LIST. Same reasoning as the `channels` patch in tools.ts: the
|
|
13
|
+
// wire contract wants junk dropped instead of failing the whole request,
|
|
14
|
+
// so one fat-fingered entry from a dashboard doesn't 400 the save. Each
|
|
15
|
+
// element is narrowed independently (typeof → trim → length cap →
|
|
16
|
+
// dedup), so nothing non-string can reach the database.
|
|
17
|
+
|
|
18
|
+
/** Max stored length of display_name / team. */
|
|
19
|
+
export const MAX_ATTR_LEN = 120;
|
|
20
|
+
/** Max stored length of a single tag. */
|
|
21
|
+
export const MAX_TAG_LEN = 64;
|
|
22
|
+
/** Max number of tags kept per node. */
|
|
23
|
+
export const MAX_TAGS = 16;
|
|
24
|
+
|
|
25
|
+
export type ScalarAttrResult =
|
|
26
|
+
| { ok: true; value: string | null }
|
|
27
|
+
| { ok: false; reason: string };
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Narrow an optional scalar display attribute.
|
|
31
|
+
* undefined → not part of this patch (caller leaves the column alone)
|
|
32
|
+
* null / "" → explicit clear
|
|
33
|
+
* non-string → rejected (never coerced)
|
|
34
|
+
* over-long → rejected (the user should see it, not silently lose text)
|
|
35
|
+
*/
|
|
36
|
+
export function validateScalarAttr(raw: unknown, field: string): ScalarAttrResult {
|
|
37
|
+
if (raw === undefined) return { ok: true, value: null };
|
|
38
|
+
if (raw === null) return { ok: true, value: null };
|
|
39
|
+
if (typeof raw !== "string") {
|
|
40
|
+
return { ok: false, reason: `${field} must be a string or null` };
|
|
41
|
+
}
|
|
42
|
+
const trimmed = raw.trim();
|
|
43
|
+
if (trimmed.length === 0) return { ok: true, value: null };
|
|
44
|
+
if (trimmed.length > MAX_ATTR_LEN) {
|
|
45
|
+
return { ok: false, reason: `${field} exceeds ${MAX_ATTR_LEN} characters` };
|
|
46
|
+
}
|
|
47
|
+
// Control characters would corrupt any log line / table cell that renders
|
|
48
|
+
// the value; strip rather than reject (they are almost always a paste
|
|
49
|
+
// artefact, not user intent).
|
|
50
|
+
// eslint-disable-next-line no-control-regex
|
|
51
|
+
const clean = trimmed.replace(/[\u0000-\u001f\u007f]+/g, " ").trim();
|
|
52
|
+
return { ok: true, value: clean.length > 0 ? clean : null };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Narrow an untrusted tags array. NEVER throws, never rejects the request:
|
|
57
|
+
* unusable entries are dropped. Returns the canonical list to store.
|
|
58
|
+
*/
|
|
59
|
+
export function narrowTags(raw: unknown): string[] {
|
|
60
|
+
if (!Array.isArray(raw)) return [];
|
|
61
|
+
const out: string[] = [];
|
|
62
|
+
const seen = new Set<string>();
|
|
63
|
+
for (const item of raw) {
|
|
64
|
+
if (typeof item !== "string") continue; // numbers/objects/arrays/null
|
|
65
|
+
// eslint-disable-next-line no-control-regex
|
|
66
|
+
const t = item.replace(/[\u0000-\u001f\u007f]+/g, " ").trim();
|
|
67
|
+
if (t.length === 0) continue;
|
|
68
|
+
if (t.length > MAX_TAG_LEN) continue; // absurd entry — drop, don't truncate
|
|
69
|
+
const key = t.toLowerCase(); // case-fold for dedup, keep first spelling
|
|
70
|
+
if (seen.has(key)) continue;
|
|
71
|
+
seen.add(key);
|
|
72
|
+
out.push(t);
|
|
73
|
+
if (out.length >= MAX_TAGS) break;
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Parse the stored tags column back into an array. Storage is JSON text;
|
|
79
|
+
* anything unparseable (hand-edited DB, older format) reads as empty so a
|
|
80
|
+
* dashboard can always `.map()` without a guard. */
|
|
81
|
+
export function parseStoredTags(stored: unknown): string[] {
|
|
82
|
+
if (typeof stored !== "string" || stored.length === 0) return [];
|
|
83
|
+
try {
|
|
84
|
+
return narrowTags(JSON.parse(stored));
|
|
85
|
+
} catch {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// Node display attributes — team / tags / display_name (Vincent 目标第 3 条).
|
|
2
|
+
//
|
|
3
|
+
// These are HUB-SIDE DISPLAY attributes. Following the `avatar_url` (#462)
|
|
4
|
+
// precedent they are deliberately NOT part of the RFC-024 config-apply
|
|
5
|
+
// pipeline: agent-node has nothing to consume, so pushing them through
|
|
6
|
+
// config revision/ack would ring a doorbell for a field the node does not
|
|
7
|
+
// understand and could wedge the config revision.
|
|
8
|
+
//
|
|
9
|
+
// They DO need concurrency protection (two dashboards editing the same
|
|
10
|
+
// node), which `avatar_url` lacks. Hence a dedicated `attrs_revision` CAS
|
|
11
|
+
// that is independent of `config_revision` — editing a tag must never bump
|
|
12
|
+
// the node's *config* revision.
|
|
13
|
+
//
|
|
14
|
+
// Every test drives the REAL HTTP endpoint against a booted server, not a
|
|
15
|
+
// helper: the contract under test is the wire shape + status codes.
|
|
16
|
+
//
|
|
17
|
+
// NOTE (test isolation): this file boots its own Bun.serve, same pattern as
|
|
18
|
+
// uploads-http.test.ts / api-host-supervisors-fallback.test.ts. Those files
|
|
19
|
+
// are per-file runs by design (single-process module-singleton conflict,
|
|
20
|
+
// tracked in #434) — run this file on its own.
|
|
21
|
+
|
|
22
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
23
|
+
import { mkdtempSync } from "fs";
|
|
24
|
+
import { tmpdir } from "os";
|
|
25
|
+
import { join } from "path";
|
|
26
|
+
|
|
27
|
+
const tmpDb = join(mkdtempSync(join(tmpdir(), "anet-attrs-")), "commhub.db");
|
|
28
|
+
process.env.COMMHUB_DB ||= tmpDb;
|
|
29
|
+
const PORT = 23000 + Math.floor(Math.random() * 1500);
|
|
30
|
+
process.env.PORT = String(PORT);
|
|
31
|
+
process.env.HOST = "127.0.0.1";
|
|
32
|
+
|
|
33
|
+
const { db } = await import("./db.js");
|
|
34
|
+
const { issueUserToken } = await import("./auth.js");
|
|
35
|
+
const { bootServer } = await import("./server.js");
|
|
36
|
+
|
|
37
|
+
const BASE = `http://127.0.0.1:${PORT}`;
|
|
38
|
+
const NET = "net_attrs";
|
|
39
|
+
const USER = "u_attrs";
|
|
40
|
+
/** A node row seeded the way a PRE-MIGRATION row looks: none of the new
|
|
41
|
+
* columns are written, so they are NULL / default at read time. */
|
|
42
|
+
const LEGACY_NODE = "node_attrs_legacy";
|
|
43
|
+
const LEGACY_ALIAS = "attrs-legacy-node";
|
|
44
|
+
|
|
45
|
+
let token = "";
|
|
46
|
+
let server: { stop: (force?: boolean) => void } | null = null;
|
|
47
|
+
|
|
48
|
+
// 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
|
|
49
|
+
// 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
|
|
50
|
+
// (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
|
|
51
|
+
// 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
|
|
52
|
+
beforeAll(async () => {
|
|
53
|
+
db.run(`INSERT INTO users (user_id, username, password_hash, role) VALUES (?1, 'attrs-user', 'x', 'user')`, [USER]);
|
|
54
|
+
db.run(`INSERT INTO networks (network_id, network_name, owner_id) VALUES (?1, 'attrs-net', ?2)`, [NET, USER]);
|
|
55
|
+
db.run(`INSERT INTO network_members (network_id, user_id, role) VALUES (?1, ?2, 'owner')`, [NET, USER]);
|
|
56
|
+
// Legacy row: only the columns that existed before this change.
|
|
57
|
+
db.run(
|
|
58
|
+
`INSERT INTO nodes (node_id, node_name, alias, runtime, model, network_id)
|
|
59
|
+
VALUES (?1, 'attrs-legacy', ?2, 'claude-agent-sdk', 'claude-sonnet-4-6', ?3)`,
|
|
60
|
+
[LEGACY_NODE, LEGACY_ALIAS, NET],
|
|
61
|
+
);
|
|
62
|
+
token = issueUserToken(USER, "attrs-test").token;
|
|
63
|
+
server = bootServer({ port: PORT, hostname: "127.0.0.1" }) as never;
|
|
64
|
+
await new Promise((r) => setTimeout(r, 120));
|
|
65
|
+
}, 30_000);
|
|
66
|
+
|
|
67
|
+
afterAll(() => {
|
|
68
|
+
try { server?.stop(true); } catch { /* already down */ }
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
async function putAttrs(ref: string, body: unknown) {
|
|
72
|
+
const res = await fetch(`${BASE}/api/nodes/${encodeURIComponent(ref)}/attrs?network_id=${NET}`, {
|
|
73
|
+
method: "PUT",
|
|
74
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
75
|
+
body: JSON.stringify(body),
|
|
76
|
+
});
|
|
77
|
+
return { status: res.status, json: (await res.json()) as Record<string, unknown> };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function listNodes() {
|
|
81
|
+
const res = await fetch(`${BASE}/api/nodes?network_id=${NET}`, {
|
|
82
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
83
|
+
});
|
|
84
|
+
const j = (await res.json()) as { nodes?: Array<Record<string, unknown>> };
|
|
85
|
+
return j.nodes ?? [];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe("node display attrs — backward compatibility with existing rows", () => {
|
|
89
|
+
test("a pre-migration node row still lists fine; new fields read as empty, not missing/throwing", async () => {
|
|
90
|
+
const rows = await listNodes();
|
|
91
|
+
const row = rows.find((r) => r.node_id === LEGACY_NODE);
|
|
92
|
+
expect(row).toBeDefined();
|
|
93
|
+
// Must not blow up and must not omit the keys — the dashboard reads
|
|
94
|
+
// them unconditionally.
|
|
95
|
+
expect(row!).toHaveProperty("display_name");
|
|
96
|
+
expect(row!).toHaveProperty("team");
|
|
97
|
+
expect(row!).toHaveProperty("tags");
|
|
98
|
+
expect(row!.display_name ?? null).toBeNull();
|
|
99
|
+
expect(row!.team ?? null).toBeNull();
|
|
100
|
+
// tags normalises to an empty array rather than null/undefined so the
|
|
101
|
+
// dashboard can `.map()` without a guard.
|
|
102
|
+
expect(row!.tags).toEqual([]);
|
|
103
|
+
expect(row!.attrs_revision).toBe(0);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("node display attrs — write then read back", () => {
|
|
108
|
+
test("PUT sets display_name/team/tags, bumps attrs_revision, and GET /api/nodes reflects it", async () => {
|
|
109
|
+
const w = await putAttrs(LEGACY_ALIAS, {
|
|
110
|
+
base_attrs_revision: 0,
|
|
111
|
+
display_name: "研发一号机",
|
|
112
|
+
team: "platform",
|
|
113
|
+
tags: ["prod", "gpu"],
|
|
114
|
+
});
|
|
115
|
+
expect(w.status).toBe(200);
|
|
116
|
+
expect(w.json.ok).toBe(true);
|
|
117
|
+
expect(w.json.attrs_revision).toBe(1);
|
|
118
|
+
|
|
119
|
+
const row = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
|
|
120
|
+
expect(row.display_name).toBe("研发一号机");
|
|
121
|
+
expect(row.team).toBe("platform");
|
|
122
|
+
expect(row.tags).toEqual(["prod", "gpu"]);
|
|
123
|
+
expect(row.attrs_revision).toBe(1);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("editing attrs does NOT touch config_revision (display vs config are separate revisions)", async () => {
|
|
127
|
+
const before = db.get<{ config_revision: number }>(
|
|
128
|
+
"SELECT config_revision FROM nodes WHERE node_id = ?1", LEGACY_NODE)!.config_revision ?? 0;
|
|
129
|
+
const w = await putAttrs(LEGACY_ALIAS, { base_attrs_revision: 1, team: "infra" });
|
|
130
|
+
expect(w.status).toBe(200);
|
|
131
|
+
const after = db.get<{ config_revision: number }>(
|
|
132
|
+
"SELECT config_revision FROM nodes WHERE node_id = ?1", LEGACY_NODE)!.config_revision ?? 0;
|
|
133
|
+
expect(after).toBe(before);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("partial patch leaves untouched fields alone", async () => {
|
|
137
|
+
const row = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
|
|
138
|
+
// team was just changed to 'infra'; display_name/tags must survive.
|
|
139
|
+
expect(row.team).toBe("infra");
|
|
140
|
+
expect(row.display_name).toBe("研发一号机");
|
|
141
|
+
expect(row.tags).toEqual(["prod", "gpu"]);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("node display attrs — CAS concurrency guard", () => {
|
|
146
|
+
test("stale base_attrs_revision → 409 with the current revision, and NO write happens", async () => {
|
|
147
|
+
const before = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
|
|
148
|
+
const stale = await putAttrs(LEGACY_ALIAS, {
|
|
149
|
+
base_attrs_revision: 0, // deliberately stale
|
|
150
|
+
team: "should-not-land",
|
|
151
|
+
});
|
|
152
|
+
expect(stale.status).toBe(409);
|
|
153
|
+
expect(stale.json.ok).toBe(false);
|
|
154
|
+
expect(stale.json.error).toBe("attrs_revision_conflict");
|
|
155
|
+
expect(stale.json.current_attrs_revision).toBe(before.attrs_revision);
|
|
156
|
+
|
|
157
|
+
const after = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
|
|
158
|
+
expect(after.team).toBe(before.team); // unchanged
|
|
159
|
+
expect(after.attrs_revision).toBe(before.attrs_revision);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("node display attrs — untrusted input is narrowed at the boundary", () => {
|
|
164
|
+
test("tags: non-strings dropped, blanks dropped, duplicates folded, count + length capped", async () => {
|
|
165
|
+
const cur = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!.attrs_revision as number;
|
|
166
|
+
const w = await putAttrs(LEGACY_ALIAS, {
|
|
167
|
+
base_attrs_revision: cur,
|
|
168
|
+
tags: [
|
|
169
|
+
"keep",
|
|
170
|
+
"keep", // duplicate
|
|
171
|
+
" spaced ", // trimmed
|
|
172
|
+
"",
|
|
173
|
+
" ",
|
|
174
|
+
42,
|
|
175
|
+
null,
|
|
176
|
+
{ evil: true },
|
|
177
|
+
["nested"],
|
|
178
|
+
"x".repeat(200), // over-long → dropped or truncated, never stored raw
|
|
179
|
+
],
|
|
180
|
+
});
|
|
181
|
+
expect(w.status).toBe(200);
|
|
182
|
+
const tags = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!.tags as string[];
|
|
183
|
+
expect(Array.isArray(tags)).toBe(true);
|
|
184
|
+
expect(tags).toContain("keep");
|
|
185
|
+
expect(tags).toContain("spaced");
|
|
186
|
+
// one "keep", no junk, nothing absurdly long
|
|
187
|
+
expect(tags.filter((t) => t === "keep")).toHaveLength(1);
|
|
188
|
+
expect(tags.every((t) => typeof t === "string" && t.length > 0 && t.length <= 64)).toBe(true);
|
|
189
|
+
expect(tags.some((t) => t.includes("evil") || t.includes("nested"))).toBe(false);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("display_name/team: non-string values are rejected, not coerced", async () => {
|
|
193
|
+
const cur = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!.attrs_revision as number;
|
|
194
|
+
const bad = await putAttrs(LEGACY_ALIAS, { base_attrs_revision: cur, team: { not: "a string" } });
|
|
195
|
+
expect(bad.status).toBe(400);
|
|
196
|
+
expect(bad.json.ok).toBe(false);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe("node display attrs — auth + scope", () => {
|
|
201
|
+
test("no token → 401, and the row is untouched", async () => {
|
|
202
|
+
const before = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
|
|
203
|
+
const res = await fetch(`${BASE}/api/nodes/${LEGACY_ALIAS}/attrs?network_id=${NET}`, {
|
|
204
|
+
method: "PUT",
|
|
205
|
+
headers: { "Content-Type": "application/json" },
|
|
206
|
+
body: JSON.stringify({ base_attrs_revision: before.attrs_revision, team: "hijack" }),
|
|
207
|
+
});
|
|
208
|
+
expect(res.status).toBe(401);
|
|
209
|
+
const after = (await listNodes()).find((r) => r.node_id === LEGACY_NODE)!;
|
|
210
|
+
expect(after.team).toBe(before.team);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("unknown node ref → 404", async () => {
|
|
214
|
+
const r = await putAttrs("no-such-node-ref", { base_attrs_revision: 0, team: "x" });
|
|
215
|
+
expect(r.status).toBe(404);
|
|
216
|
+
});
|
|
217
|
+
});
|