@livedesk/hub 0.1.34 → 0.1.36

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 CHANGED
@@ -1,32 +1,32 @@
1
- {
2
- "name": "@livedesk/hub",
3
- "version": "0.1.34",
4
- "description": "LiveDesk local Hub API and browser frame bridge",
5
- "type": "module",
6
- "main": "src/server.js",
7
- "files": [
8
- "src/",
9
- "README.md"
10
- ],
11
- "scripts": {
12
- "dev": "node src/server.js",
13
- "start": "node src/server.js",
14
- "check": "node --check src/server.js && node --check src/remote-hub.js",
15
- "prepublishOnly": "node ../../scripts/livedesk-release-git-gate.mjs"
16
- },
17
- "dependencies": {
18
- "@ffmpeg-installer/ffmpeg": "^1.1.0",
19
- "@livedesk/runtime-core": "0.1.4",
20
- "@openai/codex-sdk": "0.145.0",
21
- "cors": "^2.8.5",
22
- "express": "^4.21.2",
23
- "path-to-regexp": "0.1.13",
24
- "ws": "^8.18.3"
25
- },
26
- "engines": {
27
- "node": ">=20"
28
- },
29
- "publishConfig": {
30
- "access": "public"
31
- }
32
- }
1
+ {
2
+ "name": "@livedesk/hub",
3
+ "version": "0.1.36",
4
+ "description": "LiveDesk local Hub API and browser frame bridge",
5
+ "type": "module",
6
+ "main": "src/server.js",
7
+ "files": [
8
+ "src/",
9
+ "README.md"
10
+ ],
11
+ "scripts": {
12
+ "dev": "node src/server.js",
13
+ "start": "node src/server.js",
14
+ "check": "node --check src/server.js && node --check src/remote-hub.js",
15
+ "prepublishOnly": "node ../../scripts/livedesk-release-git-gate.mjs"
16
+ },
17
+ "dependencies": {
18
+ "@ffmpeg-installer/ffmpeg": "^1.1.0",
19
+ "@livedesk/runtime-core": "0.1.4",
20
+ "@openai/codex-sdk": "0.145.0",
21
+ "cors": "^2.8.5",
22
+ "express": "^4.21.2",
23
+ "path-to-regexp": "0.1.13",
24
+ "ws": "^8.18.3"
25
+ },
26
+ "engines": {
27
+ "node": ">=20"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ }
32
+ }
package/src/server.js CHANGED
@@ -647,6 +647,36 @@ async function getRuntimeAccessToken() {
647
647
  }
648
648
  }
649
649
 
650
+ async function restorePersistedHubSessionAtStartup() {
651
+ if (runtimeRole !== 'hub' || runtimeManager.getSnapshot().authenticated) {
652
+ return { ok: true, skipped: true, reason: 'not-required' };
653
+ }
654
+ const persisted = readPersistedRuntimeSession();
655
+ const normalized = normalizeRuntimeAuthSession(persisted, { requireRefreshToken: true });
656
+ const userId = String(normalized.ok ? normalized.session?.user?.id || '' : '').trim();
657
+ if (!normalized.ok || !userId) {
658
+ return { ok: false, skipped: true, reason: normalized.ok ? 'saved-user-missing' : normalized.error };
659
+ }
660
+ runtimeSessionGeneration += 1;
661
+ runtimeRefreshPromise = null;
662
+ runtimeAccessToken = String(normalized.session.access_token || '').trim();
663
+ runtimeRefreshToken = String(normalized.session.refresh_token || '').trim();
664
+ runtimeAccessTokenExpiresAt = normalizeSessionExpiryMs(normalized.session.expires_at);
665
+ const accessToken = await getRuntimeAccessToken();
666
+ if (!accessToken) throw new Error('saved-hub-session-access-token-unavailable');
667
+ runtimeManager.setAuthenticated(true, userId);
668
+ persistRuntimeSession({
669
+ access_token: runtimeAccessToken,
670
+ refresh_token: runtimeRefreshToken,
671
+ expires_at: Math.floor(runtimeAccessTokenExpiresAt / 1000),
672
+ user: normalized.session.user
673
+ });
674
+ const hostTarget = await publishHubHostTargetWithPendingRoleTakeover('saved-session-restored');
675
+ startHubHostTargetLeaseRenewal();
676
+ console.log(`[LiveDesk Hub] Saved sign-in restored locally; host target publication ${hostTarget?.ok === false ? 'will retry' : 'is active'}.`);
677
+ return { ok: true, skipped: false, hostTarget };
678
+ }
679
+
650
680
  function isLoopbackBindHost(value) {
651
681
  const host = String(value || '').trim().toLowerCase().replace(/^\[|\]$/g, '');
652
682
  return host === '127.0.0.1' || host === 'localhost' || host === '::1';
@@ -6471,6 +6501,9 @@ httpServer.listen(httpPort, httpHost, () => {
6471
6501
  console.log(`[LiveDesk Hub] Version ${managerVersion}`);
6472
6502
  console.log(`[LiveDesk Hub] HTTP API http://${httpHost}:${httpPort}`);
6473
6503
  console.log(`[LiveDesk Hub] Client endpoint ${status.agentEndpoint} pair=${status.pairTokenPreview}`);
6504
+ void restorePersistedHubSessionAtStartup().catch(error => {
6505
+ console.warn(`[LiveDesk Hub] Saved sign-in startup restore deferred to the Hub page: ${error?.message || error}`);
6506
+ });
6474
6507
  });
6475
6508
 
6476
6509
  const roleWatchTimer = runtimeRole === 'hub'
@@ -17,10 +17,18 @@ const HANDSHAKE_TIMEOUT_MS = 5_000;
17
17
  const REPLAY_TTL_MS = 2 * 60 * 1000;
18
18
  const MAX_REPLAY_ENTRIES = 8192;
19
19
  const DEFAULT_MAX_PENDING_HANDSHAKES = 128;
20
- const DEFAULT_MAX_CONNECTIONS_PER_IP_PER_MINUTE = 60;
20
+ // One Client can open control, frame, input, file, and audio channels.
21
+ // A 200-computer site behind one NAT can therefore legitimately create
22
+ // more than 1,000 authenticated handshakes during a coordinated restart.
23
+ // Keep a generous raw-IP burst ceiling; actual abuse blocking below is
24
+ // failure-scoped by device whenever a stable device id is available.
25
+ const DEFAULT_MAX_CONNECTIONS_PER_IP_PER_MINUTE = 2400;
21
26
  const DEFAULT_MAX_CONSECUTIVE_FAILURES = 8;
22
27
  const DEFAULT_IP_BLOCK_MS = 60_000;
23
28
  const MAX_TRACKED_IPS = 4096;
29
+ const MAX_TRACKED_DEVICE_FAILURES = 16384;
30
+ const BLOCKED_IP_RECOVERY_PROBE_MS = 2_000;
31
+ const IP_SCOPED_FAILURES = new Set(['secure-handshake-json-invalid', 'secure-handshake-too-large']);
24
32
 
25
33
  function directError(code) {
26
34
  const error = new Error(code);
@@ -60,6 +68,26 @@ function safeHandshakeError(error) {
60
68
  const code = clean(error?.code || error?.message, 100).toLowerCase();
61
69
  return /^[-a-z0-9]+$/.test(code) ? code : 'secure-handshake-rejected';
62
70
  }
71
+ function normalizeIpAddress(value) {
72
+ return clean(value, 100).replace(/^::ffff:/, '').toLowerCase() || 'unknown';
73
+ }
74
+
75
+ function isPrivateNetworkAddress(value) {
76
+ const address = normalizeIpAddress(value);
77
+ if (address === '127.0.0.1' || address === '::1' || address === 'localhost') return true;
78
+ if (address.startsWith('fe80:') || address.startsWith('fc') || address.startsWith('fd')) return true;
79
+ const parts = address.split('.').map(part => Number(part));
80
+ return parts.length === 4
81
+ && parts.every(part => Number.isInteger(part) && part >= 0 && part <= 255)
82
+ && (parts[0] === 10
83
+ || (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31)
84
+ || (parts[0] === 192 && parts[1] === 168));
85
+ }
86
+
87
+ function failureOwnerKey(ip, deviceId) {
88
+ const normalizedDeviceId = clean(deviceId, 128);
89
+ return normalizedDeviceId ? `${normalizeIpAddress(ip)}|${normalizedDeviceId}` : '';
90
+ }
63
91
 
64
92
  export function createSecureDirectAcceptor({
65
93
  authority,
@@ -77,6 +105,7 @@ export function createSecureDirectAcceptor({
77
105
  }
78
106
  const replayCache = new Map();
79
107
  const ipStates = new Map();
108
+ const deviceFailureStates = new Map();
80
109
  const maxPendingHandshakes = Math.max(1, Number(abuseLimits.maxPendingHandshakes)
81
110
  || DEFAULT_MAX_PENDING_HANDSHAKES);
82
111
  const maxConnectionsPerIpPerMinute = Math.max(1, Number(abuseLimits.maxConnectionsPerIpPerMinute)
@@ -93,10 +122,13 @@ export function createSecureDirectAcceptor({
93
122
  let capacityRejections = 0;
94
123
  let rateLimitRejections = 0;
95
124
  let blockedIpRejections = 0;
125
+ let blockedDeviceRejections = 0;
126
+ let compatibilityRejections = 0;
127
+ let recoveryProbeAdmissions = 0;
96
128
  let probeClosures = 0;
97
129
 
98
130
  function remoteAddress(rawSocket) {
99
- return clean(rawSocket?.remoteAddress, 100).replace(/^::ffff:/, '') || 'unknown';
131
+ return normalizeIpAddress(rawSocket?.remoteAddress);
100
132
  }
101
133
 
102
134
  function pruneIpStates(current) {
@@ -121,6 +153,7 @@ export function createSecureDirectAcceptor({
121
153
  connectionCount: 0,
122
154
  consecutiveFailures: 0,
123
155
  blockedUntil: 0,
156
+ nextRecoveryProbeAt: 0,
124
157
  lastSeenAt: current
125
158
  };
126
159
  ipStates.set(ip, state);
@@ -133,11 +166,51 @@ export function createSecureDirectAcceptor({
133
166
  return state;
134
167
  }
135
168
 
169
+ function pruneDeviceFailureStates(current) {
170
+ for (const [key, state] of deviceFailureStates) {
171
+ if (current - state.lastSeenAt >= Math.max(ipBlockMs * 2, 5 * 60_000)) {
172
+ deviceFailureStates.delete(key);
173
+ }
174
+ }
175
+ while (deviceFailureStates.size > MAX_TRACKED_DEVICE_FAILURES) {
176
+ const oldest = deviceFailureStates.keys().next().value;
177
+ if (!oldest) break;
178
+ deviceFailureStates.delete(oldest);
179
+ }
180
+ }
181
+
182
+ function getDeviceFailureState(ip, deviceId, current) {
183
+ const key = failureOwnerKey(ip, deviceId);
184
+ if (!key) return null;
185
+ pruneDeviceFailureStates(current);
186
+ let state = deviceFailureStates.get(key);
187
+ if (!state) {
188
+ state = { consecutiveFailures: 0, blockedUntil: 0, lastSeenAt: current };
189
+ deviceFailureStates.set(key, state);
190
+ }
191
+ state.lastSeenAt = current;
192
+ return state;
193
+ }
194
+
195
+ function deviceAdmissionError(ip, deviceId, current) {
196
+ const state = getDeviceFailureState(ip, deviceId, current);
197
+ if (state?.blockedUntil > current) {
198
+ blockedDeviceRejections += 1;
199
+ return 'secure-device-temporarily-blocked';
200
+ }
201
+ return '';
202
+ }
203
+
136
204
  function admissionError(ip, current) {
137
205
  const state = getIpState(ip, current);
138
206
  if (state.blockedUntil > current) {
139
- blockedIpRejections += 1;
140
- return 'secure-ip-temporarily-blocked';
207
+ if (state.nextRecoveryProbeAt <= current) {
208
+ state.nextRecoveryProbeAt = current + BLOCKED_IP_RECOVERY_PROBE_MS;
209
+ recoveryProbeAdmissions += 1;
210
+ } else {
211
+ blockedIpRejections += 1;
212
+ return 'secure-ip-temporarily-blocked';
213
+ }
141
214
  }
142
215
  if (pendingHandshakes >= maxPendingHandshakes) {
143
216
  capacityRejections += 1;
@@ -151,18 +224,39 @@ export function createSecureDirectAcceptor({
151
224
  return '';
152
225
  }
153
226
 
154
- function recordFailure(ip, current) {
227
+ function recordFailure(ip, deviceId, code, current) {
228
+ if (code === 'secure-client-hello-required' && isPrivateNetworkAddress(ip)) {
229
+ compatibilityRejections += 1;
230
+ return;
231
+ }
232
+ const deviceState = !IP_SCOPED_FAILURES.has(code)
233
+ ? getDeviceFailureState(ip, deviceId, current)
234
+ : null;
235
+ if (deviceState) {
236
+ deviceState.consecutiveFailures += 1;
237
+ if (deviceState.consecutiveFailures >= maxConsecutiveFailures) {
238
+ deviceState.blockedUntil = Math.max(deviceState.blockedUntil, current + ipBlockMs);
239
+ }
240
+ return;
241
+ }
155
242
  const state = getIpState(ip, current);
156
243
  state.consecutiveFailures += 1;
157
244
  if (state.consecutiveFailures >= maxConsecutiveFailures) {
158
245
  state.blockedUntil = Math.max(state.blockedUntil, current + ipBlockMs);
246
+ state.nextRecoveryProbeAt = current + BLOCKED_IP_RECOVERY_PROBE_MS;
159
247
  }
160
248
  }
161
249
 
162
- function recordSuccess(ip, current) {
250
+ function recordSuccess(ip, deviceId, current) {
163
251
  const state = getIpState(ip, current);
164
252
  state.consecutiveFailures = 0;
165
253
  state.blockedUntil = 0;
254
+ state.nextRecoveryProbeAt = 0;
255
+ const deviceState = getDeviceFailureState(ip, deviceId, current);
256
+ if (deviceState) {
257
+ deviceState.consecutiveFailures = 0;
258
+ deviceState.blockedUntil = 0;
259
+ }
166
260
  }
167
261
 
168
262
  function rejectSocket(rawSocket, code, ip, observedDeviceId = '') {
@@ -360,12 +454,12 @@ export function createSecureDirectAcceptor({
360
454
  settled = true;
361
455
  cleanup();
362
456
  const code = safeHandshakeError(error);
363
- if (code === 'secure-handshake-closed' && bytes === 0) {
457
+ if ((code === 'secure-handshake-closed' || code === 'secure-handshake-timeout') && bytes === 0) {
364
458
  probeClosures += 1;
365
459
  try { rawSocket.destroy(); } catch {}
366
460
  return;
367
461
  }
368
- recordFailure(ip, Math.floor(now()));
462
+ recordFailure(ip, observedDeviceId, code, Math.floor(now()));
369
463
  rejectSocket(rawSocket, code, ip, observedDeviceId);
370
464
  };
371
465
 
@@ -390,7 +484,12 @@ export function createSecureDirectAcceptor({
390
484
  let hello;
391
485
  try {
392
486
  hello = JSON.parse(combined.subarray(0, newline).toString('utf8'));
393
- observedDeviceId = clean(hello?.deviceId, 128);
487
+ observedDeviceId = clean(hello?.deviceId, 128); const deviceDenied = deviceAdmissionError(ip, observedDeviceId, Math.floor(now()));
488
+ if (deviceDenied) {
489
+ settled = false;
490
+ fail(directError(deviceDenied));
491
+ return;
492
+ }
394
493
  } catch {
395
494
  settled = false;
396
495
  fail(directError('secure-handshake-json-invalid'));
@@ -398,7 +497,7 @@ export function createSecureDirectAcceptor({
398
497
  }
399
498
  try {
400
499
  handleHello(rawSocket, hello, combined.subarray(newline + 1));
401
- recordSuccess(ip, Math.floor(now()));
500
+ recordSuccess(ip, observedDeviceId, Math.floor(now()));
402
501
  } catch (error) {
403
502
  // settled is reset only for the common failure path so it can emit the
404
503
  // bounded rejection response and release the raw socket.
@@ -424,9 +523,13 @@ export function createSecureDirectAcceptor({
424
523
  replayRejections,
425
524
  pendingHandshakes,
426
525
  trackedIpCount: ipStates.size,
526
+ trackedDeviceFailureCount: deviceFailureStates.size,
427
527
  capacityRejections,
428
528
  rateLimitRejections,
429
529
  blockedIpRejections,
530
+ blockedDeviceRejections,
531
+ compatibilityRejections,
532
+ recoveryProbeAdmissions,
430
533
  probeClosures,
431
534
  limits: {
432
535
  maxPendingHandshakes,