@sleep2agi/commhub-server 0.9.0-preview.143 → 0.9.0-preview.145
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agent-department-http.test.ts +16 -0
- package/src/db.ts +15 -0
- package/src/node-lifecycle-read-http.test.ts +5 -0
- package/src/node-lifecycle-read.ts +3 -0
- package/src/node-session-control-http.test.ts +308 -0
- package/src/node-session-control.ts +254 -0
- package/src/requirements-people.ts +9 -2
- package/src/server.ts +145 -1
- package/src/user-avatar-image.ts +153 -0
- package/src/user-avatar-upload-http.test.ts +294 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
3
|
+
"version": "0.9.0-preview.145",
|
|
4
4
|
"description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -145,4 +145,20 @@ describe("Agent 显式归属部门", () => {
|
|
|
145
145
|
const agents2 = [...(dir2.body.people ?? []).flatMap((p: any) => p.agents ?? []), ...(dir2.body.agents_without_owner ?? [])];
|
|
146
146
|
expect(agents2.find((a: any) => a.node_id === NODE_A)?.department_id ?? null).toBeNull();
|
|
147
147
|
});
|
|
148
|
+
|
|
149
|
+
// #1976 —— Agent 归属与人类部门是两个独立的结构。通讯录顶层要说明「这个 Hub 支持显式
|
|
150
|
+
// Agent 部门」,客户端据此把「行里没有 department_id」读成「没有显式归属」,
|
|
151
|
+
// 而不是从「负责人在哪个部门」推断出来。
|
|
152
|
+
test("通讯录声明支持显式 Agent 部门(与人类部门相互独立)", async () => {
|
|
153
|
+
const dir = await send(U.owner.token, "GET", `/api/requirements/people/directory?network_id=${NET}`);
|
|
154
|
+
expect(dir.status).toBe(200);
|
|
155
|
+
expect(dir.body.agent_department_supported).toBe(true);
|
|
156
|
+
// 行形状不变:没有显式归属的行**不带** department_id 这一格(旧客户端形状不变)。
|
|
157
|
+
const agents = [...(dir.body.people ?? []).flatMap((p: any) => p.agents ?? []), ...(dir.body.agents_without_owner ?? [])];
|
|
158
|
+
for (const a of agents) {
|
|
159
|
+
// 显式归属 ⇒ 带这一格且非空;没有显式归属 ⇒ **不带这一格**(旧客户端形状不变)。
|
|
160
|
+
if (a.department_id) expect(typeof a.department_id).toBe("string");
|
|
161
|
+
else expect(Object.prototype.hasOwnProperty.call(a, "department_id")).toBe(false);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
148
164
|
});
|
package/src/db.ts
CHANGED
|
@@ -322,6 +322,21 @@ db.exec(`
|
|
|
322
322
|
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
|
323
323
|
`);
|
|
324
324
|
|
|
325
|
+
// 账号头像上传(二进制 PNG,Hub 自己发 URL)。与 users.avatar_url 并存:
|
|
326
|
+
// avatar_url 仍可指向内建池/外链;这张表只存「上传到 Hub 本人的那张」。
|
|
327
|
+
// 单行/人:新版本覆盖旧版本,旧 URL 立刻 404(version 变了),不做历史留存。
|
|
328
|
+
db.exec(`
|
|
329
|
+
CREATE TABLE IF NOT EXISTS user_avatar_uploads (
|
|
330
|
+
user_id TEXT PRIMARY KEY,
|
|
331
|
+
version TEXT NOT NULL,
|
|
332
|
+
png BLOB NOT NULL,
|
|
333
|
+
bytes INTEGER NOT NULL,
|
|
334
|
+
width INTEGER NOT NULL,
|
|
335
|
+
height INTEGER NOT NULL,
|
|
336
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
337
|
+
);
|
|
338
|
+
`);
|
|
339
|
+
|
|
325
340
|
// Human profile avatar. Additive for existing databases; old binaries ignore it.
|
|
326
341
|
try {
|
|
327
342
|
db.exec("ALTER TABLE users ADD COLUMN avatar_url TEXT");
|
|
@@ -66,8 +66,13 @@ test("public errors redact free text for granted viewers on every projection", a
|
|
|
66
66
|
"adopt_codex_session_conflict", "adopt_codex_port_unavailable", "adopt_codex_stage_still_running",
|
|
67
67
|
"adopt_codex_identity_unproven",
|
|
68
68
|
"TUI session mismatch", "opencode_launch_health:TUI session mismatch", "opencode_tui_session_mismatch",
|
|
69
|
+
// #2012:daemon 明确拒绝的固定安全码,必须原样投影给用户,而不是退化成 lifecycle_error。
|
|
70
|
+
"copresence_adopt_v2",
|
|
71
|
+
// 负控:带后缀/路径/空白的形状仍必须清成 lifecycle_error(只精确放行这一串)。
|
|
72
|
+
"copresence_adopt_v2: /private/fixture", "copresence_adopt_v2x", " copresence_adopt_v2",
|
|
69
73
|
]) {
|
|
70
74
|
const publicCodes = ["adopt_start_timeout", "adopted_node_delete_unsupported", "adopt_codex_readopt_required", "adopt_stop_receipt_changed", "opencode_tui_session_mismatch",
|
|
75
|
+
"copresence_adopt_v2",
|
|
71
76
|
"adopt_codex_external_start_unproven", "adopt_codex_launch_failed", "adopt_codex_launch_timeout",
|
|
72
77
|
"adopt_codex_port_unproven", "adopt_codex_start_superseded", "adopt_codex_binding_generation_unproven",
|
|
73
78
|
"adopt_codex_start_evidence_missing", "adopt_codex_start_evidence_changed",
|
|
@@ -33,6 +33,9 @@ const publicLifecycleErrors = new Set([
|
|
|
33
33
|
"adopt_pane_process_mismatch", "adopt_socket_directory_unsafe", "adopt_socket_unsafe",
|
|
34
34
|
"adopt_pid_changed", "adopt_pid_invalid", "adopt_pidfile_unsafe",
|
|
35
35
|
"opencode_tui_session_mismatch",
|
|
36
|
+
// #2012 —— daemon 的 copresence_adopt_v2 拒绝是固定安全码(无路径/无异常文本),
|
|
37
|
+
// 不放行会让用户投影退化成 lifecycle_error,看不到「为什么被拒绝」。
|
|
38
|
+
"copresence_adopt_v2",
|
|
36
39
|
"adopt_proc_invalid", "adopt_proc_unreadable", "adopt_platform_unsupported",
|
|
37
40
|
"adopt_registry_conflict", "adopt_registry_entry_invalid", "adopt_registry_invalid",
|
|
38
41
|
"adopt_registry_unsafe", "adopt_config_env_invalid", "adopt_env_file_invalid",
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, setSystemTime, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const dir = mkdtempSync(join(tmpdir(), "anet-session-control-"));
|
|
7
|
+
process.env.COMMHUB_DB ||= join(dir, "hub.db");
|
|
8
|
+
const root = "/api/node-session-control";
|
|
9
|
+
let server: any, base: string, db: any, net: string, otherNet: string;
|
|
10
|
+
let owner: any, admin: any, member: any, outsider: any, ntok: any, peer: any;
|
|
11
|
+
let legacyOwner: any, legacyAdmin: any, legacyMember: any, legacyOutsider: any, legacyFull: any, legacyNetwork: any;
|
|
12
|
+
let binding = { session_id: "session_1", binding_id: "binding_1" }, serial = 0;
|
|
13
|
+
const nodeId = "n_session_control", peerId = "n_session_control_peer";
|
|
14
|
+
async function api(token: string, path: string, value?: unknown, method?: string) {
|
|
15
|
+
const res = await fetch(`${base}${path}`, { method: method ?? (value === undefined ? "GET" : "POST"),
|
|
16
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
17
|
+
...(value === undefined ? {} : { body: JSON.stringify(value) }) });
|
|
18
|
+
return { status: res.status, body: await res.json() as any };
|
|
19
|
+
}
|
|
20
|
+
const registerBridge = (v = binding, token = ntok.token!) => api(token, `${root}/bridge`,
|
|
21
|
+
{ ...v, expected: { node_id: nodeId, network_id: net } });
|
|
22
|
+
const enqueue = (operation = "background", extra = {}) => api(owner.token!, root,
|
|
23
|
+
{ network_id: net, node_id: nodeId, operation, expected: binding, ...extra });
|
|
24
|
+
const pull = (token = ntok.token!, v = binding) => api(token, `${root}/pull`, v);
|
|
25
|
+
const read = (request: string, token = owner.token!, network = net) => api(token, `${root}/${request}?network_id=${network}`);
|
|
26
|
+
const state = () => ({ ...binding, supported: true, active: true, can_request_background: true, observed_at: Date.now() });
|
|
27
|
+
const ack = (request: string, result: unknown, extra = {}) => api(ntok.token!, `${root}/${request}/ack`,
|
|
28
|
+
{ ...binding, status: "done", result, ...extra });
|
|
29
|
+
|
|
30
|
+
beforeAll(async () => {
|
|
31
|
+
({ db } = await import("./db.js"));
|
|
32
|
+
const { register, addNetworkMember, createNetworkTokenForNode, createToken } = await import("./auth.js");
|
|
33
|
+
const stamp = Date.now();
|
|
34
|
+
admin = register(`sc_admin_${stamp}`, "SessionControl-Test-1!");
|
|
35
|
+
owner = register(`sc_owner_${stamp}`, "SessionControl-Test-2!");
|
|
36
|
+
member = register(`sc_member_${stamp}`, "SessionControl-Test-3!");
|
|
37
|
+
outsider = register(`sc_other_${stamp}`, "SessionControl-Test-4!");
|
|
38
|
+
expect(admin.ok && owner.ok && member.ok && outsider.ok).toBe(true);
|
|
39
|
+
net = admin.network_id!; otherNet = outsider.network_id!;
|
|
40
|
+
for (const u of [owner, member]) expect(addNetworkMember(net, u.user.user_id, "member", admin.user.user_id, { agentAccess: "all" }).ok).toBe(true);
|
|
41
|
+
// Real atok hashes via the legacy issuer; only fixture rows are converted
|
|
42
|
+
// to the historical global scope=user shape. No prefix-based authority.
|
|
43
|
+
const legacyUser = (u: any) => {
|
|
44
|
+
const t = createToken(u.user.user_id, "sc-legacy-user");
|
|
45
|
+
expect(t.ok).toBe(true);
|
|
46
|
+
expect(t.token).toMatch(/^atok_/);
|
|
47
|
+
db.run("UPDATE api_tokens SET scope='user' WHERE token_id=?1", t.token_id);
|
|
48
|
+
return t;
|
|
49
|
+
};
|
|
50
|
+
legacyOwner = legacyUser(owner); legacyAdmin = legacyUser(admin);
|
|
51
|
+
legacyMember = legacyUser(member); legacyOutsider = legacyUser(outsider);
|
|
52
|
+
legacyFull = createToken(owner.user.user_id, "sc-legacy-global-full");
|
|
53
|
+
legacyNetwork = createToken(owner.user.user_id, "sc-legacy-network", net);
|
|
54
|
+
expect(legacyFull.ok && legacyNetwork.ok).toBe(true);
|
|
55
|
+
ntok = createNetworkTokenForNode(owner.user.user_id, net, "sc-target", nodeId);
|
|
56
|
+
peer = createNetworkTokenForNode(owner.user.user_id, net, "sc-peer", peerId);
|
|
57
|
+
expect(ntok.ok && peer.ok).toBe(true);
|
|
58
|
+
db.run("INSERT INTO sessions(resume_id,alias,node_id,network_id,status) VALUES (?1,?2,?3,?4,'idle')", ["r_sc_target", "sc-target", nodeId, net]);
|
|
59
|
+
const { bootServer } = await import("./server.js");
|
|
60
|
+
server = bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
61
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
62
|
+
}, 30_000);
|
|
63
|
+
beforeEach(async () => {
|
|
64
|
+
binding = { session_id: `session_${++serial}`, binding_id: `binding_${serial}` };
|
|
65
|
+
expect((await registerBridge()).status).toBe(200);
|
|
66
|
+
});
|
|
67
|
+
afterEach(() => setSystemTime());
|
|
68
|
+
afterAll(() => { server?.stop(true); rmSync(dir, { recursive: true, force: true }); });
|
|
69
|
+
|
|
70
|
+
describe("#2017 human-to-exact-Bridge HTTP relay", () => {
|
|
71
|
+
test("[2017 identity] missing or malformed expected is refused without fallback", async () => {
|
|
72
|
+
for (const expected of [undefined, {}, { node_id: nodeId }, { node_id: nodeId, network_id: net, token_id: ntok.token_id }]) {
|
|
73
|
+
const rejected = await api(ntok.token!, `${root}/bridge`, { ...binding, expected });
|
|
74
|
+
expect(rejected.status).toBe(400);
|
|
75
|
+
expect(rejected.body.error).toBe("session_control_request_invalid");
|
|
76
|
+
}
|
|
77
|
+
expect((await registerBridge()).status).toBe(200);
|
|
78
|
+
});
|
|
79
|
+
test("[2017 identity] A credential expected B cannot sweep, replace or retire the valid binding", async () => {
|
|
80
|
+
const made = await enqueue("state"), clock = Date.now();
|
|
81
|
+
const tokenBefore = db.get("SELECT token_id,bound_node_id,network_id FROM api_tokens WHERE token_id=?1", ntok.token_id);
|
|
82
|
+
const nodeBefore = db.get("SELECT node_id,network_id,owner_user_id FROM nodes WHERE node_id=?1", nodeId);
|
|
83
|
+
// Advancing beyond the request deadline exposes an accidental sweep before
|
|
84
|
+
// rejecting expected. Restore only the clock, never the authoritative queue.
|
|
85
|
+
setSystemTime(new Date(clock + 16_000));
|
|
86
|
+
for (const expected of [{ node_id: peerId, network_id: net }, { node_id: nodeId, network_id: otherNet }]) {
|
|
87
|
+
const rejected = await api(ntok.token!, `${root}/bridge`, {
|
|
88
|
+
session_id: "would_replace_session", binding_id: "would_retire_binding", expected,
|
|
89
|
+
});
|
|
90
|
+
expect(rejected.status).toBe(409);
|
|
91
|
+
expect(rejected.body.error).toBe("session_control_changed");
|
|
92
|
+
}
|
|
93
|
+
setSystemTime(new Date(clock));
|
|
94
|
+
expect((await read(made.body.request_id)).body.status).toBe("pending");
|
|
95
|
+
expect((await registerBridge()).status).toBe(200); // Original generation was not retired.
|
|
96
|
+
expect((await pull()).body.request).toMatchObject({ request_id: made.body.request_id, ...binding });
|
|
97
|
+
expect(db.get("SELECT token_id,bound_node_id,network_id FROM api_tokens WHERE token_id=?1", ntok.token_id)).toEqual(tokenBefore);
|
|
98
|
+
expect(db.get("SELECT node_id,network_id,owner_user_id FROM nodes WHERE node_id=?1", nodeId)).toEqual(nodeBefore);
|
|
99
|
+
});
|
|
100
|
+
test("[2017 identity] success carries the authenticated token row identity, never credential material", async () => {
|
|
101
|
+
const response = await registerBridge();
|
|
102
|
+
const row = db.get("SELECT token_id,bound_node_id,network_id FROM api_tokens WHERE token_id=?1", ntok.token_id);
|
|
103
|
+
expect(response.status).toBe(200);
|
|
104
|
+
expect(response.body.identity).toEqual({ node_id: row.bound_node_id, network_id: row.network_id, token_id: row.token_id });
|
|
105
|
+
expect(Object.keys(response.body).sort()).toEqual(["expires_at", "identity", "ok"]);
|
|
106
|
+
expect(JSON.stringify(response.body)).not.toContain(ntok.token);
|
|
107
|
+
});
|
|
108
|
+
test("normal state and background receipts are separate from actual tool completion", async () => {
|
|
109
|
+
const s = await enqueue("state");
|
|
110
|
+
expect(s.body).toMatchObject({ ok: true, status: "pending" });
|
|
111
|
+
expect(s.body.request_id).toMatch(/^sc_\d{13}_[A-Za-z0-9_-]{8,64}$/);
|
|
112
|
+
expect((await pull()).body.request).toMatchObject({ request_id: s.body.request_id, operation: "state", ...binding });
|
|
113
|
+
const observed = state();
|
|
114
|
+
expect((await ack(s.body.request_id, observed)).body.status).toBe("done");
|
|
115
|
+
expect((await read(s.body.request_id)).body.result).toEqual(observed);
|
|
116
|
+
const b = await enqueue();
|
|
117
|
+
expect(b.body.request_id).toMatch(/^bg_\d{13}_[A-Za-z0-9_-]{8,64}$/);
|
|
118
|
+
expect(b.body.expires_at).toBeGreaterThan(Date.now());
|
|
119
|
+
await pull();
|
|
120
|
+
const result = { outcome: "request_processed", state: state() };
|
|
121
|
+
expect((await ack(b.body.request_id, result)).body.status).toBe("done");
|
|
122
|
+
expect((await read(b.body.request_id)).body).toMatchObject({ ok: true, status: "done", result });
|
|
123
|
+
});
|
|
124
|
+
test("[2017 review] already_idle cannot confirm an active or background-capable state", async () => {
|
|
125
|
+
for (const flags of [
|
|
126
|
+
{ active: true, can_request_background: true },
|
|
127
|
+
{ active: true, can_request_background: false },
|
|
128
|
+
{ active: false, can_request_background: true },
|
|
129
|
+
]) {
|
|
130
|
+
const made = await enqueue(); await pull();
|
|
131
|
+
const reply = await ack(made.body.request_id, { outcome: "already_idle", state: { ...state(), ...flags } });
|
|
132
|
+
expect(reply.status).toBe(400);
|
|
133
|
+
expect(reply.body.error).toBe("session_control_invalid_response");
|
|
134
|
+
expect((await read(made.body.request_id)).body).toMatchObject({ status: "unknown", error: "session_control_invalid_response" });
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
test("[2017 review] legacy user atok owner/admin and global full can create and read control", async () => {
|
|
138
|
+
for (const t of [legacyOwner, legacyAdmin, legacyFull]) {
|
|
139
|
+
for (const operation of ["state", "background"]) {
|
|
140
|
+
const made = await api(t.token, root, { network_id: net, node_id: nodeId, operation, expected: binding });
|
|
141
|
+
expect(made.status).toBe(200);
|
|
142
|
+
expect((await read(made.body.request_id, t.token)).body.status).toBe("pending");
|
|
143
|
+
expect((await pull()).body.request.request_id).toBe(made.body.request_id);
|
|
144
|
+
const result = operation === "state" ? state() : { outcome: "request_processed", state: state() };
|
|
145
|
+
expect((await ack(made.body.request_id, result)).body.status).toBe("done");
|
|
146
|
+
expect((await read(made.body.request_id, t.token)).body.result).toEqual(result);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
test("[2017 review] legacy atok still requires current target ownership and exact network", async () => {
|
|
151
|
+
for (const t of [legacyMember, legacyOutsider]) {
|
|
152
|
+
expect((await api(t.token, root, { network_id: net, node_id: nodeId, operation: "state" })).status).toBe(403);
|
|
153
|
+
}
|
|
154
|
+
expect((await api(legacyOwner.token, root, { network_id: otherNet, node_id: nodeId, operation: "state" })).status).toBe(403);
|
|
155
|
+
const made = await enqueue("state");
|
|
156
|
+
for (const t of [legacyMember, legacyOutsider]) expect((await read(made.body.request_id, t.token)).status).toBe(403);
|
|
157
|
+
expect((await read(made.body.request_id, legacyOwner.token, otherNet)).status).toBe(403);
|
|
158
|
+
});
|
|
159
|
+
test("[2017 review] legacy atok user and unbound network credentials cannot impersonate Bridge", async () => {
|
|
160
|
+
const made = await enqueue("state");
|
|
161
|
+
for (const t of [legacyOwner, legacyAdmin, legacyFull, legacyNetwork]) {
|
|
162
|
+
expect((await registerBridge(binding, t.token)).status).toBe(403);
|
|
163
|
+
expect((await pull(t.token)).status).toBe(403);
|
|
164
|
+
expect((await api(t.token, `${root}/${made.body.request_id}/ack`, { ...binding, status: "done", result: state() })).status).toBe(403);
|
|
165
|
+
}
|
|
166
|
+
expect((await read(made.body.request_id)).body.status).toBe("pending");
|
|
167
|
+
expect((await pull()).body.request.request_id).toBe(made.body.request_id);
|
|
168
|
+
});
|
|
169
|
+
test("[2017 review] unknown, revoked, expired or unsupported-scope credentials stay refused", async () => {
|
|
170
|
+
const request = () => api(legacyOwner.token, root, { network_id: net, node_id: nodeId, operation: "state" });
|
|
171
|
+
expect((await api("atok_00000000000000000000000000000000", root, {})).status).toBe(401);
|
|
172
|
+
for (const patch of ["revoked_at=datetime('now')", "expires_at=datetime('now','-1 day')", "scope='unsupported'"]) {
|
|
173
|
+
db.run(`UPDATE api_tokens SET ${patch} WHERE token_id=?1`, legacyOwner.token_id);
|
|
174
|
+
try { expect((await request()).status).toBeGreaterThanOrEqual(400); }
|
|
175
|
+
finally { db.run("UPDATE api_tokens SET revoked_at=NULL,expires_at=NULL,scope='user' WHERE token_id=?1", legacyOwner.token_id); }
|
|
176
|
+
}
|
|
177
|
+
expect((await request()).status).toBe(200);
|
|
178
|
+
});
|
|
179
|
+
test("SSE is a scoped doorbell only; the request lives in pull", async () => {
|
|
180
|
+
const abort = new AbortController();
|
|
181
|
+
const response = await fetch(`${base}/events/sc-target`, { headers: { authorization: `Bearer ${ntok.token}` }, signal: abort.signal });
|
|
182
|
+
expect(response.status).toBe(200);
|
|
183
|
+
const reader = response.body!.getReader();
|
|
184
|
+
try {
|
|
185
|
+
const made = await enqueue();
|
|
186
|
+
let data = "";
|
|
187
|
+
for (let i = 0; i < 4 && !data.includes('"type":"session_control"'); i++) {
|
|
188
|
+
const next = await reader.read(); data += new TextDecoder().decode(next.value);
|
|
189
|
+
}
|
|
190
|
+
const event = data.split("\n").filter(s => s.startsWith("data: ")).map(s => JSON.parse(s.slice(6))).find(s => s.type === "session_control");
|
|
191
|
+
expect(event).toEqual({ type: "session_control", request_id: made.body.request_id });
|
|
192
|
+
expect((await pull()).body.request.request_id).toBe(made.body.request_id);
|
|
193
|
+
} finally { abort.abort(); await reader.cancel().catch(() => {}); }
|
|
194
|
+
});
|
|
195
|
+
test("node, peer, anonymous, master and member cannot impersonate human owner/admin", async () => {
|
|
196
|
+
for (const token of [ntok.token, peer.token, "", "legacy-test-only", member.token, outsider.token]) {
|
|
197
|
+
expect((await api(token, root, { network_id: net, node_id: nodeId, operation: "state" })).status).toBeGreaterThanOrEqual(400);
|
|
198
|
+
}
|
|
199
|
+
const adminMade = await api(admin.token!, root, { network_id: net, node_id: nodeId, operation: "state" });
|
|
200
|
+
expect(adminMade.status).toBe(200);
|
|
201
|
+
expect((await read(adminMade.body.request_id, member.token!)).status).toBe(403);
|
|
202
|
+
expect((await read(adminMade.body.request_id, ntok.token!)).status).toBe(403);
|
|
203
|
+
expect((await read(adminMade.body.request_id, owner.token!, otherNet)).status).toBe(403);
|
|
204
|
+
});
|
|
205
|
+
test("cross-network, wrong expected, absent expected and command/path keys are refused", async () => {
|
|
206
|
+
expect((await enqueue("background", { network_id: otherNet })).status).toBe(403);
|
|
207
|
+
expect((await enqueue("background", { expected: { ...binding, session_id: "other_session" } })).body.error).toBe("session_control_changed");
|
|
208
|
+
expect((await api(owner.token!, root, { network_id: net, node_id: nodeId, operation: "background" })).status).toBe(400);
|
|
209
|
+
for (const key of ["url", "path", "keys", "shell"]) expect((await enqueue("state", { [key]: "unsafe" })).status).toBe(400);
|
|
210
|
+
expect((await registerBridge({ ...binding, node_id: peerId } as any)).status).toBe(400);
|
|
211
|
+
});
|
|
212
|
+
test("peer token and legacy unbound token never receive target requests", async () => {
|
|
213
|
+
const { createNetworkTokenForNode } = await import("./auth.js");
|
|
214
|
+
const legacy = createNetworkTokenForNode(owner.user.user_id, net, "sc-unbound");
|
|
215
|
+
expect(legacy.ok).toBe(true);
|
|
216
|
+
// Same-holder refreshes now bind existing rows. Construct an actual old
|
|
217
|
+
// unbound credential whose name only hints at this target instead.
|
|
218
|
+
db.run("UPDATE api_tokens SET name='node:sc-target' WHERE token_id=?1", legacy.token_id);
|
|
219
|
+
expect(db.get("SELECT bound_node_id FROM api_tokens WHERE token_id=?1", legacy.token_id).bound_node_id).toBeNull();
|
|
220
|
+
expect((await registerBridge(binding, legacy.token!)).status).toBe(403);
|
|
221
|
+
expect((await registerBridge(binding, owner.token!)).status).toBe(403);
|
|
222
|
+
const made = await enqueue();
|
|
223
|
+
expect((await pull(peer.token!)).body.request).toBeUndefined();
|
|
224
|
+
expect((await pull()).body.request.request_id).toBe(made.body.request_id);
|
|
225
|
+
expect((await api(peer.token!, `${root}/${made.body.request_id}/ack`, { ...binding, status: "done", result: state() })).status).toBe(409);
|
|
226
|
+
});
|
|
227
|
+
test("concurrent claims yield exactly one delivery and do not replay", async () => {
|
|
228
|
+
const made = await enqueue();
|
|
229
|
+
expect((await enqueue()).status).toBe(429);
|
|
230
|
+
const claims = await Promise.all([pull(), pull(), pull()]);
|
|
231
|
+
expect(claims.filter(r => r.body.request?.request_id === made.body.request_id)).toHaveLength(1);
|
|
232
|
+
expect((await read(made.body.request_id)).body.status).toBe("in_progress");
|
|
233
|
+
expect((await pull()).body.request).toBeNull();
|
|
234
|
+
});
|
|
235
|
+
test("expired pending request is never delivered", async () => {
|
|
236
|
+
const made = await enqueue();
|
|
237
|
+
setSystemTime(new Date(Date.now() + 16_000));
|
|
238
|
+
expect((await pull()).body.request).toBeNull();
|
|
239
|
+
expect((await read(made.body.request_id)).body.status).toBe("expired");
|
|
240
|
+
});
|
|
241
|
+
test("lost ACK becomes unknown, never success; late ACK and repeated pull cannot replay", async () => {
|
|
242
|
+
const made = await enqueue(); await pull();
|
|
243
|
+
setSystemTime(new Date(Date.now() + 16_000));
|
|
244
|
+
expect((await read(made.body.request_id)).body).toMatchObject({ status: "unknown", error: "session_background_outcome_unknown" });
|
|
245
|
+
expect((await ack(made.body.request_id, { outcome: "request_processed", state: state() })).status).toBe(409);
|
|
246
|
+
expect((await pull()).body.request).toBeNull();
|
|
247
|
+
});
|
|
248
|
+
test("new generation invalidates pending and cannot be overwritten by delayed old Bridge", async () => {
|
|
249
|
+
const old = { ...binding }, made = await enqueue();
|
|
250
|
+
binding = { session_id: "new_session", binding_id: `new_binding_${serial}` };
|
|
251
|
+
expect((await registerBridge()).status).toBe(200);
|
|
252
|
+
expect((await registerBridge(old)).body.error).toBe("session_control_changed");
|
|
253
|
+
expect((await pull(ntok.token!, old)).body.error).toBe("session_control_changed");
|
|
254
|
+
expect((await read(made.body.request_id)).body).toMatchObject({ status: "failed", error: "session_control_changed" });
|
|
255
|
+
expect((await pull()).body.request).toBeNull();
|
|
256
|
+
});
|
|
257
|
+
test("handle change after claim is unknown and old-session ACK cannot complete", async () => {
|
|
258
|
+
const old = { ...binding }, made = await enqueue(); await pull();
|
|
259
|
+
binding = { ...binding, binding_id: `changed_${serial}` }; await registerBridge();
|
|
260
|
+
expect((await api(ntok.token!, `${root}/${made.body.request_id}/ack`, { ...old, status: "done", result: { outcome: "already_idle", state: { ...state(), ...old } } })).status).toBe(409);
|
|
261
|
+
expect((await read(made.body.request_id)).body.status).toBe("unknown");
|
|
262
|
+
});
|
|
263
|
+
test("requester revocation before pull prevents delivery and current read permission is rechecked", async () => {
|
|
264
|
+
const made = await enqueue();
|
|
265
|
+
db.run("UPDATE network_members SET role='member' WHERE network_id=?1 AND user_id=?2", [net, owner.user.user_id]);
|
|
266
|
+
db.run("UPDATE nodes SET owner_user_id=?1 WHERE node_id=?2", [member.user.user_id, nodeId]);
|
|
267
|
+
try {
|
|
268
|
+
expect((await pull()).body.request).toBeNull();
|
|
269
|
+
expect((await read(made.body.request_id)).status).toBe(403);
|
|
270
|
+
expect((await read(made.body.request_id, admin.token!)).body.status).toBe("failed");
|
|
271
|
+
} finally { db.run("UPDATE nodes SET owner_user_id=?1 WHERE node_id=?2", [owner.user.user_id, nodeId]); }
|
|
272
|
+
});
|
|
273
|
+
test("target token revocation is checked at pull, not only registration", async () => {
|
|
274
|
+
const made = await enqueue();
|
|
275
|
+
db.run("UPDATE api_tokens SET revoked_at=datetime('now') WHERE token_id=?1", ntok.token_id);
|
|
276
|
+
try {
|
|
277
|
+
expect((await pull()).status).toBeGreaterThanOrEqual(400);
|
|
278
|
+
expect((await read(made.body.request_id)).body.status).toBe("failed");
|
|
279
|
+
} finally { db.run("UPDATE api_tokens SET revoked_at=NULL WHERE token_id=?1", ntok.token_id); }
|
|
280
|
+
});
|
|
281
|
+
test("malformed runtime results and raw exception strings never become success or leak", async () => {
|
|
282
|
+
const made = await enqueue(); await pull();
|
|
283
|
+
const reply = await ack(made.body.request_id, { outcome: "request_processed", state: { ...state(), binding_id: "wrong" } });
|
|
284
|
+
expect(reply.status).toBe(400);
|
|
285
|
+
expect((await read(made.body.request_id)).body).toMatchObject({ status: "unknown", error: "session_control_invalid_response" });
|
|
286
|
+
const next = await enqueue(); await pull();
|
|
287
|
+
const secret = "http://secret-host/?token=ntok_secret";
|
|
288
|
+
expect((await api(ntok.token!, `${root}/${next.body.request_id}/ack`, { ...binding, status: "failed", error: secret })).body.error).toBe("session_control_invalid_response");
|
|
289
|
+
expect(JSON.stringify((await read(next.body.request_id)).body)).not.toContain(secret);
|
|
290
|
+
});
|
|
291
|
+
test("known runtime refusal is failed; already_idle receipt is accepted", async () => {
|
|
292
|
+
const made = await enqueue(); await pull();
|
|
293
|
+
expect((await api(ntok.token!, `${root}/${made.body.request_id}/ack`, { ...binding, status: "failed", error: "session_control_unsupported" })).body.status).toBe("failed");
|
|
294
|
+
const next = await enqueue(); await pull();
|
|
295
|
+
expect((await ack(next.body.request_id, { outcome: "already_idle", state: { ...state(), active: false, can_request_background: false } })).body.status).toBe("done");
|
|
296
|
+
});
|
|
297
|
+
test("lease expiry refuses new work and Hub restart loses IDs without replay", async () => {
|
|
298
|
+
const made = await enqueue();
|
|
299
|
+
setSystemTime(new Date(Date.now() + 76_000));
|
|
300
|
+
expect((await enqueue()).body.error).toBe("session_control_unavailable");
|
|
301
|
+
setSystemTime();
|
|
302
|
+
server.stop(true);
|
|
303
|
+
const { bootServer } = await import("./server.js");
|
|
304
|
+
server = bootServer({ port: 0, hostname: "127.0.0.1" }); base = `http://127.0.0.1:${server.port}`;
|
|
305
|
+
expect((await read(made.body.request_id)).status).toBe(404);
|
|
306
|
+
expect((await pull()).body.error).toBe("session_control_changed");
|
|
307
|
+
});
|
|
308
|
+
});
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { db } from "./db.js";
|
|
3
|
+
import { getUserNetworkRole, resolveToken, sessionIdleDays } from "./auth.js";
|
|
4
|
+
import { isAgentRestricted } from "./agent-access.js";
|
|
5
|
+
import { resolveNodeCaller } from "./create-node.js";
|
|
6
|
+
import { pushEvent } from "./push.js";
|
|
7
|
+
|
|
8
|
+
type Identity = { session_id: string; binding_id: string };
|
|
9
|
+
type Credential = { token_id: string; user_id: string; network_id: string | null;
|
|
10
|
+
bound_node_id: string | null; scope: string; role: string };
|
|
11
|
+
type Node = { node_id: string; network_id: string; owner_user_id: string | null; alias: string };
|
|
12
|
+
type Bridge = Identity & { tokenId: string; expires: number; retired: Set<string> };
|
|
13
|
+
type Entry = Identity & { request_id: string; operation: "state" | "background";
|
|
14
|
+
networkId: string; nodeId: string; tokenId: string; requester: string; expires_at: number;
|
|
15
|
+
status: "pending" | "in_progress" | "done" | "failed" | "unknown" | "expired";
|
|
16
|
+
result?: unknown; error?: string };
|
|
17
|
+
const TTL = 15_000, LEASE = 75_000, CAP = 1024, HISTORY = 64;
|
|
18
|
+
const ROOT = "/api/node-session-control";
|
|
19
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,199}$/;
|
|
20
|
+
const ERRORS = new Set(["unavailable", "changed", "unsupported", "invalid_response", "request_invalid",
|
|
21
|
+
"request_expired", "request_limit"].map(s => `session_control_${s}`).concat("session_background_outcome_unknown"));
|
|
22
|
+
const keys = (o: Record<string, unknown>, allowed: string[]) => Object.keys(o).every(k => allowed.includes(k));
|
|
23
|
+
const object = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
|
|
24
|
+
const id = (v: unknown): v is string => typeof v === "string" && ID.test(v);
|
|
25
|
+
const identity = (v: unknown): v is Identity => object(v) && keys(v, ["session_id", "binding_id"])
|
|
26
|
+
&& id(v.session_id) && id(v.binding_id);
|
|
27
|
+
const same = (a: Identity, b: Identity) => a.session_id === b.session_id && a.binding_id === b.binding_id;
|
|
28
|
+
const generation = (token: string, v: Identity) => JSON.stringify([token, v.session_id, v.binding_id]);
|
|
29
|
+
const fail = (error: string, status = 409) => Response.json({ ok: false, error }, { status });
|
|
30
|
+
const ok = (value: object) => Response.json({ ok: true, ...value });
|
|
31
|
+
|
|
32
|
+
// Recheck stored identities without storing plaintext bearer tokens. The SQL
|
|
33
|
+
// mirrors resolveToken's expiry/revoke/idle rules; membership is checked below.
|
|
34
|
+
function credential(tokenId: string): Credential | null {
|
|
35
|
+
const days = sessionIdleDays();
|
|
36
|
+
return db.get<Credential>(`SELECT t.token_id,t.user_id,t.network_id,t.bound_node_id,t.scope,u.role
|
|
37
|
+
FROM api_tokens t JOIN users u ON u.user_id=t.user_id WHERE t.token_id=?1
|
|
38
|
+
AND t.revoked_at IS NULL AND (t.expires_at IS NULL OR t.expires_at>datetime('now'))
|
|
39
|
+
AND NOT (t.scope='user' AND t.network_id IS NULL AND ?2>0
|
|
40
|
+
AND COALESCE(t.last_used_at,t.created_at)<datetime('now',?3))`,
|
|
41
|
+
tokenId, days, `-${Math.round(days * 86400)} seconds`) ?? null;
|
|
42
|
+
}
|
|
43
|
+
function node(nodeId: string): Node | null {
|
|
44
|
+
return db.get<Node>("SELECT node_id,network_id,owner_user_id,alias FROM nodes WHERE node_id=?1", nodeId) ?? null;
|
|
45
|
+
}
|
|
46
|
+
function isHumanCredential(c: Credential | null): c is Credential {
|
|
47
|
+
return !!c && c.network_id === null && c.bound_node_id === null
|
|
48
|
+
&& (c.scope === "user" || c.scope === "full");
|
|
49
|
+
}
|
|
50
|
+
function human(tokenId: string, n: Node): boolean {
|
|
51
|
+
const c = credential(tokenId);
|
|
52
|
+
if (!isHumanCredential(c) || !n.network_id) return false;
|
|
53
|
+
const role = getUserNetworkRole(c.user_id, n.network_id);
|
|
54
|
+
return !!role && role !== "viewer" && !isAgentRestricted(c.user_id, n.network_id)
|
|
55
|
+
&& (c.user_id === n.owner_user_id || role === "owner" || role === "admin" || c.role === "admin");
|
|
56
|
+
}
|
|
57
|
+
function bound(tokenId: string, n?: Node | null): Node | null {
|
|
58
|
+
const c = credential(tokenId);
|
|
59
|
+
if (!c?.bound_node_id || !c.network_id || c.scope !== "network") return null;
|
|
60
|
+
const target = n ?? node(c.bound_node_id);
|
|
61
|
+
if (!target || c.bound_node_id !== target.node_id || c.network_id !== target.network_id
|
|
62
|
+
|| !getUserNetworkRole(c.user_id, c.network_id) || isAgentRestricted(c.user_id, c.network_id)) return null;
|
|
63
|
+
const caller = resolveNodeCaller(tokenId);
|
|
64
|
+
return caller.ok && caller.kind === "node" && caller.nodeId === target.node_id
|
|
65
|
+
&& caller.networkId === target.network_id ? target : null;
|
|
66
|
+
}
|
|
67
|
+
async function body(req: Request): Promise<Record<string, unknown> | null> {
|
|
68
|
+
const reader = req.body?.getReader();
|
|
69
|
+
if (!reader) return null;
|
|
70
|
+
const chunks: Uint8Array[] = []; let size = 0;
|
|
71
|
+
try {
|
|
72
|
+
for (;;) {
|
|
73
|
+
const part = await reader.read(); if (part.done) break;
|
|
74
|
+
size += part.value.byteLength;
|
|
75
|
+
if (size > 8192) { await reader.cancel(); return null; }
|
|
76
|
+
chunks.push(part.value);
|
|
77
|
+
}
|
|
78
|
+
const v: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
79
|
+
return object(v) ? v : null;
|
|
80
|
+
} catch { return null; }
|
|
81
|
+
}
|
|
82
|
+
function state(value: unknown, e: Entry): boolean {
|
|
83
|
+
return object(value) && keys(value, ["session_id", "binding_id", "supported", "active", "can_request_background", "observed_at"])
|
|
84
|
+
&& same(value as Identity, e) && typeof value.supported === "boolean" && typeof value.active === "boolean"
|
|
85
|
+
&& typeof value.can_request_background === "boolean" && typeof value.observed_at === "number"
|
|
86
|
+
&& Number.isFinite(value.observed_at) && value.observed_at > 0
|
|
87
|
+
&& (!value.can_request_background || (value.supported && value.active));
|
|
88
|
+
}
|
|
89
|
+
function validResult(v: unknown, e: Entry): boolean {
|
|
90
|
+
if (e.operation === "state") return state(v, e);
|
|
91
|
+
return object(v) && keys(v, ["outcome", "state"])
|
|
92
|
+
&& (v.outcome === "request_processed" || v.outcome === "already_idle") && state(v.state, e)
|
|
93
|
+
&& (v.outcome !== "already_idle" || (object(v.state)
|
|
94
|
+
&& v.state.active === false && v.state.can_request_background === false));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** One finite, process-local relay per Hub boot. Restart loses requests (404),
|
|
98
|
+
* never replays them. Multi-worker deployments require process affinity.
|
|
99
|
+
* A claim authorizes one delivery, not proof that any tool moved to background.
|
|
100
|
+
*/
|
|
101
|
+
export function createNodeSessionControlRelay() {
|
|
102
|
+
const bridges = new Map<string, Bridge>();
|
|
103
|
+
const entries = new Map<string, Entry>();
|
|
104
|
+
const key = (n: Node) => JSON.stringify([n.network_id, n.node_id]);
|
|
105
|
+
function close(e: Entry, error: string) {
|
|
106
|
+
e.status = e.status === "in_progress" ? "unknown" : "failed";
|
|
107
|
+
e.error = e.status === "unknown" ? "session_background_outcome_unknown" : error;
|
|
108
|
+
}
|
|
109
|
+
function refresh(e: Entry, now: number) {
|
|
110
|
+
if (e.status !== "pending" && e.status !== "in_progress") return;
|
|
111
|
+
if (now >= e.expires_at) {
|
|
112
|
+
e.status = e.status === "in_progress" ? "unknown" : "expired";
|
|
113
|
+
e.error = e.status === "unknown" ? "session_background_outcome_unknown" : "session_control_request_expired";
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const n = node(e.nodeId), b = n && bridges.get(key(n));
|
|
117
|
+
if (!n || n.network_id !== e.networkId || !human(e.requester, n) || !bound(e.tokenId, n)) {
|
|
118
|
+
close(e, "session_control_unavailable");
|
|
119
|
+
} else if (!b || b.expires <= now || b.tokenId !== e.tokenId || !same(b, e)) {
|
|
120
|
+
close(e, "session_control_changed");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function sweep(now: number) {
|
|
124
|
+
for (const [k, e] of entries) {
|
|
125
|
+
refresh(e, now);
|
|
126
|
+
if (now > e.expires_at + 60_000) entries.delete(k);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return async function handle(req: Request, url: URL): Promise<Response | null> {
|
|
130
|
+
if (url.pathname !== ROOT && !url.pathname.startsWith(`${ROOT}/`)) return null;
|
|
131
|
+
try {
|
|
132
|
+
// Header-only, persisted credential authority; no master/dev-open fallback.
|
|
133
|
+
const raw = req.headers.get("authorization")?.match(/^Bearer (\S+)$/)?.[1] ?? "";
|
|
134
|
+
const auth = resolveToken(raw);
|
|
135
|
+
if (!auth?.tokenId) return fail("session_control_unavailable", 401);
|
|
136
|
+
const tokenId = auth.tokenId;
|
|
137
|
+
const c = credential(tokenId);
|
|
138
|
+
if (!c) return fail("session_control_unavailable", 401);
|
|
139
|
+
const isHuman = isHumanCredential(c);
|
|
140
|
+
const isNode = c.scope === "network" && !!c.network_id;
|
|
141
|
+
const suffix = url.pathname.slice(ROOT.length);
|
|
142
|
+
if (req.method === "GET" && /^\/(bg|sc)_\d{13}_[A-Za-z0-9_-]{8,64}$/.test(suffix)) {
|
|
143
|
+
const e = entries.get(suffix.slice(1));
|
|
144
|
+
// Unknown/restarted Hub and unauthorized reads reveal no request data.
|
|
145
|
+
if (!isHuman) return fail("session_control_unavailable", 403);
|
|
146
|
+
if (!e) return fail("session_control_unavailable", 404);
|
|
147
|
+
const n = node(e.nodeId);
|
|
148
|
+
if (!n || url.searchParams.get("network_id") !== e.networkId || n.network_id !== e.networkId || !human(tokenId, n)) {
|
|
149
|
+
return fail("session_control_unavailable", 403);
|
|
150
|
+
}
|
|
151
|
+
refresh(e, Date.now());
|
|
152
|
+
return ok({ request_id: e.request_id, status: e.status,
|
|
153
|
+
...(e.result === undefined ? {} : { result: e.result }), ...(e.error ? { error: e.error } : {}) });
|
|
154
|
+
}
|
|
155
|
+
if (req.method !== "POST") return fail("session_control_request_invalid", 405);
|
|
156
|
+
const v = await body(req);
|
|
157
|
+
if (!v) return fail("session_control_request_invalid", 400);
|
|
158
|
+
const now = Date.now();
|
|
159
|
+
if (suffix === "/bridge") {
|
|
160
|
+
if (!isNode) return fail("session_control_unavailable", 403);
|
|
161
|
+
if (!keys(v, ["session_id", "binding_id", "expected"]) || !id(v.session_id) || !id(v.binding_id)
|
|
162
|
+
|| !object(v.expected) || !keys(v.expected, ["node_id", "network_id"])
|
|
163
|
+
|| !id(v.expected.node_id) || !id(v.expected.network_id)) return fail("session_control_request_invalid", 400);
|
|
164
|
+
// Body reading is complete. This synchronous section rechecks authority
|
|
165
|
+
// and expected identity BEFORE sweep, retirement, or registration writes.
|
|
166
|
+
// expected is an assertion, never a source for binding or proof fields.
|
|
167
|
+
const c = credential(tokenId), n = bound(tokenId);
|
|
168
|
+
if (!c || !n) return fail("session_control_unavailable", 403);
|
|
169
|
+
if (c.bound_node_id !== n.node_id || c.network_id !== n.network_id
|
|
170
|
+
|| v.expected.node_id !== c.bound_node_id || v.expected.network_id !== c.network_id) {
|
|
171
|
+
return fail("session_control_changed");
|
|
172
|
+
}
|
|
173
|
+
const registration = { session_id: v.session_id, binding_id: v.binding_id };
|
|
174
|
+
const k = key(n), b = bridges.get(k), g = generation(tokenId, registration);
|
|
175
|
+
if (b?.retired.has(g)) return fail("session_control_changed");
|
|
176
|
+
const replacing = b && (b.tokenId !== tokenId || !same(b, registration));
|
|
177
|
+
if ((replacing && b.retired.size >= HISTORY) || (!b && bridges.size >= CAP)) {
|
|
178
|
+
return fail("session_control_request_limit", 429);
|
|
179
|
+
}
|
|
180
|
+
sweep(now);
|
|
181
|
+
if (replacing) {
|
|
182
|
+
b.retired.add(generation(b.tokenId, b));
|
|
183
|
+
for (const e of entries.values()) if (e.nodeId === n.node_id && e.networkId === n.network_id
|
|
184
|
+
&& (e.status === "pending" || e.status === "in_progress")) close(e, "session_control_changed");
|
|
185
|
+
}
|
|
186
|
+
bridges.set(k, { ...registration, tokenId, expires: now + LEASE, retired: b?.retired ?? new Set() });
|
|
187
|
+
return ok({ expires_at: now + LEASE,
|
|
188
|
+
identity: { node_id: c.bound_node_id, network_id: c.network_id, token_id: c.token_id } });
|
|
189
|
+
}
|
|
190
|
+
sweep(now);
|
|
191
|
+
if (!suffix) {
|
|
192
|
+
if (!isHuman) return fail("session_control_unavailable", 403);
|
|
193
|
+
if (!keys(v, ["network_id", "node_id", "operation", "expected"]) || !id(v.network_id) || !id(v.node_id)
|
|
194
|
+
|| (v.operation !== "state" && v.operation !== "background")
|
|
195
|
+
|| (v.expected !== undefined && !identity(v.expected)) || (v.operation === "background" && !identity(v.expected))) {
|
|
196
|
+
return fail("session_control_request_invalid", 400);
|
|
197
|
+
}
|
|
198
|
+
const n = node(v.node_id);
|
|
199
|
+
if (!n || n.network_id !== v.network_id || !human(tokenId, n)) return fail("session_control_unavailable", 403);
|
|
200
|
+
const b = bridges.get(key(n));
|
|
201
|
+
if (!b || b.expires <= now || !bound(b.tokenId, n)) return fail("session_control_unavailable");
|
|
202
|
+
if (v.expected && !same(v.expected as Identity, b)) return fail("session_control_changed");
|
|
203
|
+
if (entries.size >= CAP || [...entries.values()].some(e => e.nodeId === n.node_id
|
|
204
|
+
&& e.networkId === n.network_id && (e.status === "pending" || e.status === "in_progress"))) {
|
|
205
|
+
return fail("session_control_request_limit", 429);
|
|
206
|
+
}
|
|
207
|
+
const request_id = `${v.operation === "background" ? "bg" : "sc"}_${now}_${randomBytes(18).toString("base64url")}`;
|
|
208
|
+
entries.set(request_id, { session_id: b.session_id, binding_id: b.binding_id, request_id,
|
|
209
|
+
operation: v.operation, networkId: n.network_id, nodeId: n.node_id, tokenId: b.tokenId,
|
|
210
|
+
requester: tokenId, expires_at: now + TTL, status: "pending" });
|
|
211
|
+
pushEvent(n.alias, { type: "session_control", request_id }, n.network_id);
|
|
212
|
+
return ok({ request_id, status: "pending", expires_at: now + TTL });
|
|
213
|
+
}
|
|
214
|
+
if (!isNode) return fail("session_control_unavailable", 403);
|
|
215
|
+
const n = bound(tokenId);
|
|
216
|
+
if (!n) return fail("session_control_unavailable", 403);
|
|
217
|
+
const k = key(n), b = bridges.get(k);
|
|
218
|
+
if (!b || b.expires <= now || b.tokenId !== tokenId) return fail("session_control_changed");
|
|
219
|
+
if (suffix === "/pull") {
|
|
220
|
+
if (!identity(v)) return fail("session_control_request_invalid", 400);
|
|
221
|
+
if (!same(b, v)) return fail("session_control_changed");
|
|
222
|
+
const e = [...entries.values()].find(e => e.nodeId === n.node_id && e.networkId === n.network_id && e.status === "pending");
|
|
223
|
+
if (!e) return ok({ request: null });
|
|
224
|
+
refresh(e, now);
|
|
225
|
+
if (e.status !== "pending") return ok({ request: null });
|
|
226
|
+
// No await between authority recheck, claim, and response. Never requeue.
|
|
227
|
+
e.status = "in_progress";
|
|
228
|
+
return ok({ request: { request_id: e.request_id, operation: e.operation,
|
|
229
|
+
session_id: e.session_id, binding_id: e.binding_id, expires_at: e.expires_at } });
|
|
230
|
+
}
|
|
231
|
+
if (/^\/(bg|sc)_\d{13}_[A-Za-z0-9_-]{8,64}\/ack$/.test(suffix)) {
|
|
232
|
+
if (!keys(v, ["session_id", "binding_id", "status", "result", "error"]) || !id(v.session_id) || !id(v.binding_id)
|
|
233
|
+
|| (v.status !== "done" && v.status !== "failed")) return fail("session_control_request_invalid", 400);
|
|
234
|
+
const e = entries.get(suffix.split("/")[1]);
|
|
235
|
+
if (!e) return fail("session_control_unavailable", 404);
|
|
236
|
+
if (e.nodeId !== n.node_id || e.networkId !== n.network_id || e.tokenId !== tokenId || !same(e, v as Identity)
|
|
237
|
+
|| !same(b, e)) return fail("session_control_changed");
|
|
238
|
+
refresh(e, now);
|
|
239
|
+
if (e.status !== "in_progress") return fail("session_control_request_expired");
|
|
240
|
+
if (v.status === "done" && validResult(v.result, e) && v.error === undefined) {
|
|
241
|
+
e.status = "done"; e.result = v.result;
|
|
242
|
+
} else if (v.status === "failed" && typeof v.error === "string" && ERRORS.has(v.error) && v.result === undefined) {
|
|
243
|
+
e.status = "failed"; e.error = v.error;
|
|
244
|
+
} else {
|
|
245
|
+
// Invalid/untrusted runtime replies never become success or leak raw errors.
|
|
246
|
+
e.status = "unknown"; e.error = "session_control_invalid_response";
|
|
247
|
+
return fail(e.error, 400);
|
|
248
|
+
}
|
|
249
|
+
return ok({ request_id: e.request_id, status: e.status });
|
|
250
|
+
}
|
|
251
|
+
return fail("session_control_request_invalid", 404);
|
|
252
|
+
} catch { return fail("session_control_unavailable", 503); }
|
|
253
|
+
};
|
|
254
|
+
}
|