@nordbyte/nordrelay 0.6.0 → 0.8.0
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/.env.example +52 -0
- package/README.md +171 -50
- package/dist/access-control.js +6 -1
- package/dist/activity-events.js +2 -2
- package/dist/adapter-conformance.js +61 -0
- package/dist/bot-preferences.js +1 -0
- package/dist/bot.js +95 -37
- package/dist/channel-adapter.js +44 -11
- package/dist/channel-command-catalog.js +94 -0
- package/dist/channel-command-core.js +60 -0
- package/dist/channel-command-service.js +230 -1
- package/dist/channel-mirror-registry.js +84 -0
- package/dist/channel-peer-prompt.js +95 -0
- package/dist/channel-prompt-engine.js +177 -0
- package/dist/channel-runtime.js +12 -5
- package/dist/channel-turn-lifecycle.js +73 -0
- package/dist/codex-state.js +114 -78
- package/dist/config-metadata.js +82 -8
- package/dist/config.js +79 -7
- package/dist/context-key.js +42 -0
- package/dist/discord-bot.js +173 -342
- package/dist/discord-command-surface.js +11 -73
- package/dist/index.js +29 -0
- package/dist/metrics.js +48 -0
- package/dist/peer-auth.js +85 -0
- package/dist/peer-client.js +288 -0
- package/dist/peer-context.js +21 -0
- package/dist/peer-identity.js +127 -0
- package/dist/peer-readiness.js +77 -0
- package/dist/peer-runtime-service.js +658 -0
- package/dist/peer-server.js +220 -0
- package/dist/peer-store.js +307 -0
- package/dist/peer-types.js +52 -0
- package/dist/relay-runtime-helpers.js +210 -0
- package/dist/relay-runtime.js +79 -274
- package/dist/remote-prompt.js +98 -0
- package/dist/settings-wizard-test.js +216 -0
- package/dist/slack-artifacts.js +165 -0
- package/dist/slack-bot.js +1461 -0
- package/dist/slack-channel-runtime.js +147 -0
- package/dist/slack-command-surface.js +46 -0
- package/dist/slack-diagnostics.js +116 -0
- package/dist/slack-rate-limit.js +139 -0
- package/dist/telegram-command-menu.js +3 -53
- package/dist/telegram-general-commands.js +14 -0
- package/dist/telegram-preference-commands.js +23 -127
- package/dist/user-management-crypto.js +38 -0
- package/dist/user-management-normalize.js +188 -0
- package/dist/user-management-types.js +1 -0
- package/dist/user-management.js +193 -196
- package/dist/web-api-contract.js +16 -0
- package/dist/web-dashboard-access-routes.js +62 -0
- package/dist/web-dashboard-assets.js +1 -0
- package/dist/web-dashboard-pages.js +26 -4
- package/dist/web-dashboard-peer-routes.js +225 -0
- package/dist/web-dashboard-ui.js +1 -0
- package/dist/web-dashboard.js +46 -0
- package/dist/web-state.js +2 -2
- package/dist/webui-assets/dashboard.css +193 -0
- package/dist/webui-assets/dashboard.js +870 -57
- package/package.json +5 -2
- package/plugins/nordrelay/scripts/nordrelay.mjs +468 -11
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { createServer as createHttpServer } from "node:http";
|
|
2
|
+
import { createServer as createHttpsServer } from "node:https";
|
|
3
|
+
import { URL } from "node:url";
|
|
4
|
+
import { friendlyErrorText } from "./error-messages.js";
|
|
5
|
+
import { createPairingSignaturePayload, createSharedSecret, ensurePeerTlsFiles, fingerprintForPublicKey, loadOrCreatePeerIdentity, verifyPeerPayload, } from "./peer-identity.js";
|
|
6
|
+
import { header, PeerNonceCache, verifyPeerRequest } from "./peer-auth.js";
|
|
7
|
+
import { peerRuntimeContextKey } from "./peer-context.js";
|
|
8
|
+
import { PeerStore } from "./peer-store.js";
|
|
9
|
+
import { PeerRuntimeService, peerError } from "./peer-runtime-service.js";
|
|
10
|
+
import { PEER_PROTOCOL_VERSION, } from "./peer-types.js";
|
|
11
|
+
import { RelayRuntime } from "./relay-runtime.js";
|
|
12
|
+
export async function startPeerServer(options) {
|
|
13
|
+
const { config, runtime } = options;
|
|
14
|
+
if (!config.peerEnabled) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
const home = options.home ?? process.env.NORDRELAY_HOME;
|
|
18
|
+
const identity = loadOrCreatePeerIdentity(home, config.peerName);
|
|
19
|
+
const store = new PeerStore(home);
|
|
20
|
+
const nonces = new PeerNonceCache();
|
|
21
|
+
const contextRuntimes = new Map();
|
|
22
|
+
const service = new PeerRuntimeService(config, runtime, {
|
|
23
|
+
runtimeForContext: (peer, sourceContextKey) => {
|
|
24
|
+
const contextKey = peerRuntimeContextKey(peer, sourceContextKey);
|
|
25
|
+
let scoped = contextRuntimes.get(contextKey);
|
|
26
|
+
if (!scoped) {
|
|
27
|
+
scoped = new RelayRuntime(config, {
|
|
28
|
+
contextKey,
|
|
29
|
+
registryFileName: "peer-contexts.json",
|
|
30
|
+
registrySqliteKey: "peer-contexts",
|
|
31
|
+
});
|
|
32
|
+
contextRuntimes.set(contextKey, scoped);
|
|
33
|
+
}
|
|
34
|
+
return scoped;
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
const useTls = config.peerTlsEnabled;
|
|
38
|
+
const tls = useTls ? ensurePeerTlsFiles(home, identity.public) : null;
|
|
39
|
+
if (!useTls && config.peerRequireTls && !isLoopbackHost(config.peerHost)) {
|
|
40
|
+
throw new Error("Peer server refuses plaintext on non-loopback hosts. Set NORDRELAY_PEER_TLS_ENABLED=true or bind to 127.0.0.1.");
|
|
41
|
+
}
|
|
42
|
+
const server = useTls
|
|
43
|
+
? createHttpsServer({ cert: tls.cert, key: tls.key }, handleRequest)
|
|
44
|
+
: createHttpServer(handleRequest);
|
|
45
|
+
await new Promise((resolve) => server.listen(config.peerPort, config.peerHost, resolve));
|
|
46
|
+
const scheme = useTls ? "https" : "http";
|
|
47
|
+
const host = config.peerHost === "0.0.0.0" || config.peerHost === "::" ? "127.0.0.1" : config.peerHost;
|
|
48
|
+
const displayHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
49
|
+
const address = server.address();
|
|
50
|
+
const actualPort = typeof address === "object" && address ? address.port : config.peerPort;
|
|
51
|
+
const url = config.peerPublicUrl || `${scheme}://${displayHost}:${actualPort}`;
|
|
52
|
+
console.log(`Peer server: ${url} (${useTls ? `TLS ${tls.fingerprint}` : "plaintext loopback"})`);
|
|
53
|
+
return {
|
|
54
|
+
url,
|
|
55
|
+
tlsFingerprint: tls?.fingerprint,
|
|
56
|
+
close: async () => {
|
|
57
|
+
await closeServer(server);
|
|
58
|
+
for (const scoped of contextRuntimes.values()) {
|
|
59
|
+
scoped.dispose();
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
async function handleRequest(req, res) {
|
|
64
|
+
try {
|
|
65
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
66
|
+
if (req.method === "GET" && url.pathname === "/peer/healthz") {
|
|
67
|
+
sendJson(res, 200, { ok: true, protocolVersion: PEER_PROTOCOL_VERSION });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (req.method === "GET" && url.pathname === "/peer/identity") {
|
|
71
|
+
sendJson(res, 200, {
|
|
72
|
+
protocolVersion: PEER_PROTOCOL_VERSION,
|
|
73
|
+
identity: identity.public,
|
|
74
|
+
tlsFingerprint: tls?.fingerprint,
|
|
75
|
+
});
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (req.method === "POST" && url.pathname === "/peer/pair") {
|
|
79
|
+
const bodyText = await readBody(req, 128 * 1024);
|
|
80
|
+
const body = parseJson(bodyText);
|
|
81
|
+
const response = handlePair(body);
|
|
82
|
+
sendJson(res, 201, response);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (req.method === "POST" && url.pathname === "/peer/rpc") {
|
|
86
|
+
const bodyText = await readBody(req, 64 * 1024 * 1024);
|
|
87
|
+
const peer = authenticate(req, "POST", "/peer/rpc", bodyText);
|
|
88
|
+
const body = parseJson(bodyText);
|
|
89
|
+
const data = await service.handle(peer, body);
|
|
90
|
+
const result = { ok: true, data };
|
|
91
|
+
sendJson(res, 200, result);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (req.method === "GET" && url.pathname === "/peer/events") {
|
|
95
|
+
const peer = authenticate(req, "GET", `${url.pathname}${url.search}`, "");
|
|
96
|
+
res.writeHead(200, {
|
|
97
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
98
|
+
"cache-control": "no-cache, no-transform",
|
|
99
|
+
connection: "keep-alive",
|
|
100
|
+
});
|
|
101
|
+
const sourceContextKey = url.searchParams.get("contextKey") || undefined;
|
|
102
|
+
const unsubscribe = service.subscribe(peer, sourceContextKey, (event) => {
|
|
103
|
+
if (res.destroyed || res.writableEnded)
|
|
104
|
+
return;
|
|
105
|
+
res.write(`event: ${event.type}\n`);
|
|
106
|
+
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
107
|
+
});
|
|
108
|
+
const heartbeat = setInterval(() => {
|
|
109
|
+
if (!res.destroyed && !res.writableEnded)
|
|
110
|
+
res.write(": heartbeat\n\n");
|
|
111
|
+
}, 25_000);
|
|
112
|
+
heartbeat.unref?.();
|
|
113
|
+
req.on("close", () => {
|
|
114
|
+
clearInterval(heartbeat);
|
|
115
|
+
unsubscribe();
|
|
116
|
+
});
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
sendJson(res, 404, { error: "not found" });
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
const status = isAuthError(error) ? 403 : 500;
|
|
123
|
+
sendJson(res, status, { error: friendlyErrorText(error) });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function handlePair(body) {
|
|
127
|
+
if (!body?.identity?.nodeId || !body.identity.publicKey || !body.code || !body.signature || !body.timestamp) {
|
|
128
|
+
throw new Error("Invalid peer pairing request.");
|
|
129
|
+
}
|
|
130
|
+
if (fingerprintForPublicKey(body.identity.publicKey) !== body.identity.fingerprint) {
|
|
131
|
+
throw new Error("Pairing identity fingerprint mismatch.");
|
|
132
|
+
}
|
|
133
|
+
if (body.identity.nodeId === identity.public.nodeId) {
|
|
134
|
+
throw new Error("Refusing to pair this NordRelay instance with itself.");
|
|
135
|
+
}
|
|
136
|
+
if (Math.abs(Date.now() - Date.parse(body.timestamp)) > 5 * 60 * 1000) {
|
|
137
|
+
throw new Error("Pairing request timestamp is outside the allowed clock skew.");
|
|
138
|
+
}
|
|
139
|
+
const signaturePayload = createPairingSignaturePayload(body.identity.nodeId, body.timestamp, body.code);
|
|
140
|
+
if (!verifyPeerPayload(body.identity.publicKey, signaturePayload, body.signature)) {
|
|
141
|
+
throw new Error("Invalid peer pairing signature.");
|
|
142
|
+
}
|
|
143
|
+
const invitation = store.consumeInvitation(body.code, body.identity.nodeId);
|
|
144
|
+
const secret = createSharedSecret();
|
|
145
|
+
const peer = store.upsertPeer({
|
|
146
|
+
name: body.name?.trim() || body.identity.name || invitation.name,
|
|
147
|
+
url: body.publicUrl,
|
|
148
|
+
nodeId: body.identity.nodeId,
|
|
149
|
+
publicKey: body.identity.publicKey,
|
|
150
|
+
fingerprint: body.identity.fingerprint,
|
|
151
|
+
secret,
|
|
152
|
+
enabled: true,
|
|
153
|
+
direction: body.publicUrl ? "bidirectional" : "inbound",
|
|
154
|
+
scopes: invitation.scopes,
|
|
155
|
+
allowedAgents: invitation.allowedAgents,
|
|
156
|
+
allowedWorkspaceRoots: invitation.allowedWorkspaceRoots,
|
|
157
|
+
workspaceAliases: invitation.workspaceAliases,
|
|
158
|
+
});
|
|
159
|
+
return {
|
|
160
|
+
protocolVersion: PEER_PROTOCOL_VERSION,
|
|
161
|
+
identity: identity.public,
|
|
162
|
+
peerId: peer.id,
|
|
163
|
+
secret,
|
|
164
|
+
scopes: peer.scopes,
|
|
165
|
+
allowedAgents: peer.allowedAgents,
|
|
166
|
+
allowedWorkspaceRoots: peer.allowedWorkspaceRoots,
|
|
167
|
+
workspaceAliases: peer.workspaceAliases,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
function authenticate(req, method, pathname, body) {
|
|
171
|
+
const peerId = header(req, "x-nordrelay-peer-id");
|
|
172
|
+
const peer = peerId ? store.get(peerId) : null;
|
|
173
|
+
if (!peer) {
|
|
174
|
+
throw new Error("Unknown peer.");
|
|
175
|
+
}
|
|
176
|
+
if (!peer.enabled) {
|
|
177
|
+
throw new Error("Peer is disabled.");
|
|
178
|
+
}
|
|
179
|
+
verifyPeerRequest({ req, peer, method, pathname, body, nonces });
|
|
180
|
+
store.markSeen(peer.id);
|
|
181
|
+
return peer;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
async function readBody(req, maxBytes) {
|
|
185
|
+
const chunks = [];
|
|
186
|
+
let size = 0;
|
|
187
|
+
for await (const chunk of req) {
|
|
188
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
189
|
+
size += buffer.length;
|
|
190
|
+
if (size > maxBytes) {
|
|
191
|
+
throw new Error("Peer request body is too large.");
|
|
192
|
+
}
|
|
193
|
+
chunks.push(buffer);
|
|
194
|
+
}
|
|
195
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
196
|
+
}
|
|
197
|
+
function parseJson(text) {
|
|
198
|
+
try {
|
|
199
|
+
return JSON.parse(text || "{}");
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
throw new Error("Invalid JSON body.");
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function sendJson(res, status, value) {
|
|
206
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
207
|
+
res.end(`${JSON.stringify(value)}\n`);
|
|
208
|
+
}
|
|
209
|
+
function isLoopbackHost(host) {
|
|
210
|
+
return host === "127.0.0.1" || host === "::1" || host === "localhost";
|
|
211
|
+
}
|
|
212
|
+
function isAuthError(error) {
|
|
213
|
+
const message = peerError(error).toLowerCase();
|
|
214
|
+
return /peer|signature|timestamp|replay|permission|denied|auth|disabled/.test(message);
|
|
215
|
+
}
|
|
216
|
+
function closeServer(server) {
|
|
217
|
+
return new Promise((resolve, reject) => {
|
|
218
|
+
server.close((error) => error ? reject(error) : resolve());
|
|
219
|
+
});
|
|
220
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { ALL_PERMISSIONS } from "./access-control.js";
|
|
6
|
+
import { AGENT_IDS, isAgentId } from "./agent.js";
|
|
7
|
+
import { readJsonFileWithBackup, writeJsonFileAtomic } from "./persistence.js";
|
|
8
|
+
import { DEFAULT_PEER_SCOPES, publicInvitation, publicPeer, } from "./peer-types.js";
|
|
9
|
+
const DEFAULT_HOME = path.join(os.homedir(), ".nordrelay");
|
|
10
|
+
const INVITE_CODE_BYTES = 18;
|
|
11
|
+
const MAX_INVITATION_TTL_MS = 24 * 60 * 60 * 1000;
|
|
12
|
+
export class PeerStore {
|
|
13
|
+
filePath;
|
|
14
|
+
constructor(home = process.env.NORDRELAY_HOME || DEFAULT_HOME) {
|
|
15
|
+
this.filePath = path.join(home, "peers.json");
|
|
16
|
+
}
|
|
17
|
+
snapshot(identity, options) {
|
|
18
|
+
const payload = this.readPayload();
|
|
19
|
+
return {
|
|
20
|
+
identity,
|
|
21
|
+
enabled: options.enabled,
|
|
22
|
+
listenUrl: options.listenUrl,
|
|
23
|
+
requireTls: options.requireTls,
|
|
24
|
+
readiness: options.readiness,
|
|
25
|
+
peers: payload.peers.map(publicPeer),
|
|
26
|
+
invitations: payload.invitations.map(publicInvitation),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
list() {
|
|
30
|
+
return this.readPayload().peers;
|
|
31
|
+
}
|
|
32
|
+
listPublic() {
|
|
33
|
+
return this.list().map(publicPeer);
|
|
34
|
+
}
|
|
35
|
+
get(id) {
|
|
36
|
+
return this.readPayload().peers.find((peer) => peer.id === id || peer.nodeId === id) ?? null;
|
|
37
|
+
}
|
|
38
|
+
createInvitation(options = {}) {
|
|
39
|
+
const code = randomBytes(INVITE_CODE_BYTES).toString("base64url");
|
|
40
|
+
const now = new Date();
|
|
41
|
+
const requestedTtl = options.expiresInMs ?? 10 * 60 * 1000;
|
|
42
|
+
const ttl = Math.max(1_000, Math.min(requestedTtl, MAX_INVITATION_TTL_MS));
|
|
43
|
+
const expiresAt = new Date(now.getTime() + ttl);
|
|
44
|
+
const invitation = {
|
|
45
|
+
id: randomUUID().replace(/-/g, "").slice(0, 12),
|
|
46
|
+
name: options.name?.trim() || "NordRelay peer",
|
|
47
|
+
codeHash: hashSecret(code),
|
|
48
|
+
createdAt: now.toISOString(),
|
|
49
|
+
expiresAt: expiresAt.toISOString(),
|
|
50
|
+
scopes: normalizeScopes(options.scopes ?? DEFAULT_PEER_SCOPES),
|
|
51
|
+
allowedAgents: normalizeAgents(options.allowedAgents ?? [...AGENT_IDS]),
|
|
52
|
+
allowedWorkspaceRoots: normalizeWorkspaceRoots(options.allowedWorkspaceRoots ?? []),
|
|
53
|
+
workspaceAliases: normalizeWorkspaceAliases(options.workspaceAliases ?? {}),
|
|
54
|
+
};
|
|
55
|
+
this.mutatePayload((payload) => {
|
|
56
|
+
payload.invitations = payload.invitations.filter((item) => !item.usedAt && Date.parse(item.expiresAt) > Date.now());
|
|
57
|
+
payload.invitations.push(invitation);
|
|
58
|
+
});
|
|
59
|
+
return { invitation: publicInvitation(invitation), code };
|
|
60
|
+
}
|
|
61
|
+
consumeInvitation(code, usedByNodeId) {
|
|
62
|
+
const trimmed = code.trim();
|
|
63
|
+
if (!trimmed) {
|
|
64
|
+
throw new Error("Pairing code is required.");
|
|
65
|
+
}
|
|
66
|
+
let consumed = null;
|
|
67
|
+
this.mutatePayload((payload) => {
|
|
68
|
+
const invitation = payload.invitations.find((item) => !item.usedAt && verifySecret(trimmed, item.codeHash));
|
|
69
|
+
if (!invitation) {
|
|
70
|
+
throw new Error("Invalid or already used pairing code.");
|
|
71
|
+
}
|
|
72
|
+
if (Date.parse(invitation.expiresAt) <= Date.now()) {
|
|
73
|
+
throw new Error("Pairing code has expired.");
|
|
74
|
+
}
|
|
75
|
+
invitation.usedAt = new Date().toISOString();
|
|
76
|
+
invitation.usedByNodeId = usedByNodeId;
|
|
77
|
+
consumed = { ...invitation, scopes: [...invitation.scopes], allowedAgents: [...invitation.allowedAgents], allowedWorkspaceRoots: [...invitation.allowedWorkspaceRoots] };
|
|
78
|
+
});
|
|
79
|
+
if (!consumed) {
|
|
80
|
+
throw new Error("Pairing code could not be consumed.");
|
|
81
|
+
}
|
|
82
|
+
return consumed;
|
|
83
|
+
}
|
|
84
|
+
upsertPeer(input) {
|
|
85
|
+
const now = new Date().toISOString();
|
|
86
|
+
let next = null;
|
|
87
|
+
this.mutatePayload((payload) => {
|
|
88
|
+
const existing = payload.peers.find((peer) => peer.nodeId === input.nodeId || (input.id && peer.id === input.id));
|
|
89
|
+
if (existing) {
|
|
90
|
+
existing.name = input.name.trim() || existing.name;
|
|
91
|
+
existing.url = input.url ?? existing.url;
|
|
92
|
+
existing.publicKey = input.publicKey;
|
|
93
|
+
existing.fingerprint = input.fingerprint;
|
|
94
|
+
existing.tlsFingerprint = input.tlsFingerprint ?? existing.tlsFingerprint;
|
|
95
|
+
existing.secret = input.secret;
|
|
96
|
+
existing.enabled = input.enabled ?? existing.enabled;
|
|
97
|
+
existing.direction = mergeDirection(existing.direction, input.direction ?? existing.direction);
|
|
98
|
+
existing.scopes = normalizeScopes(input.scopes ?? existing.scopes);
|
|
99
|
+
existing.allowedAgents = normalizeAgents(input.allowedAgents ?? existing.allowedAgents);
|
|
100
|
+
existing.allowedWorkspaceRoots = normalizeWorkspaceRoots(input.allowedWorkspaceRoots ?? existing.allowedWorkspaceRoots);
|
|
101
|
+
existing.workspaceAliases = normalizeWorkspaceAliases(input.workspaceAliases ?? existing.workspaceAliases ?? {});
|
|
102
|
+
existing.updatedAt = now;
|
|
103
|
+
delete existing.lastError;
|
|
104
|
+
next = clonePeer(existing);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const record = {
|
|
108
|
+
id: input.id ?? randomUUID().replace(/-/g, "").slice(0, 12),
|
|
109
|
+
name: input.name.trim() || "NordRelay peer",
|
|
110
|
+
url: input.url,
|
|
111
|
+
nodeId: input.nodeId,
|
|
112
|
+
publicKey: input.publicKey,
|
|
113
|
+
fingerprint: input.fingerprint,
|
|
114
|
+
tlsFingerprint: input.tlsFingerprint,
|
|
115
|
+
secret: input.secret,
|
|
116
|
+
enabled: input.enabled ?? true,
|
|
117
|
+
direction: input.direction ?? "outbound",
|
|
118
|
+
scopes: normalizeScopes(input.scopes ?? DEFAULT_PEER_SCOPES),
|
|
119
|
+
allowedAgents: normalizeAgents(input.allowedAgents ?? [...AGENT_IDS]),
|
|
120
|
+
allowedWorkspaceRoots: normalizeWorkspaceRoots(input.allowedWorkspaceRoots ?? []),
|
|
121
|
+
workspaceAliases: normalizeWorkspaceAliases(input.workspaceAliases ?? {}),
|
|
122
|
+
createdAt: now,
|
|
123
|
+
updatedAt: now,
|
|
124
|
+
};
|
|
125
|
+
payload.peers.push(record);
|
|
126
|
+
next = clonePeer(record);
|
|
127
|
+
});
|
|
128
|
+
if (!next) {
|
|
129
|
+
throw new Error("Peer could not be saved.");
|
|
130
|
+
}
|
|
131
|
+
return next;
|
|
132
|
+
}
|
|
133
|
+
updatePeer(id, patch) {
|
|
134
|
+
let next = null;
|
|
135
|
+
this.mutatePayload((payload) => {
|
|
136
|
+
const peer = payload.peers.find((candidate) => candidate.id === id || candidate.nodeId === id);
|
|
137
|
+
if (!peer) {
|
|
138
|
+
throw new Error("Peer not found.");
|
|
139
|
+
}
|
|
140
|
+
if (patch.name !== undefined)
|
|
141
|
+
peer.name = patch.name.trim() || peer.name;
|
|
142
|
+
if (patch.url !== undefined)
|
|
143
|
+
peer.url = patch.url.trim() || undefined;
|
|
144
|
+
if (patch.enabled !== undefined)
|
|
145
|
+
peer.enabled = patch.enabled;
|
|
146
|
+
if (patch.scopes !== undefined)
|
|
147
|
+
peer.scopes = normalizeScopes(patch.scopes);
|
|
148
|
+
if (patch.allowedAgents !== undefined)
|
|
149
|
+
peer.allowedAgents = normalizeAgents(patch.allowedAgents);
|
|
150
|
+
if (patch.allowedWorkspaceRoots !== undefined)
|
|
151
|
+
peer.allowedWorkspaceRoots = normalizeWorkspaceRoots(patch.allowedWorkspaceRoots);
|
|
152
|
+
if (patch.workspaceAliases !== undefined)
|
|
153
|
+
peer.workspaceAliases = normalizeWorkspaceAliases(patch.workspaceAliases);
|
|
154
|
+
peer.updatedAt = new Date().toISOString();
|
|
155
|
+
next = clonePeer(peer);
|
|
156
|
+
});
|
|
157
|
+
if (!next) {
|
|
158
|
+
throw new Error("Peer not found.");
|
|
159
|
+
}
|
|
160
|
+
return next;
|
|
161
|
+
}
|
|
162
|
+
markSeen(id, patch = {}) {
|
|
163
|
+
this.patchPeer(id, {
|
|
164
|
+
lastSeenAt: new Date().toISOString(),
|
|
165
|
+
lastCheckedAt: new Date().toISOString(),
|
|
166
|
+
lastLatencyMs: patch.latencyMs,
|
|
167
|
+
remoteVersion: patch.remoteVersion,
|
|
168
|
+
remoteStatus: patch.remoteStatus ?? "online",
|
|
169
|
+
lastError: undefined,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
markError(id, error) {
|
|
173
|
+
this.patchPeer(id, { lastError: error, remoteStatus: "offline", lastCheckedAt: new Date().toISOString(), updatedAt: new Date().toISOString() });
|
|
174
|
+
}
|
|
175
|
+
revokePeer(id) {
|
|
176
|
+
let removed = false;
|
|
177
|
+
this.mutatePayload((payload) => {
|
|
178
|
+
const before = payload.peers.length;
|
|
179
|
+
payload.peers = payload.peers.filter((peer) => peer.id !== id && peer.nodeId !== id);
|
|
180
|
+
removed = payload.peers.length !== before;
|
|
181
|
+
});
|
|
182
|
+
return removed;
|
|
183
|
+
}
|
|
184
|
+
deleteInvitation(id) {
|
|
185
|
+
let removed = null;
|
|
186
|
+
this.mutatePayload((payload) => {
|
|
187
|
+
const index = payload.invitations.findIndex((invitation) => invitation.id === id);
|
|
188
|
+
if (index < 0) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const [invitation] = payload.invitations.splice(index, 1);
|
|
192
|
+
removed = invitation;
|
|
193
|
+
});
|
|
194
|
+
return removed ? publicInvitation(removed) : null;
|
|
195
|
+
}
|
|
196
|
+
patchPeer(id, patch) {
|
|
197
|
+
this.mutatePayload((payload) => {
|
|
198
|
+
const peer = payload.peers.find((candidate) => candidate.id === id || candidate.nodeId === id);
|
|
199
|
+
if (!peer)
|
|
200
|
+
return;
|
|
201
|
+
Object.assign(peer, patch);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
mutatePayload(mutator) {
|
|
205
|
+
const payload = this.readPayload();
|
|
206
|
+
const result = mutator(payload);
|
|
207
|
+
mkdirSync(path.dirname(this.filePath), { recursive: true });
|
|
208
|
+
writeJsonFileAtomic(this.filePath, payload);
|
|
209
|
+
return result;
|
|
210
|
+
}
|
|
211
|
+
readPayload() {
|
|
212
|
+
const payload = readJsonFileWithBackup(this.filePath).value;
|
|
213
|
+
if (!payload || payload.version !== 1 || !Array.isArray(payload.peers) || !Array.isArray(payload.invitations)) {
|
|
214
|
+
return { version: 1, peers: [], invitations: [] };
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
version: 1,
|
|
218
|
+
peers: payload.peers.filter(isPeerRecord).map((peer) => ({
|
|
219
|
+
...peer,
|
|
220
|
+
scopes: normalizeScopes(peer.scopes),
|
|
221
|
+
allowedAgents: normalizeAgents(peer.allowedAgents),
|
|
222
|
+
allowedWorkspaceRoots: normalizeWorkspaceRoots(peer.allowedWorkspaceRoots),
|
|
223
|
+
workspaceAliases: normalizeWorkspaceAliases(peer.workspaceAliases ?? {}),
|
|
224
|
+
})),
|
|
225
|
+
invitations: payload.invitations.filter(isInvitationRecord).map((invitation) => ({
|
|
226
|
+
...invitation,
|
|
227
|
+
scopes: normalizeScopes(invitation.scopes),
|
|
228
|
+
allowedAgents: normalizeAgents(invitation.allowedAgents),
|
|
229
|
+
allowedWorkspaceRoots: normalizeWorkspaceRoots(invitation.allowedWorkspaceRoots),
|
|
230
|
+
workspaceAliases: normalizeWorkspaceAliases(invitation.workspaceAliases ?? {}),
|
|
231
|
+
})),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
export function hashSecret(value) {
|
|
236
|
+
const salt = randomBytes(16).toString("hex");
|
|
237
|
+
const digest = createHash("sha256").update(`${salt}:${value}`).digest("hex");
|
|
238
|
+
return `${salt}:${digest}`;
|
|
239
|
+
}
|
|
240
|
+
export function verifySecret(value, stored) {
|
|
241
|
+
const [salt, digest] = stored.split(":");
|
|
242
|
+
if (!salt || !digest) {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
const next = createHash("sha256").update(`${salt}:${value}`).digest();
|
|
246
|
+
const previous = Buffer.from(digest, "hex");
|
|
247
|
+
return previous.length === next.length && timingSafeEqual(previous, next);
|
|
248
|
+
}
|
|
249
|
+
function normalizeScopes(values) {
|
|
250
|
+
const allowed = new Set(ALL_PERMISSIONS);
|
|
251
|
+
return [...new Set(values.filter((value) => allowed.has(value)))];
|
|
252
|
+
}
|
|
253
|
+
function normalizeAgents(values) {
|
|
254
|
+
return [...new Set(values.filter(isAgentId))];
|
|
255
|
+
}
|
|
256
|
+
function normalizeWorkspaceRoots(values) {
|
|
257
|
+
return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
|
|
258
|
+
}
|
|
259
|
+
function normalizeWorkspaceAliases(value) {
|
|
260
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
261
|
+
return {};
|
|
262
|
+
}
|
|
263
|
+
const aliases = {};
|
|
264
|
+
for (const [rawAlias, rawWorkspace] of Object.entries(value ?? {})) {
|
|
265
|
+
const alias = rawAlias.trim();
|
|
266
|
+
const workspace = String(rawWorkspace ?? "").trim();
|
|
267
|
+
if (!alias || !workspace || /[,\s]/.test(alias))
|
|
268
|
+
continue;
|
|
269
|
+
aliases[alias] = workspace;
|
|
270
|
+
}
|
|
271
|
+
return aliases;
|
|
272
|
+
}
|
|
273
|
+
function clonePeer(peer) {
|
|
274
|
+
return {
|
|
275
|
+
...peer,
|
|
276
|
+
scopes: [...peer.scopes],
|
|
277
|
+
allowedAgents: [...peer.allowedAgents],
|
|
278
|
+
allowedWorkspaceRoots: [...peer.allowedWorkspaceRoots],
|
|
279
|
+
workspaceAliases: { ...peer.workspaceAliases },
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
function mergeDirection(left, right) {
|
|
283
|
+
if (left === right)
|
|
284
|
+
return left;
|
|
285
|
+
return "bidirectional";
|
|
286
|
+
}
|
|
287
|
+
function isPeerRecord(value) {
|
|
288
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
289
|
+
return false;
|
|
290
|
+
const record = value;
|
|
291
|
+
return typeof record.id === "string" &&
|
|
292
|
+
typeof record.name === "string" &&
|
|
293
|
+
typeof record.nodeId === "string" &&
|
|
294
|
+
typeof record.publicKey === "string" &&
|
|
295
|
+
typeof record.fingerprint === "string" &&
|
|
296
|
+
typeof record.secret === "string" &&
|
|
297
|
+
typeof record.enabled === "boolean";
|
|
298
|
+
}
|
|
299
|
+
function isInvitationRecord(value) {
|
|
300
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
301
|
+
return false;
|
|
302
|
+
const record = value;
|
|
303
|
+
return typeof record.id === "string" &&
|
|
304
|
+
typeof record.name === "string" &&
|
|
305
|
+
typeof record.codeHash === "string" &&
|
|
306
|
+
typeof record.expiresAt === "string";
|
|
307
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export const PEER_PROTOCOL_VERSION = 1;
|
|
2
|
+
export const DEFAULT_PEER_SCOPES = [
|
|
3
|
+
"inspect",
|
|
4
|
+
"sessions.read",
|
|
5
|
+
"sessions.write",
|
|
6
|
+
"prompt.send",
|
|
7
|
+
"prompt.abort",
|
|
8
|
+
"queue.read",
|
|
9
|
+
"queue.write",
|
|
10
|
+
"files.read",
|
|
11
|
+
"files.write",
|
|
12
|
+
"diagnostics.read",
|
|
13
|
+
"logs.read",
|
|
14
|
+
];
|
|
15
|
+
export function publicPeer(record) {
|
|
16
|
+
return {
|
|
17
|
+
id: record.id,
|
|
18
|
+
name: record.name,
|
|
19
|
+
url: record.url,
|
|
20
|
+
nodeId: record.nodeId,
|
|
21
|
+
fingerprint: record.fingerprint,
|
|
22
|
+
tlsFingerprint: record.tlsFingerprint,
|
|
23
|
+
enabled: record.enabled,
|
|
24
|
+
direction: record.direction,
|
|
25
|
+
scopes: [...record.scopes],
|
|
26
|
+
allowedAgents: [...record.allowedAgents],
|
|
27
|
+
allowedWorkspaceRoots: [...record.allowedWorkspaceRoots],
|
|
28
|
+
workspaceAliases: { ...record.workspaceAliases },
|
|
29
|
+
createdAt: record.createdAt,
|
|
30
|
+
updatedAt: record.updatedAt,
|
|
31
|
+
lastSeenAt: record.lastSeenAt,
|
|
32
|
+
lastCheckedAt: record.lastCheckedAt,
|
|
33
|
+
lastLatencyMs: record.lastLatencyMs,
|
|
34
|
+
remoteVersion: record.remoteVersion,
|
|
35
|
+
remoteStatus: record.remoteStatus,
|
|
36
|
+
lastError: record.lastError,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export function publicInvitation(record) {
|
|
40
|
+
return {
|
|
41
|
+
id: record.id,
|
|
42
|
+
name: record.name,
|
|
43
|
+
expiresAt: record.expiresAt,
|
|
44
|
+
createdAt: record.createdAt,
|
|
45
|
+
scopes: [...record.scopes],
|
|
46
|
+
allowedAgents: [...record.allowedAgents],
|
|
47
|
+
allowedWorkspaceRoots: [...record.allowedWorkspaceRoots],
|
|
48
|
+
workspaceAliases: { ...record.workspaceAliases },
|
|
49
|
+
usedAt: record.usedAt,
|
|
50
|
+
usedByNodeId: record.usedByNodeId,
|
|
51
|
+
};
|
|
52
|
+
}
|