mecord-connect 1.0.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/DISCLOSURE +47 -0
- package/LICENSE +64 -0
- package/README.md +44 -0
- package/bin/operator.mjs +10 -0
- package/package.json +58 -0
- package/runtime/app/apps/local-agent/src/approval-store.js +342 -0
- package/runtime/app/apps/local-agent/src/bootstrap-config.js +255 -0
- package/runtime/app/apps/local-agent/src/cli.js +237 -0
- package/runtime/app/apps/local-agent/src/device-reset.js +260 -0
- package/runtime/app/apps/local-agent/src/emergency-stop.js +141 -0
- package/runtime/app/apps/local-agent/src/main.js +311 -0
- package/runtime/app/apps/local-agent/src/privacy-data.js +129 -0
- package/runtime/app/apps/local-agent/src/relay-agent.js +333 -0
- package/runtime/app/apps/local-agent/src/relay-enrollment.js +156 -0
- package/runtime/app/apps/local-agent/src/relay-session-credentials.js +436 -0
- package/runtime/app/apps/local-agent/src/remote.js +130 -0
- package/runtime/app/apps/local-agent/src/runtime-factory.js +66 -0
- package/runtime/app/apps/local-agent/src/server.js +578 -0
- package/runtime/app/package.json +4 -0
- package/runtime/app/src/capabilities/browser-cdp-connection.js +199 -0
- package/runtime/app/src/capabilities/browser-cdp-frames.js +288 -0
- package/runtime/app/src/capabilities/browser-cdp-page.js +430 -0
- package/runtime/app/src/capabilities/browser-cdp.js +384 -0
- package/runtime/app/src/capabilities/browser-managed.js +345 -0
- package/runtime/app/src/capabilities/docker.js +448 -0
- package/runtime/app/src/capabilities/filesystem.js +264 -0
- package/runtime/app/src/capabilities/git-checkpoint.js +415 -0
- package/runtime/app/src/capabilities/git-write.js +321 -0
- package/runtime/app/src/capabilities/git.js +252 -0
- package/runtime/app/src/capabilities/index.js +16 -0
- package/runtime/app/src/capabilities/path-scope.js +86 -0
- package/runtime/app/src/capabilities/postgres.js +566 -0
- package/runtime/app/src/capabilities/process.js +258 -0
- package/runtime/app/src/capabilities/project-command.js +599 -0
- package/runtime/app/src/capabilities/project-transaction.js +215 -0
- package/runtime/app/src/capabilities/project.js +67 -0
- package/runtime/app/src/capabilities/system.js +40 -0
- package/runtime/app/src/capabilities/vscode.js +312 -0
- package/runtime/app/src/capabilities/windows-uia.js +331 -0
- package/runtime/app/src/core/account-device-registry.js +604 -0
- package/runtime/app/src/core/action-identity.js +44 -0
- package/runtime/app/src/core/audit.js +295 -0
- package/runtime/app/src/core/capability-policy.js +58 -0
- package/runtime/app/src/core/child-environment.js +29 -0
- package/runtime/app/src/core/device-enrollment.js +417 -0
- package/runtime/app/src/core/device-identity.js +436 -0
- package/runtime/app/src/core/device-registry.js +477 -0
- package/runtime/app/src/core/device-reset.js +178 -0
- package/runtime/app/src/core/device-routing.js +305 -0
- package/runtime/app/src/core/durable-state.js +306 -0
- package/runtime/app/src/core/errors.js +20 -0
- package/runtime/app/src/core/evidence.js +9 -0
- package/runtime/app/src/core/index.js +17 -0
- package/runtime/app/src/core/machine-state.js +160 -0
- package/runtime/app/src/core/network-authority.js +20 -0
- package/runtime/app/src/core/policy.js +67 -0
- package/runtime/app/src/core/product-identity.js +7 -0
- package/runtime/app/src/core/provenance.js +18 -0
- package/runtime/app/src/core/provider-learning.js +274 -0
- package/runtime/app/src/core/public-plugin-surface.js +25 -0
- package/runtime/app/src/core/public-restricted-data.js +105 -0
- package/runtime/app/src/core/rate-limit.js +118 -0
- package/runtime/app/src/core/relay-client.js +568 -0
- package/runtime/app/src/core/relay-delivery-store.js +512 -0
- package/runtime/app/src/core/relay-result-store.js +321 -0
- package/runtime/app/src/core/release-update.js +253 -0
- package/runtime/app/src/core/router.js +96 -0
- package/runtime/app/src/core/runtime.js +136 -0
- package/runtime/app/src/core/scoped-path-syntax.js +16 -0
- package/runtime/app/src/core/session-token.js +498 -0
- package/runtime/app/src/core/task-orchestrator.js +1148 -0
- package/runtime/app/src/core/task-store.js +517 -0
- package/runtime/app/src/core/task.js +142 -0
- package/runtime/app/src/core/trusted-executable.js +124 -0
- package/runtime/app/src/core/types.js +83 -0
- package/runtime/app/src/core/windows-path-lease.js +138 -0
- package/runtime/native/operator-windows-dpapi.exe +0 -0
- package/runtime/native/operator-windows-path-lease.exe +0 -0
- package/runtime/native/operator-windows-uia.exe +0 -0
- package/runtime/runtime-manifest.json +380 -0
- package/src/cli.mjs +345 -0
- package/src/publish-check.mjs +22 -0
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { DeviceIdentityStore } from './device-identity.js';
|
|
4
|
+
import { OperatorError } from './errors.js';
|
|
5
|
+
import { readDurableStateText, writeDurableStateText } from './durable-state.js';
|
|
6
|
+
|
|
7
|
+
const PROTOCOL = 1;
|
|
8
|
+
const MAX_FRAME_BYTES = 256 * 1024;
|
|
9
|
+
const MAX_DELIVERY_ID = 128;
|
|
10
|
+
const MAX_SEQUENCE = Number.MAX_SAFE_INTEGER;
|
|
11
|
+
const MIN_BACKOFF_MS = 250;
|
|
12
|
+
const MAX_BACKOFF_MS = 30_000;
|
|
13
|
+
const DEFAULT_HEARTBEAT_MS = 20_000;
|
|
14
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 15_000;
|
|
15
|
+
const MIN_CONNECT_TIMEOUT_MS = 100;
|
|
16
|
+
const MAX_CONNECT_TIMEOUT_MS = 60_000;
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
export class RelayClient {
|
|
109
|
+
#stateFile ;
|
|
110
|
+
#url ;
|
|
111
|
+
#identity ;
|
|
112
|
+
#socketFactory ;
|
|
113
|
+
#getSessionToken ;
|
|
114
|
+
#getSupportedCapabilities ;
|
|
115
|
+
#requireCapabilityBinding ;
|
|
116
|
+
#onDelivery ;
|
|
117
|
+
#onRecovery ;
|
|
118
|
+
#onExpiredRecovery ;
|
|
119
|
+
#onAcknowledged ;
|
|
120
|
+
#random ;
|
|
121
|
+
#clock ;
|
|
122
|
+
#sleep ;
|
|
123
|
+
#connectTimeoutMs ;
|
|
124
|
+
#stopped = false;
|
|
125
|
+
#socket = null;
|
|
126
|
+
#attempt = 0;
|
|
127
|
+
#heartbeatTimer = null;
|
|
128
|
+
#lastPongAt = 0;
|
|
129
|
+
#interruptConnection = null;
|
|
130
|
+
|
|
131
|
+
constructor(options ) {
|
|
132
|
+
this.#stateFile = path.join(path.resolve(options.stateDir), 'relay-client.json');
|
|
133
|
+
this.#url = validateRelayUrl(options.url, Boolean(options.allowLoopbackInsecureWs));
|
|
134
|
+
this.#identity = options.identity;
|
|
135
|
+
this.#socketFactory = options.socketFactory ?? nativeSocketFactory;
|
|
136
|
+
this.#getSessionToken = options.getSessionToken;
|
|
137
|
+
if (options.supportedCapabilities !== undefined && options.getSupportedCapabilities) {
|
|
138
|
+
throw new OperatorError('RELAY_CAPABILITIES_CONFIGURATION_INVALID', 'Configure either static or dynamic relay capabilities, not both.');
|
|
139
|
+
}
|
|
140
|
+
this.#requireCapabilityBinding = options.supportedCapabilities !== undefined || options.getSupportedCapabilities !== undefined;
|
|
141
|
+
const staticCapabilities = validateSupportedCapabilities(options.supportedCapabilities ?? []);
|
|
142
|
+
this.#getSupportedCapabilities = options.getSupportedCapabilities
|
|
143
|
+
? async () => validateSupportedCapabilities(await options.getSupportedCapabilities ())
|
|
144
|
+
: async () => [...staticCapabilities];
|
|
145
|
+
this.#onDelivery = options.onDelivery;
|
|
146
|
+
this.#onRecovery = options.onRecovery;
|
|
147
|
+
this.#onExpiredRecovery = options.onExpiredRecovery;
|
|
148
|
+
this.#onAcknowledged = options.onAcknowledged;
|
|
149
|
+
this.#random = options.random ?? Math.random;
|
|
150
|
+
this.#clock = options.clock ?? (() => new Date());
|
|
151
|
+
this.#sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
152
|
+
this.#connectTimeoutMs = boundedConnectTimeout(options.connectTimeoutMs);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async run() {
|
|
156
|
+
this.#stopped = false;
|
|
157
|
+
while (!this.#stopped) {
|
|
158
|
+
try {
|
|
159
|
+
await this.#connectOnce();
|
|
160
|
+
this.#attempt = 0;
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (this.#stopped) break;
|
|
163
|
+
if (error instanceof OperatorError && !error.retryable) throw error;
|
|
164
|
+
const delay = reconnectDelay(this.#attempt++, this.#random());
|
|
165
|
+
await this.#sleep(delay);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
stop() {
|
|
171
|
+
this.#stopped = true;
|
|
172
|
+
this.#clearHeartbeat();
|
|
173
|
+
try { this.#socket?.close(1000, 'operator stopping'); } catch { /* already closed */ }
|
|
174
|
+
this.#socket = null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
reconnect() {
|
|
178
|
+
if (this.#stopped) return;
|
|
179
|
+
const interrupt = this.#interruptConnection;
|
|
180
|
+
if (interrupt) {
|
|
181
|
+
interrupt();
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
try { this.#socket?.close(1012, 'session refresh recovery'); } catch { /* reconnect loop handles the next attempt */ }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async state() {
|
|
188
|
+
return await this.#readState();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async #connectOnce() {
|
|
192
|
+
const state = await this.#readState();
|
|
193
|
+
const token = await this.#getSessionToken();
|
|
194
|
+
const supportedCapabilities = await this.#getSupportedCapabilities();
|
|
195
|
+
if (!token || Buffer.byteLength(token, 'utf8') > 16 * 1024) {
|
|
196
|
+
throw new OperatorError('RELAY_SESSION_TOKEN_INVALID', 'Relay session token is missing or exceeds the bounded size.');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const socket = this.#socketFactory(this.#url);
|
|
200
|
+
this.#socket = socket;
|
|
201
|
+
await waitForOpen(socket, this.#connectTimeoutMs);
|
|
202
|
+
if (typeof socket.url === 'string' && !sameRelayDestination(this.#url, socket.url)) {
|
|
203
|
+
try { socket.close(4003, 'destination changed'); } catch { /* mismatch is already authoritative */ }
|
|
204
|
+
throw new OperatorError('RELAY_SOCKET_DESTINATION_CHANGED', 'Opened relay WebSocket destination differs from the authorized endpoint.', { retryable: false });
|
|
205
|
+
}
|
|
206
|
+
if (this.#stopped) return;
|
|
207
|
+
|
|
208
|
+
const identity = await this.#identity.loadOrCreate();
|
|
209
|
+
const helloPayload = {
|
|
210
|
+
protocol: PROTOCOL,
|
|
211
|
+
deviceId: identity.deviceId,
|
|
212
|
+
deviceName: identity.deviceName,
|
|
213
|
+
fingerprint: identity.fingerprint,
|
|
214
|
+
resumeAfterSeq: state.lastAckedServerSeq,
|
|
215
|
+
pendingRecovery: state.processing ? { seq: state.processing.seq, id: state.processing.id } : null,
|
|
216
|
+
...(this.#requireCapabilityBinding ? { capabilityBinding: 1 , capabilities: [...supportedCapabilities] } : {}),
|
|
217
|
+
sentAt: this.#clock().toISOString(),
|
|
218
|
+
nonce: crypto.randomBytes(24).toString('base64url')
|
|
219
|
+
};
|
|
220
|
+
const signature = await this.#identity.sign(canonicalBytes(helloPayload));
|
|
221
|
+
|
|
222
|
+
const connectionDone = new Promise ((resolve, reject) => {
|
|
223
|
+
let welcomed = false;
|
|
224
|
+
let settled = false;
|
|
225
|
+
let messageQueue = Promise.resolve();
|
|
226
|
+
let interrupt = null;
|
|
227
|
+
|
|
228
|
+
const cleanup = () => {
|
|
229
|
+
socket.removeEventListener?.('message', onMessage);
|
|
230
|
+
socket.removeEventListener?.('error', onError);
|
|
231
|
+
socket.removeEventListener?.('close', onClose);
|
|
232
|
+
if (this.#interruptConnection === interrupt) this.#interruptConnection = null;
|
|
233
|
+
};
|
|
234
|
+
const succeed = () => {
|
|
235
|
+
if (settled) return;
|
|
236
|
+
settled = true;
|
|
237
|
+
cleanup();
|
|
238
|
+
resolve();
|
|
239
|
+
};
|
|
240
|
+
const fail = (error ) => {
|
|
241
|
+
if (settled) return;
|
|
242
|
+
settled = true;
|
|
243
|
+
this.#clearHeartbeat();
|
|
244
|
+
cleanup();
|
|
245
|
+
try { socket.close(4002, 'protocol/recovery error'); } catch { /* noop */ }
|
|
246
|
+
reject(error instanceof OperatorError ? error : new OperatorError('RELAY_PROTOCOL_ERROR', error instanceof Error ? error.message : String(error), { retryable: true }));
|
|
247
|
+
};
|
|
248
|
+
const onError = () => fail(new OperatorError('RELAY_SOCKET_ERROR', 'Relay socket reported an error.', { retryable: true }));
|
|
249
|
+
const onClose = () => {
|
|
250
|
+
if (this.#stopped) succeed();
|
|
251
|
+
else fail(new OperatorError(welcomed ? 'RELAY_SOCKET_CLOSED' : 'RELAY_CONNECT_FAILED', welcomed ? 'Relay socket closed unexpectedly.' : 'Relay socket closed before handshake completion.', { retryable: true }));
|
|
252
|
+
};
|
|
253
|
+
interrupt = () => fail(new OperatorError('RELAY_RECONNECT_REQUESTED', 'Relay reconnect was requested.', { retryable: true }));
|
|
254
|
+
this.#interruptConnection = interrupt;
|
|
255
|
+
const processMessage = async (event ) => {
|
|
256
|
+
const frame = parseServerFrame(event?.data);
|
|
257
|
+
if (!welcomed) {
|
|
258
|
+
if (frame.type !== 'welcome') throw new OperatorError('RELAY_PROTOCOL_ERROR', 'Relay sent a non-welcome frame before handshake completion.');
|
|
259
|
+
await this.#validateWelcome(frame, state, supportedCapabilities);
|
|
260
|
+
welcomed = true;
|
|
261
|
+
this.#attempt = 0;
|
|
262
|
+
this.#lastPongAt = Date.now();
|
|
263
|
+
this.#startHeartbeat(socket, boundedHeartbeat(frame.heartbeatMs));
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (frame.type === 'pong') {
|
|
267
|
+
this.#lastPongAt = Date.now();
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (frame.type === 'welcome') throw new OperatorError('RELAY_PROTOCOL_ERROR', 'Relay sent a duplicate welcome frame.');
|
|
271
|
+
await this.#handleDelivery(socket, frame);
|
|
272
|
+
};
|
|
273
|
+
const onMessage = (event ) => {
|
|
274
|
+
messageQueue = messageQueue.then(() => processMessage(event));
|
|
275
|
+
void messageQueue.catch(fail);
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
socket.addEventListener('message', onMessage);
|
|
279
|
+
socket.addEventListener('error', onError);
|
|
280
|
+
socket.addEventListener('close', onClose);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
sendFrame(socket, { type: 'hello', payload: helloPayload, signature, sessionToken: token });
|
|
284
|
+
await connectionDone;
|
|
285
|
+
this.#clearHeartbeat();
|
|
286
|
+
this.#socket = null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async #validateWelcome(frame , state , supportedCapabilities ) {
|
|
290
|
+
if (frame.protocol !== PROTOCOL) throw new OperatorError('RELAY_PROTOCOL_VERSION', 'Relay protocol version mismatch.');
|
|
291
|
+
if (this.#requireCapabilityBinding) {
|
|
292
|
+
const legacyRelay = frame.capabilityBinding === undefined && frame.capabilities === undefined;
|
|
293
|
+
if (!legacyRelay) {
|
|
294
|
+
if (frame.capabilityBinding !== 1 || frame.capabilities === undefined) {
|
|
295
|
+
throw new OperatorError('RELAY_CAPABILITY_BINDING_INVALID', 'Relay returned a partial or invalid capability-binding negotiation.', { retryable: false });
|
|
296
|
+
}
|
|
297
|
+
const effective = validateSupportedCapabilities(frame.capabilities);
|
|
298
|
+
const advertised = new Set(supportedCapabilities);
|
|
299
|
+
if (effective.some((capability) => !advertised.has(capability))) {
|
|
300
|
+
throw new OperatorError('RELAY_CAPABILITY_BINDING_INVALID', 'Relay acknowledged a capability that the local runtime did not advertise.', { retryable: false });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(frame.connectionId)) throw new OperatorError('RELAY_PROTOCOL_ERROR', 'Relay connection ID is invalid.');
|
|
305
|
+
if (!Number.isSafeInteger(frame.resumeFromSeq) || frame.resumeFromSeq < 0) throw new OperatorError('RELAY_PROTOCOL_ERROR', 'Relay resume sequence is invalid.');
|
|
306
|
+
const expiredThroughSeq = frame.expiredThroughSeq;
|
|
307
|
+
if (expiredThroughSeq !== undefined && (!Number.isSafeInteger(expiredThroughSeq) || expiredThroughSeq < 1 || expiredThroughSeq !== frame.resumeFromSeq)) {
|
|
308
|
+
throw new OperatorError('RELAY_PROTOCOL_ERROR', 'Relay expired-history reconciliation proof is invalid.');
|
|
309
|
+
}
|
|
310
|
+
if (frame.resumeFromSeq === state.lastAckedServerSeq) {
|
|
311
|
+
if (expiredThroughSeq !== undefined) throw new OperatorError('RELAY_PROTOCOL_ERROR', 'Relay supplied an unnecessary expired-history reconciliation proof.');
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (frame.resumeFromSeq < state.lastAckedServerSeq || expiredThroughSeq !== frame.resumeFromSeq) {
|
|
315
|
+
throw new OperatorError('RELAY_RESUME_MISMATCH', 'Relay resume cursor does not match the durable local acknowledgement cursor.', { retryable: true });
|
|
316
|
+
}
|
|
317
|
+
if (state.processing) {
|
|
318
|
+
if (state.processing.seq > frame.resumeFromSeq || !this.#onExpiredRecovery) {
|
|
319
|
+
throw new OperatorError('RELAY_RECOVERY_CONFLICT', 'Relay cannot skip expired history while a local delivery remains in uncertain processing state.', { retryable: false });
|
|
320
|
+
}
|
|
321
|
+
const decision = await this.#onExpiredRecovery({ processing: { ...state.processing }, expiredThroughSeq: frame.resumeFromSeq });
|
|
322
|
+
if (decision !== 'ack') {
|
|
323
|
+
throw new OperatorError('RELAY_RECOVERY_REQUIRED', 'Expired delivery recovery callback refused to reconcile the durable processing result.', { retryable: false });
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
await this.#writeState({ version: 1, lastAckedServerSeq: frame.resumeFromSeq });
|
|
327
|
+
if (state.processing && state.processing.seq <= frame.resumeFromSeq) {
|
|
328
|
+
await this.#notifyAcknowledged(state.processing);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async #handleDelivery(socket , frame ) {
|
|
333
|
+
const delivery = validateDelivery(frame);
|
|
334
|
+
let state = await this.#readState();
|
|
335
|
+
|
|
336
|
+
if (delivery.seq <= state.lastAckedServerSeq) {
|
|
337
|
+
await this.#notifyAcknowledged(delivery);
|
|
338
|
+
sendFrame(socket, { type: 'ack', seq: delivery.seq, id: delivery.id, duplicate: true });
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (delivery.seq !== state.lastAckedServerSeq + 1) {
|
|
342
|
+
throw new OperatorError('RELAY_SEQUENCE_GAP', `Expected relay sequence ${state.lastAckedServerSeq + 1} but received ${delivery.seq}.`, { retryable: true });
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (state.processing) {
|
|
346
|
+
if (state.processing.seq !== delivery.seq || state.processing.id !== delivery.id) {
|
|
347
|
+
throw new OperatorError('RELAY_RECOVERY_CONFLICT', 'A different delivery arrived while an uncertain delivery is awaiting recovery.', { retryable: false });
|
|
348
|
+
}
|
|
349
|
+
if (!this.#onRecovery) {
|
|
350
|
+
throw new OperatorError('RELAY_RECOVERY_REQUIRED', 'A prior delivery may have executed before a crash; explicit recovery is required before replay.', { retryable: false });
|
|
351
|
+
}
|
|
352
|
+
const decision = await this.#onRecovery({ delivery, processing: { ...state.processing } });
|
|
353
|
+
if (decision === 'stop') throw new OperatorError('RELAY_RECOVERY_REQUIRED', 'Recovery callback refused to continue the uncertain delivery.', { retryable: false });
|
|
354
|
+
if (decision === 'retry') {
|
|
355
|
+
await this.#writeState({ ...state, processing: undefined });
|
|
356
|
+
state = await this.#readState();
|
|
357
|
+
} else if (decision === 'ack') {
|
|
358
|
+
const completed = { version: 1 , lastAckedServerSeq: delivery.seq };
|
|
359
|
+
await this.#writeState(completed);
|
|
360
|
+
await this.#notifyAcknowledged(delivery);
|
|
361
|
+
sendFrame(socket, { type: 'ack', seq: delivery.seq, id: delivery.id, recovered: true });
|
|
362
|
+
return;
|
|
363
|
+
} else {
|
|
364
|
+
throw new OperatorError('RELAY_RECOVERY_REQUIRED', 'Recovery callback returned an invalid decision.', { retryable: false });
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const processing = { seq: delivery.seq, id: delivery.id, startedAt: this.#clock().toISOString() };
|
|
369
|
+
await this.#writeState({ ...state, processing });
|
|
370
|
+
await this.#onDelivery(delivery);
|
|
371
|
+
await this.#writeState({ version: 1, lastAckedServerSeq: delivery.seq });
|
|
372
|
+
await this.#notifyAcknowledged(delivery);
|
|
373
|
+
sendFrame(socket, { type: 'ack', seq: delivery.seq, id: delivery.id });
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async #notifyAcknowledged(delivery ) {
|
|
377
|
+
try { await this.#onAcknowledged?.({ seq: delivery.seq, id: delivery.id }); }
|
|
378
|
+
catch { /* acknowledgement is already durable; cleanup is best-effort */ }
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
#startHeartbeat(socket , heartbeatMs ) {
|
|
382
|
+
this.#clearHeartbeat();
|
|
383
|
+
this.#heartbeatTimer = setInterval(() => {
|
|
384
|
+
if (Date.now() - this.#lastPongAt > heartbeatMs * 3) {
|
|
385
|
+
try { socket.close(4000, 'heartbeat timeout'); } catch { /* noop */ }
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
const nonce = crypto.randomBytes(12).toString('base64url');
|
|
389
|
+
try { sendFrame(socket, { type: 'ping', nonce, at: new Date().toISOString() }); } catch { /* close path handles reconnect */ }
|
|
390
|
+
}, heartbeatMs);
|
|
391
|
+
this.#heartbeatTimer.unref();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
#clearHeartbeat() {
|
|
395
|
+
if (this.#heartbeatTimer) clearInterval(this.#heartbeatTimer);
|
|
396
|
+
this.#heartbeatTimer = null;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async #readState() {
|
|
400
|
+
try {
|
|
401
|
+
const text = await readDurableStateText(this.#stateFile, {
|
|
402
|
+
maxBytes: 64 * 1024,
|
|
403
|
+
errorCode: 'RELAY_STATE_CORRUPT',
|
|
404
|
+
invalidMessage: 'Relay client state is invalid.'
|
|
405
|
+
});
|
|
406
|
+
return validateState(JSON.parse(text));
|
|
407
|
+
} catch (error) {
|
|
408
|
+
if ((error ).code === 'ENOENT') return { version: 1, lastAckedServerSeq: 0 };
|
|
409
|
+
if (error instanceof OperatorError) throw error;
|
|
410
|
+
throw new OperatorError('RELAY_STATE_CORRUPT', 'Relay client state could not be read.');
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
async #writeState(stateInput ) {
|
|
415
|
+
const state = validateState(stateInput);
|
|
416
|
+
await writeDurableStateText(this.#stateFile, JSON.stringify(state, null, 2), {
|
|
417
|
+
maxBytes: 64 * 1024,
|
|
418
|
+
errorCode: 'RELAY_STATE_CORRUPT',
|
|
419
|
+
invalidMessage: 'Relay client state is invalid.'
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export function reconnectDelay(attemptInput , randomInput = Math.random()) {
|
|
425
|
+
const attempt = Math.min(Math.max(Math.trunc(attemptInput), 0), 20);
|
|
426
|
+
const random = Number.isFinite(randomInput) ? Math.min(Math.max(randomInput, 0), 1) : 0.5;
|
|
427
|
+
const base = Math.min(MIN_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
|
|
428
|
+
const jitter = 0.75 + random * 0.5;
|
|
429
|
+
return Math.min(Math.max(Math.round(base * jitter), MIN_BACKOFF_MS), MAX_BACKOFF_MS);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
function validateSupportedCapabilities(input ) {
|
|
434
|
+
if (!Array.isArray(input) || input.length > 128) {
|
|
435
|
+
throw new OperatorError('RELAY_CAPABILITIES_INVALID', 'Relay supported capabilities must be a bounded array.');
|
|
436
|
+
}
|
|
437
|
+
const output = [];
|
|
438
|
+
const seen = new Set ();
|
|
439
|
+
for (const item of input) {
|
|
440
|
+
const capability = String(item ?? '');
|
|
441
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(capability)) {
|
|
442
|
+
throw new OperatorError('RELAY_CAPABILITIES_INVALID', 'Relay supported capability name is invalid.');
|
|
443
|
+
}
|
|
444
|
+
if (seen.has(capability)) continue;
|
|
445
|
+
seen.add(capability);
|
|
446
|
+
output.push(capability);
|
|
447
|
+
}
|
|
448
|
+
return output.sort();
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function sameRelayDestination(expectedRaw , actualRaw ) {
|
|
452
|
+
try { return new URL(expectedRaw).toString() === new URL(actualRaw).toString(); } catch { return false; }
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export function validateRelayUrl(urlInput , allowLoopbackInsecureWs = false) {
|
|
456
|
+
let url ;
|
|
457
|
+
try { url = new URL(urlInput); } catch { throw new OperatorError('RELAY_URL_INVALID', 'Relay URL is invalid.'); }
|
|
458
|
+
if (url.username || url.password || url.hash) throw new OperatorError('RELAY_URL_INVALID', 'Relay URL must not contain credentials or a fragment.');
|
|
459
|
+
if (url.protocol === 'wss:') return url.toString();
|
|
460
|
+
if (url.protocol === 'ws:' && allowLoopbackInsecureWs && isLoopbackHost(url.hostname)) return url.toString();
|
|
461
|
+
throw new OperatorError('RELAY_TLS_REQUIRED', 'Relay connections require wss://; insecure ws:// is permitted only for explicit loopback development tests.');
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function validateState(input ) {
|
|
465
|
+
if (!input || input.version !== 1 || !Number.isSafeInteger(input.lastAckedServerSeq) || input.lastAckedServerSeq < 0 || input.lastAckedServerSeq > MAX_SEQUENCE) {
|
|
466
|
+
throw new OperatorError('RELAY_STATE_CORRUPT', 'Relay client state structure is invalid.');
|
|
467
|
+
}
|
|
468
|
+
if (input.processing === undefined) return { version: 1, lastAckedServerSeq: input.lastAckedServerSeq };
|
|
469
|
+
const seq = Number(input.processing.seq);
|
|
470
|
+
const id = validDeliveryId(String(input.processing.id ?? ''));
|
|
471
|
+
const startedAt = validIso(String(input.processing.startedAt ?? ''));
|
|
472
|
+
if (!Number.isSafeInteger(seq) || seq !== input.lastAckedServerSeq + 1) throw new OperatorError('RELAY_STATE_CORRUPT', 'Relay processing sequence is invalid.');
|
|
473
|
+
return { version: 1, lastAckedServerSeq: input.lastAckedServerSeq, processing: { seq, id, startedAt } };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function validateDelivery(frame ) {
|
|
477
|
+
if (!Number.isSafeInteger(frame.seq) || frame.seq < 1 || frame.seq > MAX_SEQUENCE) throw new OperatorError('RELAY_PROTOCOL_ERROR', 'Relay delivery sequence is invalid.');
|
|
478
|
+
const id = validDeliveryId(frame.id);
|
|
479
|
+
const kind = String(frame.kind ?? '');
|
|
480
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(kind)) throw new OperatorError('RELAY_PROTOCOL_ERROR', 'Relay delivery kind is invalid.');
|
|
481
|
+
if (!frame.payload || typeof frame.payload !== 'object' || Array.isArray(frame.payload)) throw new OperatorError('RELAY_PROTOCOL_ERROR', 'Relay delivery payload must be an object.');
|
|
482
|
+
return { seq: frame.seq, id, kind, payload: frame.payload };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function validDeliveryId(value ) {
|
|
486
|
+
if (!value || value.length > MAX_DELIVERY_ID || !/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(value)) throw new OperatorError('RELAY_PROTOCOL_ERROR', 'Relay delivery ID is invalid.');
|
|
487
|
+
return value;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function validIso(value ) {
|
|
491
|
+
const time = Date.parse(value);
|
|
492
|
+
if (!Number.isFinite(time) || new Date(time).toISOString() !== value) throw new OperatorError('RELAY_STATE_CORRUPT', 'Relay state timestamp is invalid.');
|
|
493
|
+
return value;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function parseServerFrame(raw ) {
|
|
497
|
+
const text = typeof raw === 'string' ? raw : raw instanceof ArrayBuffer ? Buffer.from(raw).toString('utf8') : ArrayBuffer.isView(raw) ? Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength).toString('utf8') : '';
|
|
498
|
+
if (!text || Buffer.byteLength(text, 'utf8') > MAX_FRAME_BYTES) throw new OperatorError('RELAY_FRAME_INVALID', 'Relay frame is missing or exceeds the bounded size.');
|
|
499
|
+
let parsed ;
|
|
500
|
+
try { parsed = JSON.parse(text); } catch { throw new OperatorError('RELAY_FRAME_INVALID', 'Relay frame is not valid JSON.'); }
|
|
501
|
+
if (!parsed || typeof parsed !== 'object') throw new OperatorError('RELAY_FRAME_INVALID', 'Relay frame must be an object.');
|
|
502
|
+
if (parsed.type === 'welcome') return parsed ;
|
|
503
|
+
if (parsed.type === 'delivery') return parsed ;
|
|
504
|
+
if (parsed.type === 'pong' && typeof parsed.nonce === 'string') return parsed ;
|
|
505
|
+
throw new OperatorError('RELAY_PROTOCOL_ERROR', 'Relay frame type is unsupported.');
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function sendFrame(socket , frame ) {
|
|
509
|
+
const text = JSON.stringify(frame);
|
|
510
|
+
if (Buffer.byteLength(text, 'utf8') > MAX_FRAME_BYTES) throw new OperatorError('RELAY_FRAME_TOO_LARGE', 'Outbound relay frame exceeds the bounded size.');
|
|
511
|
+
socket.send(text);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function boundedHeartbeat(value ) {
|
|
515
|
+
const parsed = Number(value ?? DEFAULT_HEARTBEAT_MS);
|
|
516
|
+
if (!Number.isInteger(parsed)) return DEFAULT_HEARTBEAT_MS;
|
|
517
|
+
return Math.min(Math.max(parsed, 5_000), 60_000);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function waitForOpen(socket , timeoutMs ) {
|
|
521
|
+
if (socket.readyState === 1) return Promise.resolve();
|
|
522
|
+
return new Promise((resolve, reject) => {
|
|
523
|
+
let settled = false;
|
|
524
|
+
const finish = (callback ) => {
|
|
525
|
+
if (settled) return;
|
|
526
|
+
settled = true;
|
|
527
|
+
cleanup();
|
|
528
|
+
callback();
|
|
529
|
+
};
|
|
530
|
+
const onOpen = () => finish(resolve);
|
|
531
|
+
const onError = () => finish(() => reject(new OperatorError('RELAY_CONNECT_FAILED', 'Relay socket failed before opening.', { retryable: true })));
|
|
532
|
+
const onClose = () => finish(() => reject(new OperatorError('RELAY_CONNECT_FAILED', 'Relay socket closed before opening.', { retryable: true })));
|
|
533
|
+
const timer = setTimeout(() => finish(() => {
|
|
534
|
+
try { socket.close(4001, 'connect timeout'); } catch { /* timeout failure is already authoritative */ }
|
|
535
|
+
reject(new OperatorError('RELAY_CONNECT_TIMEOUT', `Relay socket did not open within ${timeoutMs}ms.`, { retryable: true }));
|
|
536
|
+
}), timeoutMs);
|
|
537
|
+
const cleanup = () => {
|
|
538
|
+
clearTimeout(timer);
|
|
539
|
+
socket.removeEventListener?.('open', onOpen);
|
|
540
|
+
socket.removeEventListener?.('error', onError);
|
|
541
|
+
socket.removeEventListener?.('close', onClose);
|
|
542
|
+
};
|
|
543
|
+
socket.addEventListener('open', onOpen);
|
|
544
|
+
socket.addEventListener('error', onError);
|
|
545
|
+
socket.addEventListener('close', onClose);
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function boundedConnectTimeout(value ) {
|
|
550
|
+
const parsed = Number(value ?? DEFAULT_CONNECT_TIMEOUT_MS);
|
|
551
|
+
if (!Number.isFinite(parsed)) return DEFAULT_CONNECT_TIMEOUT_MS;
|
|
552
|
+
return Math.min(Math.max(Math.trunc(parsed), MIN_CONNECT_TIMEOUT_MS), MAX_CONNECT_TIMEOUT_MS);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function canonicalBytes(value ) {
|
|
556
|
+
return Buffer.from(JSON.stringify(value), 'utf8');
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function nativeSocketFactory(url ) {
|
|
560
|
+
const Ctor = (globalThis ).WebSocket;
|
|
561
|
+
if (typeof Ctor !== 'function') throw new OperatorError('RELAY_WEBSOCKET_UNAVAILABLE', 'This Node runtime does not provide a WebSocket client.', { retryable: false });
|
|
562
|
+
return new Ctor(url) ;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function isLoopbackHost(hostname ) {
|
|
566
|
+
const value = hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
567
|
+
return value === 'localhost' || value === '127.0.0.1' || value === '::1';
|
|
568
|
+
}
|