@livedesk/hub 0.1.32 → 0.1.34

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/src/server.js CHANGED
@@ -39,17 +39,17 @@ import { createAgentDeviceScope, resolveAgentTargetIds } from './agents/agent-de
39
39
  import { AgentRuntimeError } from './agents/agent-runtime-error.js';
40
40
  import { AGENT_PERMISSION_MODES, createAgentPermissionPolicy, evaluateAgentToolPermission, hashAgentPermissionPolicy } from './agents/agent-permissions.js';
41
41
  import { createAgentPermissionStore } from './agents/agent-permission-store.js';
42
- import { createAgentAuditStore } from './agents/agent-audit-store.js';
43
- import { createSecurityAuditStore } from './security/security-audit-store.js';
44
- import { getAgentToolDefinition, isRetiredAgentMutatingToolName } from './agents/agent-tool-registry.js';
42
+ import { createAgentAuditStore } from './agents/agent-audit-store.js';
43
+ import { createSecurityAuditStore } from './security/security-audit-store.js';
44
+ import { getAgentToolDefinition, isRetiredAgentMutatingToolName } from './agents/agent-tool-registry.js';
45
45
  import { LiveDeskSettingsStore, SettingsConflictError } from './settings/settings-store.js';
46
46
  import { effectiveDevicePolicy } from './settings/settings-schema.js';
47
47
  import { buildEffectiveDevicePolicy } from './settings/effective-device-policy.js';
48
48
  import { CaptureStore } from './captures/capture-store.js';
49
49
  import { createLiveDeskUpdateManager } from './live-desk-update.js';
50
50
  import { createHubUdpTransport } from './transport/udp-hub-transport.js';
51
- import { normalizeRuntimeAuthSession, runtimeRoleError } from '../../runtime-core/src/index.js';
52
- import { createOsSecretStore, OS_SECRET_REFERENCE } from '../../runtime-core/src/os-secret-store.js';
51
+ import { normalizeRuntimeAuthSession, runtimeRoleError } from '../../runtime-core/src/index.js';
52
+ import { createOsSecretStore, OS_SECRET_REFERENCE } from '../../runtime-core/src/os-secret-store.js';
53
53
  import { fetchAuthResponse, AUTH_REQUEST_TIMEOUT_MS } from '../../runtime-core/src/auth-http.js';
54
54
  import { PRODUCTION_SUPABASE_PUBLISHABLE_KEY, PRODUCTION_SUPABASE_URL } from '../../runtime-core/src/auth-config.js';
55
55
  import { createHubRuntime } from './runtime/hub-runtime.js';
@@ -65,20 +65,20 @@ const webDistCandidates = [
65
65
  const webDistPath = webDistCandidates.find(candidate => existsSync(resolve(candidate, 'index.html'))) || webDistCandidates[webDistCandidates.length - 1];
66
66
  const webIndexPath = resolve(webDistPath, 'index.html');
67
67
  const packageInfo = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf8'));
68
- const httpHost = process.env.LIVEDESK_HUB_HTTP_HOST || '127.0.0.1';
69
- const httpPort = Number(process.env.LIVEDESK_HUB_HTTP_PORT || process.env.PORT || 5179);
70
- if (!isLoopbackBindHost(httpHost) && process.env.LIVEDESK_TEST_MODE !== '1') {
71
- throw new Error('hub-http-lan-bind-requires-tls-authenticated-proxy');
72
- }
73
- const localAdminSessionToken = crypto.randomBytes(32).toString('base64url');
74
- const localAdminCsrfToken = crypto.randomBytes(32).toString('base64url');
75
- const localAdminSessionId = crypto.randomUUID();
76
- const launcherShutdownToken = /^[A-Za-z0-9_-]{43}$/.test(
77
- String(process.env.LIVEDESK_LAUNCHER_SHUTDOWN_TOKEN || '').trim()
78
- )
79
- ? String(process.env.LIVEDESK_LAUNCHER_SHUTDOWN_TOKEN).trim()
80
- : '';
81
- const enforceLocalAdminAuth = process.env.LIVEDESK_TEST_MODE !== '1';
68
+ const httpHost = process.env.LIVEDESK_HUB_HTTP_HOST || '127.0.0.1';
69
+ const httpPort = Number(process.env.LIVEDESK_HUB_HTTP_PORT || process.env.PORT || 5179);
70
+ if (!isLoopbackBindHost(httpHost) && process.env.LIVEDESK_TEST_MODE !== '1') {
71
+ throw new Error('hub-http-lan-bind-requires-tls-authenticated-proxy');
72
+ }
73
+ const localAdminSessionToken = crypto.randomBytes(32).toString('base64url');
74
+ const localAdminCsrfToken = crypto.randomBytes(32).toString('base64url');
75
+ const localAdminSessionId = crypto.randomUUID();
76
+ const launcherShutdownToken = /^[A-Za-z0-9_-]{43}$/.test(
77
+ String(process.env.LIVEDESK_LAUNCHER_SHUTDOWN_TOKEN || '').trim()
78
+ )
79
+ ? String(process.env.LIVEDESK_LAUNCHER_SHUTDOWN_TOKEN).trim()
80
+ : '';
81
+ const enforceLocalAdminAuth = process.env.LIVEDESK_TEST_MODE !== '1';
82
82
  const runtimeRole = String(process.env.LIVEDESK_RUNTIME_ROLE || 'hub').trim().toLowerCase() === 'client' ? 'client' : 'hub';
83
83
  const runtimeDeviceId = String(process.env.LIVEDESK_DEVICE_ID || '').trim();
84
84
  const runtimeDeviceName = String(process.env.LIVEDESK_DEVICE_NAME || os.hostname()).trim() || os.hostname();
@@ -179,21 +179,21 @@ const SUPABASE_AUTH_TIMEOUT_MS = process.env.LIVEDESK_AUTH_TEST_MODE === '1'
179
179
  ? readPositiveIntegerEnv('LIVEDESK_AUTH_TEST_TIMEOUT_MS', AUTH_REQUEST_TIMEOUT_MS)
180
180
  : AUTH_REQUEST_TIMEOUT_MS;
181
181
  const CLIENT_AUTH_STORAGE_KEY = 'livedesk.client.supabase.auth';
182
- const runtimeAuthStatePath = process.env.LIVEDESK_DESKTOP_HOST === '1'
182
+ const runtimeAuthStatePath = process.env.LIVEDESK_DESKTOP_HOST === '1'
183
183
  ? ''
184
- : String(process.env.LIVEDESK_AUTH_STATE_PATH || '').trim();
185
- const runtimeRefreshSecretStore = createOsSecretStore({
186
- service: 'LiveDesk',
187
- account: 'client-refresh-token',
188
- dataDir: process.env.LIVEDESK_STATE_DIR || (runtimeAuthStatePath ? dirname(runtimeAuthStatePath) : undefined)
189
- });
190
- const testLicensePlan = process.env.LIVEDESK_TEST_MODE === '1'
184
+ : String(process.env.LIVEDESK_AUTH_STATE_PATH || '').trim();
185
+ const runtimeRefreshSecretStore = createOsSecretStore({
186
+ service: 'LiveDesk',
187
+ account: 'client-refresh-token',
188
+ dataDir: process.env.LIVEDESK_STATE_DIR || (runtimeAuthStatePath ? dirname(runtimeAuthStatePath) : undefined)
189
+ });
190
+ const testLicensePlan = process.env.LIVEDESK_TEST_MODE === '1'
191
191
  && ['ltd', 'pro'].includes(String(process.env.LIVEDESK_TEST_LICENSE_PLAN || '').toLowerCase())
192
- ? String(process.env.LIVEDESK_TEST_LICENSE_PLAN).toLowerCase()
193
- : '';
194
- const testSecurityAccountId = process.env.LIVEDESK_TEST_MODE === '1'
195
- ? String(process.env.LIVEDESK_ACCOUNT_ID || '').trim().slice(0, 128)
196
- : '';
192
+ ? String(process.env.LIVEDESK_TEST_LICENSE_PLAN).toLowerCase()
193
+ : '';
194
+ const testSecurityAccountId = process.env.LIVEDESK_TEST_MODE === '1'
195
+ ? String(process.env.LIVEDESK_ACCOUNT_ID || '').trim().slice(0, 128)
196
+ : '';
197
197
  const persistentSessionGcEnabled =
198
198
  process.env.LIVEDESK_TEST_MODE === '1'
199
199
  && process.env.LIVEDESK_PERSISTENT_SESSION_TEST_GC === '1';
@@ -203,12 +203,12 @@ const traceRemoteTestEventsEnabled =
203
203
  const persistentSessionGcToken = persistentSessionGcEnabled
204
204
  ? String(process.env.LIVEDESK_PERSISTENT_SESSION_TEST_TOKEN || '').trim()
205
205
  : '';
206
- let connectedDeviceCount = 0;
207
- let runtimeAccessToken = '';
208
- let runtimeRefreshToken = '';
209
- let runtimeAccessTokenExpiresAt = 0;
210
- let runtimeSessionGeneration = 0;
211
- let runtimeRefreshPromise = null;
206
+ let connectedDeviceCount = 0;
207
+ let runtimeAccessToken = '';
208
+ let runtimeRefreshToken = '';
209
+ let runtimeAccessTokenExpiresAt = 0;
210
+ let runtimeSessionGeneration = 0;
211
+ let runtimeRefreshPromise = null;
212
212
  let roleWatchInFlight = false;
213
213
  let verifiedLicense = {
214
214
  userId: '',
@@ -220,10 +220,10 @@ let verifiedLicense = {
220
220
  let frameClientSeq = 0;
221
221
  let inputClientSeq = 0;
222
222
  let audioClientSeq = 0;
223
- let liveDeskUpdateManager = null;
224
- let hubTransferJobs = null;
225
- let securityAuditStore = null;
226
- let securityAuditHealthy = true;
223
+ let liveDeskUpdateManager = null;
224
+ let hubTransferJobs = null;
225
+ let securityAuditStore = null;
226
+ let securityAuditHealthy = true;
227
227
  const HUB_HOST_TARGET_LEASE_MS = Math.max(5000, readPositiveIntegerEnv('LIVEDESK_HOST_TARGET_LEASE_MS', 60_000));
228
228
  const HUB_HOST_TARGET_RENEW_MS = Math.max(1000, Math.min(
229
229
  readPositiveIntegerEnv('LIVEDESK_HOST_TARGET_RENEW_MS', 20_000),
@@ -254,7 +254,7 @@ let hubWakeNotificationState = {
254
254
  };
255
255
  let hubWakeNextAttemptAtMs = 0;
256
256
 
257
- function readPositiveIntegerEnv(name, fallback) {
257
+ function readPositiveIntegerEnv(name, fallback) {
258
258
  const value = Number(process.env[name]);
259
259
  return Number.isFinite(value) && value > 0 ? Math.round(value) : fallback;
260
260
  }
@@ -267,42 +267,42 @@ function secureExactTestTokenMatches(actual, expected) {
267
267
  && crypto.timingSafeEqual(actualBytes, expectedBytes);
268
268
  }
269
269
 
270
- function handleRemoteHubEvent(type, event) {
271
- liveDeskUpdateManager?.handleRemoteEvent(type, event);
272
- hubTransferJobs?.handleRemoteEvent(type, event);
273
- const auditedRemoteActions = {
274
- RemoteSecurityHandshakeAudit: event?.action || 'remote.handshake',
275
- RemoteSecurityPolicyAudit: 'remote.connection-policy',
276
- RemoteAbuseDefense: 'remote.abuse-defense',
277
- RemoteDeviceConnected: 'remote.device.connected',
278
- RemoteDeviceDisconnected: 'remote.device.disconnected',
279
- RemoteInputSocketConnected: 'remote.control.channel-started',
280
- RemoteInputSocketDisconnected: 'remote.control.channel-ended',
281
- RemoteControlSessionStarted: 'remote.control.session-started',
282
- RemoteControlSessionEnded: 'remote.control.session-ended',
283
- RemoteFileSocketConnected: 'remote.file.channel-started',
284
- RemoteFileSocketDisconnected: 'remote.file.channel-ended',
285
- RemoteEnrollmentTokenRotated: 'remote.enrollment-token.rotated'
286
- };
287
- if (auditedRemoteActions[type]) {
288
- const device = event?.device || {};
289
- recordSecurityAudit({
290
- action: auditedRemoteActions[type],
291
- phase: /Disconnected|ended|rotated/i.test(`${type}:${auditedRemoteActions[type]}`) ? 'complete' : 'start',
292
- result: event?.result || (event?.reason && /RemoteSecurity(?:Handshake|Policy)Audit|RemoteAbuseDefense/.test(type) ? 'rejected' : 'success'),
293
- reason: event?.reason || '',
294
- actorAccountId: event?.accountId || '',
295
- hubId: event?.hubId || event?.remoteHub?.hostInstanceId || '',
296
- deviceId: event?.deviceId || device.deviceId || '',
297
- sessionId: event?.sessionId || device.sessionId || '',
298
- authMethod: /RemoteSecurity(?:Handshake|Policy)Audit|RemoteAbuseDefense/.test(type) ? 'device-credential-p256' : 'authenticated-device-session',
299
- details: {
300
- transport: event?.transport || device.transport || '',
301
- channel: event?.channel || '',
302
- credentialSerial: event?.credentialSerial || ''
303
- }
304
- });
305
- }
270
+ function handleRemoteHubEvent(type, event) {
271
+ liveDeskUpdateManager?.handleRemoteEvent(type, event);
272
+ hubTransferJobs?.handleRemoteEvent(type, event);
273
+ const auditedRemoteActions = {
274
+ RemoteSecurityHandshakeAudit: event?.action || 'remote.handshake',
275
+ RemoteSecurityPolicyAudit: 'remote.connection-policy',
276
+ RemoteAbuseDefense: 'remote.abuse-defense',
277
+ RemoteDeviceConnected: 'remote.device.connected',
278
+ RemoteDeviceDisconnected: 'remote.device.disconnected',
279
+ RemoteInputSocketConnected: 'remote.control.channel-started',
280
+ RemoteInputSocketDisconnected: 'remote.control.channel-ended',
281
+ RemoteControlSessionStarted: 'remote.control.session-started',
282
+ RemoteControlSessionEnded: 'remote.control.session-ended',
283
+ RemoteFileSocketConnected: 'remote.file.channel-started',
284
+ RemoteFileSocketDisconnected: 'remote.file.channel-ended',
285
+ RemoteEnrollmentTokenRotated: 'remote.enrollment-token.rotated'
286
+ };
287
+ if (auditedRemoteActions[type]) {
288
+ const device = event?.device || {};
289
+ recordSecurityAudit({
290
+ action: auditedRemoteActions[type],
291
+ phase: /Disconnected|ended|rotated/i.test(`${type}:${auditedRemoteActions[type]}`) ? 'complete' : 'start',
292
+ result: event?.result || (event?.reason && /RemoteSecurity(?:Handshake|Policy)Audit|RemoteAbuseDefense/.test(type) ? 'rejected' : 'success'),
293
+ reason: event?.reason || '',
294
+ actorAccountId: event?.accountId || '',
295
+ hubId: event?.hubId || event?.remoteHub?.hostInstanceId || '',
296
+ deviceId: event?.deviceId || device.deviceId || '',
297
+ sessionId: event?.sessionId || device.sessionId || '',
298
+ authMethod: /RemoteSecurity(?:Handshake|Policy)Audit|RemoteAbuseDefense/.test(type) ? 'device-credential-p256' : 'authenticated-device-session',
299
+ details: {
300
+ transport: event?.transport || device.transport || '',
301
+ channel: event?.channel || '',
302
+ credentialSerial: event?.credentialSerial || ''
303
+ }
304
+ });
305
+ }
306
306
  if (traceRemoteTestEventsEnabled
307
307
  && (type === 'RemoteFrameDropped' || type === 'RemoteFrameTransportProofAccepted')) {
308
308
  console.warn(`[LiveDesk Hub Test Event] ${type} ${JSON.stringify({
@@ -349,16 +349,16 @@ function handleRemoteHubEvent(type, event) {
349
349
  }
350
350
  return;
351
351
  }
352
- if (type === 'RemoteDeviceConnected' || type === 'RemoteDeviceDisconnected') {
353
- connectedDeviceCount = Number(remoteHub.getStatus({ includeSecrets: false }).connectedDeviceCount || 0);
354
- }
355
- if (type === 'RemoteEnrollmentTokenRotated') {
356
- void publishHubHostTargetWithPendingRoleTakeover('enrollment-token-rotated').catch(error => {
357
- console.warn(`[LiveDesk Hub] Enrollment token publication failed: ${error?.message || error}`);
358
- });
359
- return;
360
- }
361
- if (type !== 'RemoteDeviceConnected') {
352
+ if (type === 'RemoteDeviceConnected' || type === 'RemoteDeviceDisconnected') {
353
+ connectedDeviceCount = Number(remoteHub.getStatus({ includeSecrets: false }).connectedDeviceCount || 0);
354
+ }
355
+ if (type === 'RemoteEnrollmentTokenRotated') {
356
+ void publishHubHostTargetWithPendingRoleTakeover('enrollment-token-rotated').catch(error => {
357
+ console.warn(`[LiveDesk Hub] Enrollment token publication failed: ${error?.message || error}`);
358
+ });
359
+ return;
360
+ }
361
+ if (type !== 'RemoteDeviceConnected') {
362
362
  return;
363
363
  }
364
364
  const deviceId = String(event?.deviceId || event?.device?.deviceId || '').trim();
@@ -490,23 +490,23 @@ function readRuntimeAuthState() {
490
490
  }
491
491
  }
492
492
 
493
- function readPersistedRuntimeSession(state = readRuntimeAuthState()) {
493
+ function readPersistedRuntimeSession(state = readRuntimeAuthState()) {
494
494
  try {
495
495
  const raw = state?.[CLIENT_AUTH_STORAGE_KEY];
496
496
  const session = typeof raw === 'string' ? JSON.parse(raw) : raw;
497
- if (!session || typeof session !== 'object') return null;
498
- const plaintextRefreshToken = String(session.refresh_token || '').trim();
499
- const refreshToken = plaintextRefreshToken || (session.refresh_token_ref === OS_SECRET_REFERENCE
500
- ? runtimeRefreshSecretStore.read()
501
- : '');
502
- if (plaintextRefreshToken) {
503
- if (!runtimeRefreshSecretStore.write(plaintextRefreshToken)) return null;
504
- const migrated = { ...session, refresh_token_ref: OS_SECRET_REFERENCE };
505
- delete migrated.refresh_token;
506
- state[CLIENT_AUTH_STORAGE_KEY] = JSON.stringify(migrated);
507
- writePrivateRuntimeAuthState(state);
508
- }
509
- return { ...session, refresh_token: refreshToken };
497
+ if (!session || typeof session !== 'object') return null;
498
+ const plaintextRefreshToken = String(session.refresh_token || '').trim();
499
+ const refreshToken = plaintextRefreshToken || (session.refresh_token_ref === OS_SECRET_REFERENCE
500
+ ? runtimeRefreshSecretStore.read()
501
+ : '');
502
+ if (plaintextRefreshToken) {
503
+ if (!runtimeRefreshSecretStore.write(plaintextRefreshToken)) return null;
504
+ const migrated = { ...session, refresh_token_ref: OS_SECRET_REFERENCE };
505
+ delete migrated.refresh_token;
506
+ state[CLIENT_AUTH_STORAGE_KEY] = JSON.stringify(migrated);
507
+ writePrivateRuntimeAuthState(state);
508
+ }
509
+ return { ...session, refresh_token: refreshToken };
510
510
  } catch {
511
511
  return null;
512
512
  }
@@ -549,34 +549,34 @@ function persistRuntimeSession(session) {
549
549
  ...(session?.user && typeof session.user === 'object' ? session.user : {})
550
550
  }
551
551
  }, { requireRefreshToken: true });
552
- if (!normalized.ok) return false;
553
- if (!runtimeRefreshSecretStore.write(normalized.session.refresh_token)) return false;
554
- const persisted = { ...normalized.session, refresh_token_ref: OS_SECRET_REFERENCE };
555
- delete persisted.refresh_token;
556
- state[CLIENT_AUTH_STORAGE_KEY] = JSON.stringify(persisted);
552
+ if (!normalized.ok) return false;
553
+ if (!runtimeRefreshSecretStore.write(normalized.session.refresh_token)) return false;
554
+ const persisted = { ...normalized.session, refresh_token_ref: OS_SECRET_REFERENCE };
555
+ delete persisted.refresh_token;
556
+ state[CLIENT_AUTH_STORAGE_KEY] = JSON.stringify(persisted);
557
557
  writePrivateRuntimeAuthState(state);
558
558
  return true;
559
559
  }
560
560
 
561
- function clearPersistedRuntimeSession() {
561
+ function clearPersistedRuntimeSession() {
562
562
  if (!runtimeAuthStatePath) return false;
563
563
  const state = readRuntimeAuthState();
564
- delete state[CLIENT_AUTH_STORAGE_KEY];
565
- runtimeRefreshSecretStore.clear();
564
+ delete state[CLIENT_AUTH_STORAGE_KEY];
565
+ runtimeRefreshSecretStore.clear();
566
566
  return writePrivateRuntimeAuthState(state);
567
567
  }
568
568
 
569
- function clearRuntimeSession() {
570
- runtimeSessionGeneration += 1;
571
- runtimeRefreshPromise = null;
572
- runtimeAccessToken = '';
573
- runtimeRefreshToken = '';
569
+ function clearRuntimeSession() {
570
+ runtimeSessionGeneration += 1;
571
+ runtimeRefreshPromise = null;
572
+ runtimeAccessToken = '';
573
+ runtimeRefreshToken = '';
574
574
  runtimeAccessTokenExpiresAt = 0;
575
575
  runtimeManager.setAuthenticated(false);
576
576
  try { clearPersistedRuntimeSession(); } catch { /* runtime auth is already invalid */ }
577
577
  }
578
578
 
579
- async function getRuntimeAccessToken() {
579
+ async function getRuntimeAccessToken() {
580
580
  const accessToken = String(runtimeAccessToken || '').trim();
581
581
  const expiresSoon = runtimeAccessTokenExpiresAt > 0
582
582
  && runtimeAccessTokenExpiresAt <= Date.now() + HUB_ACCESS_TOKEN_REFRESH_SKEW_MS;
@@ -585,112 +585,112 @@ async function getRuntimeAccessToken() {
585
585
  }
586
586
 
587
587
  const refreshToken = String(runtimeRefreshToken || '').trim();
588
- if (!refreshToken) {
589
- return accessToken;
590
- }
591
-
592
- if (runtimeRefreshPromise) return runtimeRefreshPromise;
593
-
594
- const ownerGeneration = runtimeSessionGeneration;
595
- const refreshOperation = (async () => {
596
- const response = await fetchAuthResponse(
597
- fetch,
598
- `${supabaseUrl}/auth/v1/token?grant_type=refresh_token`,
599
- {
600
- method: 'POST',
601
- headers: {
602
- apikey: supabasePublishableKey,
603
- 'Content-Type': 'application/json',
604
- Accept: 'application/json'
605
- },
606
- body: JSON.stringify({ refresh_token: refreshToken })
607
- },
608
- SUPABASE_AUTH_TIMEOUT_MS
609
- );
610
- if (!response.ok) {
611
- if (response.status === 400 || response.status === 401 || response.status === 403) {
612
- if (runtimeSessionGeneration === ownerGeneration && runtimeRefreshToken === refreshToken) {
613
- clearRuntimeSession();
614
- }
615
- throw new Error(`hub-session-refresh-failed:${response.status}`);
616
- }
617
- throw new Error(`hub-session-refresh-provider-failed:${response.status}`);
618
- }
619
- const refreshed = await response.json().catch(() => null);
620
- const nextAccessToken = String(refreshed?.access_token || '').trim();
621
- if (!nextAccessToken) {
622
- throw new Error('hub-session-refresh-missing-access-token');
623
- }
624
- if (runtimeSessionGeneration !== ownerGeneration || runtimeRefreshToken !== refreshToken) {
625
- throw new Error('hub-session-refresh-owner-retired');
626
- }
627
- runtimeAccessToken = nextAccessToken;
628
- runtimeRefreshToken = String(refreshed?.refresh_token || refreshToken).trim();
629
- runtimeAccessTokenExpiresAt = normalizeSessionExpiryMs(refreshed?.expires_at)
630
- || (Number(refreshed?.expires_in) > 0 ? Date.now() + Number(refreshed.expires_in) * 1000 : 0);
631
- try {
632
- persistRuntimeSession({
633
- access_token: runtimeAccessToken,
634
- refresh_token: runtimeRefreshToken,
635
- expires_at: Math.floor(runtimeAccessTokenExpiresAt / 1000)
636
- });
637
- } catch (error) {
638
- console.warn(`[LiveDesk Hub] Refreshed session persistence failed: ${error?.message || error}`);
639
- }
640
- return runtimeAccessToken;
641
- })();
642
- runtimeRefreshPromise = refreshOperation;
643
- try {
644
- return await refreshOperation;
645
- } finally {
646
- if (runtimeRefreshPromise === refreshOperation) runtimeRefreshPromise = null;
647
- }
648
- }
649
-
650
- function isLoopbackBindHost(value) {
651
- const host = String(value || '').trim().toLowerCase().replace(/^\[|\]$/g, '');
652
- return host === '127.0.0.1' || host === 'localhost' || host === '::1';
653
- }
654
-
655
- async function revokeRuntimeProviderSession() {
656
- const hasRuntimeSession = Boolean(runtimeAccessToken || runtimeRefreshToken);
657
- if (!hasRuntimeSession) return { ok: true, skipped: true, reason: 'no-runtime-session' };
658
-
659
- let accessToken = String(runtimeAccessToken || '').trim();
660
- try {
661
- accessToken = await getRuntimeAccessToken() || accessToken;
662
- } catch (error) {
663
- const message = String(error?.message || error);
664
- if (/^hub-session-refresh-failed:(?:400|401|403)$/.test(message)) {
665
- return { ok: true, alreadyInvalid: true };
666
- }
667
- return { ok: false, error: message };
668
- }
669
- if (!accessToken) return { ok: false, error: 'provider-session-token-unavailable' };
670
-
671
- try {
672
- const response = await fetchAuthResponse(
673
- fetch,
674
- `${supabaseUrl}/auth/v1/logout?scope=local`,
675
- {
676
- method: 'POST',
677
- headers: {
678
- apikey: supabasePublishableKey,
679
- Authorization: `Bearer ${accessToken}`,
680
- Accept: 'application/json'
681
- }
682
- },
683
- SUPABASE_AUTH_TIMEOUT_MS
684
- );
685
- if (response.ok) return { ok: true, revoked: true };
686
- if ([401, 403, 404].includes(response.status)) {
687
- return { ok: true, alreadyInvalid: true, status: response.status };
688
- }
689
- return { ok: false, error: `provider-session-revoke-failed:${response.status}` };
690
- } catch (error) {
691
- return { ok: false, error: String(error?.message || error) };
692
- }
693
- }
588
+ if (!refreshToken) {
589
+ return accessToken;
590
+ }
591
+
592
+ if (runtimeRefreshPromise) return runtimeRefreshPromise;
593
+
594
+ const ownerGeneration = runtimeSessionGeneration;
595
+ const refreshOperation = (async () => {
596
+ const response = await fetchAuthResponse(
597
+ fetch,
598
+ `${supabaseUrl}/auth/v1/token?grant_type=refresh_token`,
599
+ {
600
+ method: 'POST',
601
+ headers: {
602
+ apikey: supabasePublishableKey,
603
+ 'Content-Type': 'application/json',
604
+ Accept: 'application/json'
605
+ },
606
+ body: JSON.stringify({ refresh_token: refreshToken })
607
+ },
608
+ SUPABASE_AUTH_TIMEOUT_MS
609
+ );
610
+ if (!response.ok) {
611
+ if (response.status === 400 || response.status === 401 || response.status === 403) {
612
+ if (runtimeSessionGeneration === ownerGeneration && runtimeRefreshToken === refreshToken) {
613
+ clearRuntimeSession();
614
+ }
615
+ throw new Error(`hub-session-refresh-failed:${response.status}`);
616
+ }
617
+ throw new Error(`hub-session-refresh-provider-failed:${response.status}`);
618
+ }
619
+ const refreshed = await response.json().catch(() => null);
620
+ const nextAccessToken = String(refreshed?.access_token || '').trim();
621
+ if (!nextAccessToken) {
622
+ throw new Error('hub-session-refresh-missing-access-token');
623
+ }
624
+ if (runtimeSessionGeneration !== ownerGeneration || runtimeRefreshToken !== refreshToken) {
625
+ throw new Error('hub-session-refresh-owner-retired');
626
+ }
627
+ runtimeAccessToken = nextAccessToken;
628
+ runtimeRefreshToken = String(refreshed?.refresh_token || refreshToken).trim();
629
+ runtimeAccessTokenExpiresAt = normalizeSessionExpiryMs(refreshed?.expires_at)
630
+ || (Number(refreshed?.expires_in) > 0 ? Date.now() + Number(refreshed.expires_in) * 1000 : 0);
631
+ try {
632
+ persistRuntimeSession({
633
+ access_token: runtimeAccessToken,
634
+ refresh_token: runtimeRefreshToken,
635
+ expires_at: Math.floor(runtimeAccessTokenExpiresAt / 1000)
636
+ });
637
+ } catch (error) {
638
+ console.warn(`[LiveDesk Hub] Refreshed session persistence failed: ${error?.message || error}`);
639
+ }
640
+ return runtimeAccessToken;
641
+ })();
642
+ runtimeRefreshPromise = refreshOperation;
643
+ try {
644
+ return await refreshOperation;
645
+ } finally {
646
+ if (runtimeRefreshPromise === refreshOperation) runtimeRefreshPromise = null;
647
+ }
648
+ }
649
+
650
+ function isLoopbackBindHost(value) {
651
+ const host = String(value || '').trim().toLowerCase().replace(/^\[|\]$/g, '');
652
+ return host === '127.0.0.1' || host === 'localhost' || host === '::1';
653
+ }
654
+
655
+ async function revokeRuntimeProviderSession() {
656
+ const hasRuntimeSession = Boolean(runtimeAccessToken || runtimeRefreshToken);
657
+ if (!hasRuntimeSession) return { ok: true, skipped: true, reason: 'no-runtime-session' };
658
+
659
+ let accessToken = String(runtimeAccessToken || '').trim();
660
+ try {
661
+ accessToken = await getRuntimeAccessToken() || accessToken;
662
+ } catch (error) {
663
+ const message = String(error?.message || error);
664
+ if (/^hub-session-refresh-failed:(?:400|401|403)$/.test(message)) {
665
+ return { ok: true, alreadyInvalid: true };
666
+ }
667
+ return { ok: false, error: message };
668
+ }
669
+ if (!accessToken) return { ok: false, error: 'provider-session-token-unavailable' };
670
+
671
+ try {
672
+ const response = await fetchAuthResponse(
673
+ fetch,
674
+ `${supabaseUrl}/auth/v1/logout?scope=local`,
675
+ {
676
+ method: 'POST',
677
+ headers: {
678
+ apikey: supabasePublishableKey,
679
+ Authorization: `Bearer ${accessToken}`,
680
+ Accept: 'application/json'
681
+ }
682
+ },
683
+ SUPABASE_AUTH_TIMEOUT_MS
684
+ );
685
+ if (response.ok) return { ok: true, revoked: true };
686
+ if ([401, 403, 404].includes(response.status)) {
687
+ return { ok: true, alreadyInvalid: true, status: response.status };
688
+ }
689
+ return { ok: false, error: `provider-session-revoke-failed:${response.status}` };
690
+ } catch (error) {
691
+ return { ok: false, error: String(error?.message || error) };
692
+ }
693
+ }
694
694
 
695
695
  async function verifySupabaseUser(accessToken) {
696
696
  const token = String(accessToken || '').trim();
@@ -803,81 +803,81 @@ function requireHubFeatureAccess(_req, res, next) {
803
803
  res.status(402).json({ ok: false, error: 'livedesk-plan-device-limit', license: licenseSnapshot() });
804
804
  }
805
805
 
806
- const agentDataDir = process.env.LIVEDESK_DATA_DIR || undefined;
807
- securityAuditStore = createSecurityAuditStore({ dataDir: agentDataDir });
808
-
809
- function requestAuditHash(value) {
810
- try {
811
- return crypto.createHash('sha256').update(JSON.stringify(value ?? {}), 'utf8').digest('base64url');
812
- } catch {
813
- return crypto.createHash('sha256').update('[unserializable]', 'utf8').digest('base64url');
814
- }
815
- }
816
-
817
- function securityAuditEvent(event = {}) {
818
- const runtime = runtimeManager.getSnapshot();
819
- return {
820
- actorAccountId: event.actorAccountId || runtime.userId || '',
821
- actorUserId: event.actorUserId || runtime.userId || '',
822
- hubId: event.hubId || runtime.deviceId || '',
823
- ...event
824
- };
825
- }
826
-
827
- function markSecurityAuditFailed(error) {
828
- if (securityAuditHealthy) {
829
- securityAuditHealthy = false;
830
- console.error(`[LiveDesk Hub] SECURITY AUDIT FAILURE: ${error?.message || error}`);
831
- }
832
- }
833
-
834
- async function recordSecurityAuditRequired(event = {}) {
835
- if (!securityAuditStore || !securityAuditHealthy) {
836
- const error = new Error('security-audit-unavailable');
837
- error.code = 'security-audit-unavailable';
838
- throw error;
839
- }
840
- try {
841
- return await securityAuditStore.record(securityAuditEvent(event));
842
- } catch (error) {
843
- markSecurityAuditFailed(error);
844
- throw error;
845
- }
846
- }
847
-
848
- function recordSecurityAudit(event = {}) {
849
- void recordSecurityAuditRequired(event).catch(() => {
850
- // Required mutation paths await recordSecurityAuditRequired directly.
851
- // Best-effort lifecycle telemetry still marks the global audit gate failed.
852
- });
853
- }
854
-
855
- const liveDeskSettingsStore = new LiveDeskSettingsStore({ dataDir: agentDataDir });
856
- const captureStore = new CaptureStore({
857
- dataDir: agentDataDir,
858
- getRetentionPolicy: () => liveDeskSettingsStore.getCached()?.filesAudio?.captureAutoDelete || 'never',
859
- onRetentionDelete: result => recordSecurityAudit({
860
- action: 'privacy.capture.retention-delete',
861
- phase: 'complete',
862
- result: 'success',
863
- authMethod: 'settings-retention-policy',
864
- details: result
865
- })
866
- });
867
- void captureStore.initialize().catch(error => {
868
- console.warn(`[LiveDesk Hub] capture store initialization failed: ${error instanceof Error ? error.message : String(error)}`);
869
- });
870
- void liveDeskSettingsStore.getRecord()
871
- .then(() => captureStore.enforceRetention())
872
- .catch(error => {
873
- console.warn(`[LiveDesk Hub] settings or capture retention load failed: ${error instanceof Error ? error.message : String(error)}`);
874
- });
875
- const captureRetentionTimer = setInterval(() => {
876
- void captureStore.enforceRetention().catch(error => {
877
- console.warn(`[LiveDesk Hub] capture retention failed: ${error instanceof Error ? error.message : String(error)}`);
878
- });
879
- }, 60 * 60_000);
880
- captureRetentionTimer.unref?.();
806
+ const agentDataDir = process.env.LIVEDESK_DATA_DIR || undefined;
807
+ securityAuditStore = createSecurityAuditStore({ dataDir: agentDataDir });
808
+
809
+ function requestAuditHash(value) {
810
+ try {
811
+ return crypto.createHash('sha256').update(JSON.stringify(value ?? {}), 'utf8').digest('base64url');
812
+ } catch {
813
+ return crypto.createHash('sha256').update('[unserializable]', 'utf8').digest('base64url');
814
+ }
815
+ }
816
+
817
+ function securityAuditEvent(event = {}) {
818
+ const runtime = runtimeManager.getSnapshot();
819
+ return {
820
+ actorAccountId: event.actorAccountId || runtime.userId || '',
821
+ actorUserId: event.actorUserId || runtime.userId || '',
822
+ hubId: event.hubId || runtime.deviceId || '',
823
+ ...event
824
+ };
825
+ }
826
+
827
+ function markSecurityAuditFailed(error) {
828
+ if (securityAuditHealthy) {
829
+ securityAuditHealthy = false;
830
+ console.error(`[LiveDesk Hub] SECURITY AUDIT FAILURE: ${error?.message || error}`);
831
+ }
832
+ }
833
+
834
+ async function recordSecurityAuditRequired(event = {}) {
835
+ if (!securityAuditStore || !securityAuditHealthy) {
836
+ const error = new Error('security-audit-unavailable');
837
+ error.code = 'security-audit-unavailable';
838
+ throw error;
839
+ }
840
+ try {
841
+ return await securityAuditStore.record(securityAuditEvent(event));
842
+ } catch (error) {
843
+ markSecurityAuditFailed(error);
844
+ throw error;
845
+ }
846
+ }
847
+
848
+ function recordSecurityAudit(event = {}) {
849
+ void recordSecurityAuditRequired(event).catch(() => {
850
+ // Required mutation paths await recordSecurityAuditRequired directly.
851
+ // Best-effort lifecycle telemetry still marks the global audit gate failed.
852
+ });
853
+ }
854
+
855
+ const liveDeskSettingsStore = new LiveDeskSettingsStore({ dataDir: agentDataDir });
856
+ const captureStore = new CaptureStore({
857
+ dataDir: agentDataDir,
858
+ getRetentionPolicy: () => liveDeskSettingsStore.getCached()?.filesAudio?.captureAutoDelete || 'never',
859
+ onRetentionDelete: result => recordSecurityAudit({
860
+ action: 'privacy.capture.retention-delete',
861
+ phase: 'complete',
862
+ result: 'success',
863
+ authMethod: 'settings-retention-policy',
864
+ details: result
865
+ })
866
+ });
867
+ void captureStore.initialize().catch(error => {
868
+ console.warn(`[LiveDesk Hub] capture store initialization failed: ${error instanceof Error ? error.message : String(error)}`);
869
+ });
870
+ void liveDeskSettingsStore.getRecord()
871
+ .then(() => captureStore.enforceRetention())
872
+ .catch(error => {
873
+ console.warn(`[LiveDesk Hub] settings or capture retention load failed: ${error instanceof Error ? error.message : String(error)}`);
874
+ });
875
+ const captureRetentionTimer = setInterval(() => {
876
+ void captureStore.enforceRetention().catch(error => {
877
+ console.warn(`[LiveDesk Hub] capture retention failed: ${error instanceof Error ? error.message : String(error)}`);
878
+ });
879
+ }, 60 * 60_000);
880
+ captureRetentionTimer.unref?.();
881
881
 
882
882
  const udpTransport = createHubUdpTransport({
883
883
  env: process.env,
@@ -885,10 +885,10 @@ const udpTransport = createHubUdpTransport({
885
885
  logWarn: (_scope, message) => console.warn(`[LiveDesk Hub] ${message}`)
886
886
  });
887
887
 
888
- const remoteHub = createRemoteHub({
889
- managerPackage: '@livedesk/hub',
890
- managerVersion: packageInfo.version,
891
- dataDir: agentDataDir,
888
+ const remoteHub = createRemoteHub({
889
+ managerPackage: '@livedesk/hub',
890
+ managerVersion: packageInfo.version,
891
+ dataDir: agentDataDir,
892
892
  env: {
893
893
  ...process.env,
894
894
  MINDEXEC_MANAGER_PACKAGE: '@livedesk/hub',
@@ -899,11 +899,11 @@ const remoteHub = createRemoteHub({
899
899
  logWarn: (_scope, message) => console.warn(`[LiveDesk Hub] ${message}`),
900
900
  emitEvent: handleRemoteHubEvent,
901
901
  emitFrame: broadcastRemoteBinaryFrame,
902
- emitAudio: broadcastRemoteBinaryAudio,
903
- getSecurityIdentity: () => ({
904
- accountId: runtimeManager.getSnapshot().userId || testSecurityAccountId,
905
- hubId: remoteHub?.getSecurityStatus?.().hubId || ''
906
- }),
902
+ emitAudio: broadcastRemoteBinaryAudio,
903
+ getSecurityIdentity: () => ({
904
+ accountId: runtimeManager.getSnapshot().userId || testSecurityAccountId,
905
+ hubId: remoteHub?.getSecurityStatus?.().hubId || ''
906
+ }),
907
907
  getEffectiveDevicePolicy: ({ deviceId, capabilities }) => buildEffectiveDevicePolicy(liveDeskSettingsStore.getCached(), { deviceId, capabilities }),
908
908
  getWelcomeDevicePolicy: ({ deviceId, capabilities }) => {
909
909
  const policy = buildEffectiveDevicePolicy(liveDeskSettingsStore.getCached(), { deviceId, capabilities });
@@ -1006,11 +1006,11 @@ function getLiveDeskUpdateStatus() {
1006
1006
  if (process.env.LIVEDESK_DESKTOP_HOST !== '1') {
1007
1007
  liveDeskUpdateManager = createLiveDeskUpdateManager({
1008
1008
  remoteHub,
1009
- currentManagerVersion: process.env.LIVEDESK_MANAGER_VERSION || packageInfo.version,
1010
- currentClientVersion: process.env.LIVEDESK_CLIENT_PACKAGE_VERSION || '',
1011
- updateManifestUrl: process.env.LIVEDESK_UPDATE_MANIFEST_URL || '',
1012
- updatePublicKey: process.env.LIVEDESK_UPDATE_PUBLIC_KEY || '',
1013
- restartSupported: !!String(process.env.LIVEDESK_HUB_UPDATE_REQUEST_PATH || '').trim(),
1009
+ currentManagerVersion: process.env.LIVEDESK_MANAGER_VERSION || packageInfo.version,
1010
+ currentClientVersion: process.env.LIVEDESK_CLIENT_PACKAGE_VERSION || '',
1011
+ updateManifestUrl: process.env.LIVEDESK_UPDATE_MANIFEST_URL || '',
1012
+ updatePublicKey: process.env.LIVEDESK_UPDATE_PUBLIC_KEY || '',
1013
+ restartSupported: !!String(process.env.LIVEDESK_HUB_UPDATE_REQUEST_PATH || '').trim(),
1014
1014
  requestHubRestart
1015
1015
  });
1016
1016
  }
@@ -1328,7 +1328,7 @@ function validateAgentMcpArguments(name, args) {
1328
1328
  return '';
1329
1329
  }
1330
1330
 
1331
- async function dispatchAgentMcpTool(session, name, args = {}) {
1331
+ async function dispatchAgentMcpTool(session, name, args = {}) {
1332
1332
  if (session.cancelled || session.signal?.aborted) return { ok: false, error: 'cancelled-by-user' };
1333
1333
  // This check runs synchronously before the first await, so concurrent Node
1334
1334
  // requests cannot pass the same remaining budget and queue extra Client work.
@@ -1336,23 +1336,23 @@ async function dispatchAgentMcpTool(session, name, args = {}) {
1336
1336
  session.toolLimitReached = true;
1337
1337
  return { ok: false, error: 'codex-tool-limit-reached' };
1338
1338
  }
1339
- session.toolCallCount += 1;
1340
- if (isRetiredAgentMutatingToolName(name)) {
1341
- recordAgentAudit({
1342
- event: 'tool-rejected',
1343
- runId: session.runId,
1344
- deviceIds: [],
1345
- toolName: name,
1346
- category: 'mutation',
1347
- decision: 'deny',
1348
- status: 'rejected',
1349
- permissionMode: session.permissionPolicy?.mode,
1350
- policyHash: session.permissionPolicyHash,
1351
- details: { reason: 'agent-mutating-tool-disabled' }
1352
- });
1353
- return { ok: false, error: 'agent-mutating-tool-disabled' };
1354
- }
1355
- const tool = getAgentToolDefinition(name);
1339
+ session.toolCallCount += 1;
1340
+ if (isRetiredAgentMutatingToolName(name)) {
1341
+ recordAgentAudit({
1342
+ event: 'tool-rejected',
1343
+ runId: session.runId,
1344
+ deviceIds: [],
1345
+ toolName: name,
1346
+ category: 'mutation',
1347
+ decision: 'deny',
1348
+ status: 'rejected',
1349
+ permissionMode: session.permissionPolicy?.mode,
1350
+ policyHash: session.permissionPolicyHash,
1351
+ details: { reason: 'agent-mutating-tool-disabled' }
1352
+ });
1353
+ return { ok: false, error: 'agent-mutating-tool-disabled' };
1354
+ }
1355
+ const tool = getAgentToolDefinition(name);
1356
1356
  if (!tool) return { ok: false, error: 'codex-tool-not-allowed' };
1357
1357
  const validationError = validateAgentMcpArguments(name, args);
1358
1358
  if (validationError) return { ok: false, error: validationError };
@@ -1543,20 +1543,20 @@ async function synchronizeAgentEnablement() {
1543
1543
  return enabled;
1544
1544
  }
1545
1545
 
1546
- const hubFilesystem = createHubFilesystem();
1547
- const fileTransferCommandAckTimeoutMs = readPositiveIntegerEnv(
1548
- 'LIVEDESK_FILE_CHUNK_ACK_TIMEOUT_MS',
1549
- 30_000
1550
- );
1551
- hubTransferJobs = createHubTransferJobs({
1552
- filesystem: hubFilesystem,
1553
- remoteHub,
1554
- maxConcurrent: Number(process.env.LIVEDESK_MAX_FILE_JOBS || 2),
1555
- commandResultTimeoutMs: fileTransferCommandAckTimeoutMs,
1556
- getMaxFileSizeBytes: () => Number(
1557
- liveDeskSettingsStore.getCached()?.filesAudio?.maxFileSizeBytes
1558
- || 1024 * 1024 * 1024)
1559
- });
1546
+ const hubFilesystem = createHubFilesystem();
1547
+ const fileTransferCommandAckTimeoutMs = readPositiveIntegerEnv(
1548
+ 'LIVEDESK_FILE_CHUNK_ACK_TIMEOUT_MS',
1549
+ 30_000
1550
+ );
1551
+ hubTransferJobs = createHubTransferJobs({
1552
+ filesystem: hubFilesystem,
1553
+ remoteHub,
1554
+ maxConcurrent: Number(process.env.LIVEDESK_MAX_FILE_JOBS || 2),
1555
+ commandResultTimeoutMs: fileTransferCommandAckTimeoutMs,
1556
+ getMaxFileSizeBytes: () => Number(
1557
+ liveDeskSettingsStore.getCached()?.filesAudio?.maxFileSizeBytes
1558
+ || 1024 * 1024 * 1024)
1559
+ });
1560
1560
  const hubSharedFolders = createHubSharedFolders({
1561
1561
  filesystem: hubFilesystem,
1562
1562
  transferJobs: hubTransferJobs,
@@ -1676,125 +1676,152 @@ app.use((req, res, next) => {
1676
1676
  res.setHeader('Access-Control-Allow-Origin', origin);
1677
1677
  res.setHeader('Access-Control-Allow-Private-Network', 'true');
1678
1678
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
1679
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-LiveDesk-Admin-Session, X-LiveDesk-Admin-CSRF, X-LiveDesk-CSRF');
1679
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-LiveDesk-Admin-Session, X-LiveDesk-Admin-CSRF, X-LiveDesk-CSRF');
1680
1680
  res.setHeader('Vary', 'Origin, Access-Control-Request-Headers, Access-Control-Request-Private-Network');
1681
1681
  }
1682
1682
  if (req.method === 'OPTIONS') {
1683
1683
  res.status(204).end();
1684
1684
  return;
1685
1685
  }
1686
- next();
1687
- });
1688
- app.use((req, res, next) => {
1689
- if (!enforceLocalAdminAuth || req.path === '/api/security/session' || req.method === 'OPTIONS') {
1690
- next();
1691
- return;
1692
- }
1693
- const authorizedLauncherShutdown = req.method === 'POST'
1694
- && req.path === '/api/runtime/shutdown'
1695
- && isLoopbackAddress(req.socket?.remoteAddress)
1696
- && launcherShutdownToken
1697
- && secureExactTestTokenMatches(
1698
- String(req.headers['x-livedesk-launcher-shutdown'] || ''),
1699
- launcherShutdownToken
1700
- );
1701
- if (authorizedLauncherShutdown) {
1702
- req.liveDeskLauncherShutdown = true;
1703
- next();
1704
- return;
1705
- }
1706
- const mutating = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method);
1707
- const sensitiveRead = req.method === 'GET'
1708
- && ['/api/security/audit', '/api/security/rendezvous-issuer', '/api/privacy/inventory'].includes(req.path);
1709
- if (!mutating && !sensitiveRead) {
1710
- next();
1711
- return;
1712
- }
1713
- const adminToken = String(req.headers['x-livedesk-admin-session'] || '');
1714
- const csrfToken = String(req.headers['x-livedesk-admin-csrf'] || '');
1715
- if (!secureExactTestTokenMatches(adminToken, localAdminSessionToken)) {
1716
- recordSecurityAudit({
1717
- action: 'local-admin.request',
1718
- phase: 'complete',
1719
- result: 'rejected',
1720
- reason: 'admin-session-required',
1721
- requestHash: requestAuditHash({ method: req.method, path: req.path }),
1722
- authMethod: 'local-admin-session'
1723
- });
1724
- res.status(401).json({ ok: false, error: 'local-admin-session-required' });
1725
- return;
1726
- }
1727
- if (mutating && !secureExactTestTokenMatches(csrfToken, localAdminCsrfToken)) {
1728
- recordSecurityAudit({
1729
- action: 'local-admin.request',
1730
- phase: 'complete',
1731
- result: 'rejected',
1732
- reason: 'csrf-token-required',
1733
- requestHash: requestAuditHash({ method: req.method, path: req.path }),
1734
- authMethod: 'local-admin-session'
1735
- });
1736
- res.status(403).json({ ok: false, error: 'csrf-token-required' });
1737
- return;
1738
- }
1739
- if (!securityAuditHealthy) {
1740
- res.status(503).json({ ok: false, error: 'security-audit-unavailable' });
1741
- return;
1742
- }
1743
- req.liveDeskAdminSessionId = localAdminSessionId;
1744
- if (!mutating) {
1745
- next();
1746
- return;
1747
- }
1748
-
1749
- const mutationRequestHash = requestAuditHash({ method: req.method, path: req.path });
1750
- void recordSecurityAuditRequired({
1751
- action: 'local-admin.mutation',
1752
- phase: 'accepted',
1753
- result: 'accepted',
1754
- sessionId: localAdminSessionId,
1755
- requestHash: mutationRequestHash,
1756
- authMethod: 'local-admin-session',
1757
- details: { method: req.method, path: req.path }
1758
- }).then(() => {
1759
- const originalEnd = res.end.bind(res);
1760
- let completionStarted = false;
1761
- res.end = (...args) => {
1762
- if (completionStarted) return res;
1763
- completionStarted = true;
1764
- const statusCode = Number(res.statusCode || 200);
1765
- void recordSecurityAuditRequired({
1766
- action: 'local-admin.mutation',
1767
- phase: 'complete',
1768
- result: statusCode >= 200 && statusCode < 400 ? 'success' : 'rejected',
1769
- reason: statusCode >= 400 ? `http-${statusCode}` : '',
1770
- sessionId: localAdminSessionId,
1771
- requestHash: mutationRequestHash,
1772
- authMethod: 'local-admin-session',
1773
- details: { method: req.method, path: req.path, statusCode }
1774
- }).then(() => {
1775
- originalEnd(...args);
1776
- }).catch(error => {
1777
- if (res.headersSent) {
1778
- res.destroy(error);
1779
- return;
1780
- }
1781
- const callback = [...args].reverse().find(value => typeof value === 'function');
1782
- const body = JSON.stringify({ ok: false, error: 'security-audit-unavailable' });
1783
- res.statusCode = 503;
1784
- res.removeHeader('Content-Length');
1785
- res.removeHeader('Transfer-Encoding');
1786
- res.setHeader('Content-Type', 'application/json; charset=utf-8');
1787
- res.setHeader('Content-Length', Buffer.byteLength(body));
1788
- originalEnd(body, 'utf8', callback);
1789
- });
1790
- return res;
1791
- };
1792
- next();
1793
- }).catch(() => {
1794
- res.status(503).json({ ok: false, error: 'security-audit-unavailable' });
1795
- });
1796
- });
1797
- app.use(express.json({ limit: '32mb' }));
1686
+ next();
1687
+ });
1688
+ const parseHubJsonBody = express.json({ limit: '32mb' });
1689
+ function parseHubJsonRequest(req, res, next, onParsed = next) {
1690
+ parseHubJsonBody(req, res, error => {
1691
+ if (!error) {
1692
+ onParsed();
1693
+ return;
1694
+ }
1695
+ const type = String(error?.type || '');
1696
+ const code = String(error?.code || '');
1697
+ const bodyError = type === 'entity.parse.failed'
1698
+ ? { status: 400, error: 'invalid-json' }
1699
+ : type === 'entity.too.large'
1700
+ ? { status: 413, error: 'request-too-large' }
1701
+ : type === 'request.aborted' || type === 'stream.not.readable' || code === 'ECONNABORTED'
1702
+ ? { status: 400, error: 'request-body-unavailable' }
1703
+ : null;
1704
+ if (!bodyError) {
1705
+ next(error);
1706
+ return;
1707
+ }
1708
+ if (res.headersSent || res.destroyed || req.destroyed) return;
1709
+ res.status(bodyError.status).json({ ok: false, error: bodyError.error });
1710
+ });
1711
+ }
1712
+ app.use((req, res, next) => {
1713
+ const mutating = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method);
1714
+ if (!enforceLocalAdminAuth || req.path === '/api/security/session' || req.method === 'OPTIONS') {
1715
+ if (mutating) parseHubJsonRequest(req, res, next);
1716
+ else next();
1717
+ return;
1718
+ }
1719
+ const authorizedLauncherShutdown = req.method === 'POST'
1720
+ && req.path === '/api/runtime/shutdown'
1721
+ && isLoopbackAddress(req.socket?.remoteAddress)
1722
+ && launcherShutdownToken
1723
+ && secureExactTestTokenMatches(
1724
+ String(req.headers['x-livedesk-launcher-shutdown'] || ''),
1725
+ launcherShutdownToken
1726
+ );
1727
+ if (authorizedLauncherShutdown) {
1728
+ req.liveDeskLauncherShutdown = true;
1729
+ parseHubJsonRequest(req, res, next);
1730
+ return;
1731
+ }
1732
+ const sensitiveRead = req.method === 'GET'
1733
+ && ['/api/security/audit', '/api/security/rendezvous-issuer', '/api/privacy/inventory'].includes(req.path);
1734
+ if (!mutating && !sensitiveRead) {
1735
+ next();
1736
+ return;
1737
+ }
1738
+ const adminToken = String(req.headers['x-livedesk-admin-session'] || '');
1739
+ const csrfToken = String(req.headers['x-livedesk-admin-csrf'] || '');
1740
+ if (!secureExactTestTokenMatches(adminToken, localAdminSessionToken)) {
1741
+ recordSecurityAudit({
1742
+ action: 'local-admin.request',
1743
+ phase: 'complete',
1744
+ result: 'rejected',
1745
+ reason: 'admin-session-required',
1746
+ requestHash: requestAuditHash({ method: req.method, path: req.path }),
1747
+ authMethod: 'local-admin-session'
1748
+ });
1749
+ res.status(401).json({ ok: false, error: 'local-admin-session-required' });
1750
+ return;
1751
+ }
1752
+ if (mutating && !secureExactTestTokenMatches(csrfToken, localAdminCsrfToken)) {
1753
+ recordSecurityAudit({
1754
+ action: 'local-admin.request',
1755
+ phase: 'complete',
1756
+ result: 'rejected',
1757
+ reason: 'csrf-token-required',
1758
+ requestHash: requestAuditHash({ method: req.method, path: req.path }),
1759
+ authMethod: 'local-admin-session'
1760
+ });
1761
+ res.status(403).json({ ok: false, error: 'csrf-token-required' });
1762
+ return;
1763
+ }
1764
+ if (!securityAuditHealthy) {
1765
+ res.status(503).json({ ok: false, error: 'security-audit-unavailable' });
1766
+ return;
1767
+ }
1768
+ req.liveDeskAdminSessionId = localAdminSessionId;
1769
+ if (!mutating) {
1770
+ next();
1771
+ return;
1772
+ }
1773
+
1774
+ const beginMutationAudit = () => {
1775
+ const mutationRequestHash = requestAuditHash({ method: req.method, path: req.path });
1776
+ void recordSecurityAuditRequired({
1777
+ action: 'local-admin.mutation',
1778
+ phase: 'accepted',
1779
+ result: 'accepted',
1780
+ sessionId: localAdminSessionId,
1781
+ requestHash: mutationRequestHash,
1782
+ authMethod: 'local-admin-session',
1783
+ details: { method: req.method, path: req.path }
1784
+ }).then(() => {
1785
+ const originalEnd = res.end.bind(res);
1786
+ let completionStarted = false;
1787
+ res.end = (...args) => {
1788
+ if (completionStarted) return res;
1789
+ completionStarted = true;
1790
+ const statusCode = Number(res.statusCode || 200);
1791
+ void recordSecurityAuditRequired({
1792
+ action: 'local-admin.mutation',
1793
+ phase: 'complete',
1794
+ result: statusCode >= 200 && statusCode < 400 ? 'success' : 'rejected',
1795
+ reason: statusCode >= 400 ? `http-${statusCode}` : '',
1796
+ sessionId: localAdminSessionId,
1797
+ requestHash: mutationRequestHash,
1798
+ authMethod: 'local-admin-session',
1799
+ details: { method: req.method, path: req.path, statusCode }
1800
+ }).then(() => {
1801
+ originalEnd(...args);
1802
+ }).catch(error => {
1803
+ if (res.headersSent) {
1804
+ res.destroy(error);
1805
+ return;
1806
+ }
1807
+ const callback = [...args].reverse().find(value => typeof value === 'function');
1808
+ const body = JSON.stringify({ ok: false, error: 'security-audit-unavailable' });
1809
+ res.statusCode = 503;
1810
+ res.removeHeader('Content-Length');
1811
+ res.removeHeader('Transfer-Encoding');
1812
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
1813
+ res.setHeader('Content-Length', Buffer.byteLength(body));
1814
+ originalEnd(body, 'utf8', callback);
1815
+ });
1816
+ return res;
1817
+ };
1818
+ next();
1819
+ }).catch(() => {
1820
+ res.status(503).json({ ok: false, error: 'security-audit-unavailable' });
1821
+ });
1822
+ };
1823
+ parseHubJsonRequest(req, res, next, beginMutationAudit);
1824
+ });
1798
1825
  app.use((req, res, next) => {
1799
1826
  if (runtimeRole === 'client' && req.path.startsWith('/api/remote')) {
1800
1827
  res.status(403).json(roleGuardResponse(runtimeRole, 'hub'));
@@ -1945,12 +1972,12 @@ function normalizeTransferFiles(value) {
1945
1972
  if (totalBytes > MAX_FILE_TRANSFER_BYTES) {
1946
1973
  return { ok: false, error: 'file-transfer-too-large', files: [], totalBytes };
1947
1974
  }
1948
- files.push({
1949
- name,
1950
- relativePath,
1951
- size: byteLength,
1952
- sha256: crypto.createHash('sha256').update(Buffer.from(dataBase64, 'base64')).digest('hex'),
1953
- mimeType: String(entry?.type || entry?.mimeType || '').slice(0, 160),
1975
+ files.push({
1976
+ name,
1977
+ relativePath,
1978
+ size: byteLength,
1979
+ sha256: crypto.createHash('sha256').update(Buffer.from(dataBase64, 'base64')).digest('hex'),
1980
+ mimeType: String(entry?.type || entry?.mimeType || '').slice(0, 160),
1954
1981
  lastModified: Number(entry?.lastModified || 0) || 0,
1955
1982
  dataBase64
1956
1983
  });
@@ -1961,39 +1988,39 @@ function normalizeTransferFiles(value) {
1961
1988
  return { ok: true, files, totalBytes };
1962
1989
  }
1963
1990
 
1964
- function normalizeTransferChunk(body = {}) {
1991
+ function normalizeTransferChunk(body = {}) {
1965
1992
  const dataBase64 = String(body.dataBase64 || '').replace(/^data:[^,]*,/i, '').trim();
1966
1993
  const name = String(body.name || '').replace(/[\r\n\t\0]/g, ' ').trim().slice(0, 240);
1967
1994
  const relativePath = String(body.relativePath || name).replace(/[\r\n\t\0]/g, ' ').trim().slice(0, 600);
1968
1995
  const offset = Math.max(0, Math.floor(Number(body.offset) || 0));
1969
1996
  const totalBytes = Math.max(0, Math.floor(Number(body.totalBytes) || 0));
1970
- const byteLength = dataBase64 ? Buffer.byteLength(dataBase64, 'base64') : 0;
1971
- const final = body.final === true;
1972
- const maxFileSizeBytes = Math.max(1, Number(
1973
- liveDeskSettingsStore.getCached()?.filesAudio?.maxFileSizeBytes
1974
- || 1024 * 1024 * 1024));
1975
-
1976
- if (!name || !relativePath || offset > totalBytes || offset + byteLength > totalBytes) {
1977
- return { ok: false, error: 'invalid-file-transfer-chunk' };
1978
- }
1979
- if (totalBytes > maxFileSizeBytes) {
1980
- return { ok: false, error: 'file-transfer-file-too-large', totalBytes };
1981
- }
1997
+ const byteLength = dataBase64 ? Buffer.byteLength(dataBase64, 'base64') : 0;
1998
+ const final = body.final === true;
1999
+ const maxFileSizeBytes = Math.max(1, Number(
2000
+ liveDeskSettingsStore.getCached()?.filesAudio?.maxFileSizeBytes
2001
+ || 1024 * 1024 * 1024));
2002
+
2003
+ if (!name || !relativePath || offset > totalBytes || offset + byteLength > totalBytes) {
2004
+ return { ok: false, error: 'invalid-file-transfer-chunk' };
2005
+ }
2006
+ if (totalBytes > maxFileSizeBytes) {
2007
+ return { ok: false, error: 'file-transfer-file-too-large', totalBytes };
2008
+ }
1982
2009
  if (byteLength > MAX_FILE_TRANSFER_CHUNK_BYTES) {
1983
2010
  return { ok: false, error: 'file-transfer-chunk-too-large', byteLength };
1984
2011
  }
1985
- if (byteLength === 0 && !(final && totalBytes === 0 && offset === 0)) {
1986
- return { ok: false, error: 'empty-file-transfer-chunk' };
1987
- }
1988
- const suppliedSha256 = String(body.sha256 || '').trim().toLowerCase();
1989
- const sha256 = /^[a-f0-9]{64}$/.test(suppliedSha256)
1990
- ? suppliedSha256
1991
- : final && offset === 0 && totalBytes === byteLength
1992
- ? crypto.createHash('sha256').update(Buffer.from(dataBase64, 'base64')).digest('hex')
1993
- : '';
1994
- if (final && !sha256) {
1995
- return { ok: false, error: 'file-transfer-sha256-required', byteLength };
1996
- }
2012
+ if (byteLength === 0 && !(final && totalBytes === 0 && offset === 0)) {
2013
+ return { ok: false, error: 'empty-file-transfer-chunk' };
2014
+ }
2015
+ const suppliedSha256 = String(body.sha256 || '').trim().toLowerCase();
2016
+ const sha256 = /^[a-f0-9]{64}$/.test(suppliedSha256)
2017
+ ? suppliedSha256
2018
+ : final && offset === 0 && totalBytes === byteLength
2019
+ ? crypto.createHash('sha256').update(Buffer.from(dataBase64, 'base64')).digest('hex')
2020
+ : '';
2021
+ if (final && !sha256) {
2022
+ return { ok: false, error: 'file-transfer-sha256-required', byteLength };
2023
+ }
1997
2024
 
1998
2025
  return {
1999
2026
  ok: true,
@@ -2003,9 +2030,9 @@ function normalizeTransferChunk(body = {}) {
2003
2030
  offset,
2004
2031
  totalBytes,
2005
2032
  byteLength,
2006
- dataBase64,
2007
- final,
2008
- sha256,
2033
+ dataBase64,
2034
+ final,
2035
+ sha256,
2009
2036
  mimeType: String(body.type || body.mimeType || '').slice(0, 160),
2010
2037
  lastModified: Number(body.lastModified || 0) || 0
2011
2038
  }
@@ -2711,6 +2738,11 @@ function replaceExpectedFrameBindingForClient(ws, deviceId, expectedBinding = nu
2711
2738
  && bindingIdentity === frameLaneBindingIdentity(currentExpectedBinding)) {
2712
2739
  expectedBinding.readySent = currentExpectedBinding?.readySent === true
2713
2740
  || expectedBinding.readySent === true;
2741
+ expectedBinding.replayedKeyFrameSeq = Math.max(
2742
+ 0,
2743
+ Number(currentExpectedBinding?.replayedKeyFrameSeq || 0),
2744
+ Number(expectedBinding.replayedKeyFrameSeq || 0)
2745
+ );
2714
2746
  ws.liveDeskExpectedStreamBindingsByDeviceId.set(normalizedDeviceId, expectedBinding);
2715
2747
  if (!lane.bindingEpochsByDeviceId.has(normalizedDeviceId)) {
2716
2748
  lane.bindingEpochsByDeviceId.set(normalizedDeviceId, 1);
@@ -2749,6 +2781,50 @@ function replaceExpectedFrameBindingForClient(ws, deviceId, expectedBinding = nu
2749
2781
  return bindingIdentity;
2750
2782
  }
2751
2783
 
2784
+ function replayExpectedBindingKeyFrame(ws, expectedBinding) {
2785
+ if (!ws || ws.readyState !== ws.OPEN || !expectedBinding) {
2786
+ return false;
2787
+ }
2788
+ const deviceId = String(expectedBinding.deviceId || '').trim();
2789
+ const currentBinding = ws.liveDeskExpectedStreamBindingsByDeviceId instanceof Map
2790
+ ? ws.liveDeskExpectedStreamBindingsByDeviceId.get(deviceId)
2791
+ : null;
2792
+ const bindingIdentity = frameLaneBindingIdentity(expectedBinding);
2793
+ if (!deviceId
2794
+ || !bindingIdentity
2795
+ || bindingIdentity !== frameLaneBindingIdentity(currentBinding)) {
2796
+ return false;
2797
+ }
2798
+ const cached = remoteHub.getFramePayload(deviceId, 'live', {
2799
+ requireKeyFrame: true,
2800
+ streamId: expectedBinding.streamId,
2801
+ sessionId: expectedBinding.sessionId,
2802
+ commandId: expectedBinding.commandId,
2803
+ captureGeneration: expectedBinding.captureGeneration,
2804
+ streamPurpose: expectedBinding.streamPurpose,
2805
+ monitorIndex: expectedBinding.monitorIndex
2806
+ });
2807
+ const frameSeq = Number(cached?.frame?.frameSeq || 0);
2808
+ if (!cached
2809
+ || cached.frame?.isKeyFrame !== true
2810
+ || !Number.isSafeInteger(frameSeq)
2811
+ || frameSeq <= 0
2812
+ || Number(currentBinding?.replayedKeyFrameSeq || 0) === frameSeq) {
2813
+ return false;
2814
+ }
2815
+ currentBinding.replayedKeyFrameSeq = frameSeq;
2816
+ broadcastRemoteBinaryFrame({
2817
+ kind: 'live',
2818
+ deviceId,
2819
+ frame: cached.frame,
2820
+ payload: cached.payload,
2821
+ mimeType: cached.mimeType,
2822
+ byteLength: cached.byteLength,
2823
+ replayedForBinding: true
2824
+ });
2825
+ return true;
2826
+ }
2827
+
2752
2828
  function dropQueuedFrameAtForLane(lane, dropIndex) {
2753
2829
  const droppedBefore = lane.dropped;
2754
2830
  const droppedItem = removeFrameLaneQueueItem(lane, dropIndex);
@@ -3242,6 +3318,7 @@ function startFrameSubscriptionLive(
3242
3318
  continue;
3243
3319
  }
3244
3320
  cancelPendingFrameStreamStop(deviceId, result.streamId, String(liveOptions.streamPurpose || 'wall'));
3321
+ const replayedKeyFrame = replayExpectedBindingKeyFrame(ws, expectedBinding);
3245
3322
  started.push({
3246
3323
  deviceId: expectedBinding.deviceId,
3247
3324
  sessionId: expectedBinding.sessionId,
@@ -3250,7 +3327,8 @@ function startFrameSubscriptionLive(
3250
3327
  commandId: expectedBinding.commandId,
3251
3328
  captureGeneration: expectedBinding.captureGeneration,
3252
3329
  monitorIndex: expectedBinding.monitorIndex,
3253
- ready: reusedFrameReady,
3330
+ ready: expectedBinding.readySent === true,
3331
+ replayedKeyFrame,
3254
3332
  fps: result.fps,
3255
3333
  reused: result.reused === true
3256
3334
  });
@@ -4073,26 +4151,26 @@ function buildHubHealthPayload({
4073
4151
  };
4074
4152
  }
4075
4153
 
4076
- app.get('/api/health', (_req, res) => {
4077
- noStore(res);
4078
- res.json(buildHubHealthPayload());
4079
- });
4080
-
4081
- app.get('/api/security/session', (req, res) => {
4082
- noStore(res);
4083
- if (!isLoopbackAddress(req.socket?.remoteAddress) || !isLoopbackHostname(requestHostname(req))) {
4084
- res.status(403).json({ ok: false, error: 'local-admin-session-loopback-only' });
4085
- return;
4086
- }
4087
- res.json({
4088
- ok: true,
4089
- sessionId: localAdminSessionId,
4090
- adminToken: localAdminSessionToken,
4091
- csrfToken: localAdminCsrfToken,
4092
- issuedAt: new Date().toISOString(),
4093
- expiresOnProcessExit: true
4094
- });
4095
- });
4154
+ app.get('/api/health', (_req, res) => {
4155
+ noStore(res);
4156
+ res.json(buildHubHealthPayload());
4157
+ });
4158
+
4159
+ app.get('/api/security/session', (req, res) => {
4160
+ noStore(res);
4161
+ if (!isLoopbackAddress(req.socket?.remoteAddress) || !isLoopbackHostname(requestHostname(req))) {
4162
+ res.status(403).json({ ok: false, error: 'local-admin-session-loopback-only' });
4163
+ return;
4164
+ }
4165
+ res.json({
4166
+ ok: true,
4167
+ sessionId: localAdminSessionId,
4168
+ adminToken: localAdminSessionToken,
4169
+ csrfToken: localAdminCsrfToken,
4170
+ issuedAt: new Date().toISOString(),
4171
+ expiresOnProcessExit: true
4172
+ });
4173
+ });
4096
4174
 
4097
4175
  if (persistentSessionGcEnabled) {
4098
4176
  app.post('/api/test/persistent-session/gc', (req, res) => {
@@ -4145,18 +4223,18 @@ app.patch('/api/settings', async (req, res) => {
4145
4223
  const record = await liveDeskSettingsStore.update(patch, revision);
4146
4224
  if (patch.agent && Object.prototype.hasOwnProperty.call(patch.agent, 'enabled')) {
4147
4225
  await agentSettingsStore.update({ enabled: record.settings.agent?.enabled === true });
4148
- }
4149
- remoteHub.refreshDevicePolicies();
4150
- void captureStore.enforceRetention().catch(() => undefined);
4151
- recordSecurityAudit({
4152
- action: 'settings.security-policy.updated',
4153
- phase: 'complete',
4154
- result: 'success',
4155
- requestHash: requestAuditHash(patch),
4156
- authMethod: 'local-admin-session',
4157
- details: { revision: record.revision, sections: Object.keys(patch) }
4158
- });
4159
- res.json({ ok: true, revision: record.revision, updatedAt: record.updatedAt, settings: record.settings });
4226
+ }
4227
+ remoteHub.refreshDevicePolicies();
4228
+ void captureStore.enforceRetention().catch(() => undefined);
4229
+ recordSecurityAudit({
4230
+ action: 'settings.security-policy.updated',
4231
+ phase: 'complete',
4232
+ result: 'success',
4233
+ requestHash: requestAuditHash(patch),
4234
+ authMethod: 'local-admin-session',
4235
+ details: { revision: record.revision, sections: Object.keys(patch) }
4236
+ });
4237
+ res.json({ ok: true, revision: record.revision, updatedAt: record.updatedAt, settings: record.settings });
4160
4238
  } catch (error) {
4161
4239
  if (error instanceof SettingsConflictError) {
4162
4240
  res.status(409).json({ ok: false, error: error.code, revision: error.settings.revision, updatedAt: error.settings.updatedAt, settings: error.settings.settings });
@@ -4311,7 +4389,7 @@ app.get('/api/settings/capabilities', async (_req, res) => {
4311
4389
  });
4312
4390
  });
4313
4391
 
4314
- app.get('/api/security/trusted-devices', (_req, res) => {
4392
+ app.get('/api/security/trusted-devices', (_req, res) => {
4315
4393
  noStore(res);
4316
4394
  const devices = remoteHub.listDevices({ includeDataUrl: false }).map(device => ({
4317
4395
  deviceId: device.deviceId,
@@ -4323,141 +4401,141 @@ app.get('/api/security/trusted-devices', (_req, res) => {
4323
4401
  lastSeenAt: device.lastSeenAt || '',
4324
4402
  ipRange: String(device.remoteAddress || '').replace(/^::ffff:/, '').split(':')[0]
4325
4403
  }));
4326
- res.json({ ok: true, devices });
4327
- });
4328
-
4329
- app.get('/api/security/rendezvous-issuer', (_req, res) => {
4330
- noStore(res);
4331
- const security = remoteHub.getSecurityStatus();
4332
- res.json({
4333
- ok: true,
4334
- hubId: security.hubId,
4335
- issuerKeyId: security.hubIssuerKeyId,
4336
- publicKey: security.hubPublicKey,
4337
- algorithm: 'P-256/SHA-256',
4338
- environmentVariable: 'LIVEDESK_UDP_RENDEZVOUS_TRUSTED_ISSUER_PUBLIC_KEYS'
4339
- });
4340
- });
4341
-
4342
- app.get('/api/security/audit', async (req, res) => {
4343
- noStore(res);
4344
- try {
4345
- res.json({
4346
- ok: true,
4347
- healthy: securityAuditHealthy,
4348
- verification: await securityAuditStore.verify(),
4349
- audit: await securityAuditStore.list({ limit: req.query?.limit, action: req.query?.action })
4350
- });
4351
- } catch (error) {
4352
- securityAuditHealthy = false;
4353
- res.status(503).json({ ok: false, error: error?.code || 'security-audit-unavailable' });
4354
- }
4355
- });
4356
-
4357
- app.get('/api/privacy/inventory', async (_req, res) => {
4358
- noStore(res);
4359
- try {
4360
- const [captures, sharedFolders, verification] = await Promise.all([
4361
- captureStore.list(),
4362
- hubSharedFolders.list(),
4363
- securityAuditStore.verify()
4364
- ]);
4365
- res.json({
4366
- ok: true,
4367
- inventory: {
4368
- authenticated: runtimeManager.getSnapshot().authenticated === true,
4369
- captures: captures.length,
4370
- sharedFolderDefinitions: sharedFolders.length,
4371
- trustedDeviceCredentials: remoteHub.getSecurityStatus().devices.length,
4372
- securityAuditRecords: verification.records,
4373
- agentAuditRetained: (await agentAuditStore.list({ limit: 500 })).length
4374
- }
4375
- });
4376
- } catch (error) {
4377
- res.status(503).json({ ok: false, error: String(error?.code || error?.message || 'privacy-inventory-unavailable').slice(0, 120) });
4378
- }
4379
- });
4380
-
4381
- app.delete('/api/privacy/local-data', async (req, res) => {
4382
- noStore(res);
4383
- if (String(req.body?.confirmation || '') !== 'DELETE-LIVEDESK-LOCAL-DATA') {
4384
- res.status(400).json({ ok: false, error: 'privacy-delete-confirmation-required' });
4385
- return;
4386
- }
4387
- try {
4388
- const providerLogout = await revokeRuntimeProviderSession();
4389
- const hostTarget = runtimeRole === 'hub'
4390
- ? await clearHubHostTarget('privacy-delete')
4391
- : { ok: true, active: false };
4392
- clearRuntimeSession();
4393
- for (const device of remoteHub.getSecurityStatus().devices) {
4394
- remoteHub.disconnectDevice(device.deviceId, 'privacy-local-data-delete');
4395
- }
4396
- const trustedDeviceCredentials = remoteHub.clearDeviceCredentials();
4397
- const transferJobs = hubTransferJobs.clear();
4398
- const captures = await captureStore.removeAll();
4399
- const sharedFolders = await hubSharedFolders.clear();
4400
- await agentAuditStore.clear();
4401
- await securityAuditStore.reset();
4402
- securityAuditHealthy = true;
4403
- await securityAuditStore.record({
4404
- action: 'privacy.local-data.deleted',
4405
- phase: 'complete',
4406
- result: 'success',
4407
- authMethod: 'local-admin-session',
4408
- details: {
4409
- capturesRemoved: captures.removed,
4410
- captureBytesRemoved: captures.removedBytes,
4411
- sharedFolderDefinitionsRemoved: sharedFolders.removed,
4412
- trustedDeviceCredentialsRemoved: trustedDeviceCredentials,
4413
- transferJobRecordsRemoved: transferJobs.removed,
4414
- providerSessionRevoked: providerLogout.revoked === true
4415
- }
4416
- });
4417
- res.json({
4418
- ok: true,
4419
- localDeleted: true,
4420
- providerLogout,
4421
- hostTarget,
4422
- captures,
4423
- sharedFolders,
4424
- trustedDeviceCredentials,
4425
- transferJobs,
4426
- preserved: ['settings-preferences', 'hub-device-identity', 'application-binaries']
4427
- });
4428
- } catch (error) {
4429
- securityAuditHealthy = false;
4430
- res.status(503).json({ ok: false, error: String(error?.code || error?.message || 'privacy-delete-failed').slice(0, 120) });
4431
- }
4432
- });
4433
-
4434
- app.delete('/api/security/trusted-devices/:deviceId', (req, res) => {
4435
- noStore(res);
4436
- const disconnected = remoteHub.disconnectDevice(req.params.deviceId, 'trusted-device-revoked');
4437
- const credentialRevoked = remoteHub.revokeDeviceCredential(req.params.deviceId, 'trusted-device-revoked');
4438
- const revoked = disconnected === true || credentialRevoked === true;
4439
- recordSecurityAudit({
4440
- action: 'security.device.revoked',
4441
- phase: 'complete',
4442
- result: revoked ? 'success' : 'not-found',
4443
- deviceId: req.params.deviceId,
4444
- authMethod: 'local-admin-session'
4445
- });
4446
- res.json({ ok: revoked, revoked, deviceId: req.params.deviceId });
4447
- });
4404
+ res.json({ ok: true, devices });
4405
+ });
4406
+
4407
+ app.get('/api/security/rendezvous-issuer', (_req, res) => {
4408
+ noStore(res);
4409
+ const security = remoteHub.getSecurityStatus();
4410
+ res.json({
4411
+ ok: true,
4412
+ hubId: security.hubId,
4413
+ issuerKeyId: security.hubIssuerKeyId,
4414
+ publicKey: security.hubPublicKey,
4415
+ algorithm: 'P-256/SHA-256',
4416
+ environmentVariable: 'LIVEDESK_UDP_RENDEZVOUS_TRUSTED_ISSUER_PUBLIC_KEYS'
4417
+ });
4418
+ });
4419
+
4420
+ app.get('/api/security/audit', async (req, res) => {
4421
+ noStore(res);
4422
+ try {
4423
+ res.json({
4424
+ ok: true,
4425
+ healthy: securityAuditHealthy,
4426
+ verification: await securityAuditStore.verify(),
4427
+ audit: await securityAuditStore.list({ limit: req.query?.limit, action: req.query?.action })
4428
+ });
4429
+ } catch (error) {
4430
+ securityAuditHealthy = false;
4431
+ res.status(503).json({ ok: false, error: error?.code || 'security-audit-unavailable' });
4432
+ }
4433
+ });
4434
+
4435
+ app.get('/api/privacy/inventory', async (_req, res) => {
4436
+ noStore(res);
4437
+ try {
4438
+ const [captures, sharedFolders, verification] = await Promise.all([
4439
+ captureStore.list(),
4440
+ hubSharedFolders.list(),
4441
+ securityAuditStore.verify()
4442
+ ]);
4443
+ res.json({
4444
+ ok: true,
4445
+ inventory: {
4446
+ authenticated: runtimeManager.getSnapshot().authenticated === true,
4447
+ captures: captures.length,
4448
+ sharedFolderDefinitions: sharedFolders.length,
4449
+ trustedDeviceCredentials: remoteHub.getSecurityStatus().devices.length,
4450
+ securityAuditRecords: verification.records,
4451
+ agentAuditRetained: (await agentAuditStore.list({ limit: 500 })).length
4452
+ }
4453
+ });
4454
+ } catch (error) {
4455
+ res.status(503).json({ ok: false, error: String(error?.code || error?.message || 'privacy-inventory-unavailable').slice(0, 120) });
4456
+ }
4457
+ });
4458
+
4459
+ app.delete('/api/privacy/local-data', async (req, res) => {
4460
+ noStore(res);
4461
+ if (String(req.body?.confirmation || '') !== 'DELETE-LIVEDESK-LOCAL-DATA') {
4462
+ res.status(400).json({ ok: false, error: 'privacy-delete-confirmation-required' });
4463
+ return;
4464
+ }
4465
+ try {
4466
+ const providerLogout = await revokeRuntimeProviderSession();
4467
+ const hostTarget = runtimeRole === 'hub'
4468
+ ? await clearHubHostTarget('privacy-delete')
4469
+ : { ok: true, active: false };
4470
+ clearRuntimeSession();
4471
+ for (const device of remoteHub.getSecurityStatus().devices) {
4472
+ remoteHub.disconnectDevice(device.deviceId, 'privacy-local-data-delete');
4473
+ }
4474
+ const trustedDeviceCredentials = remoteHub.clearDeviceCredentials();
4475
+ const transferJobs = hubTransferJobs.clear();
4476
+ const captures = await captureStore.removeAll();
4477
+ const sharedFolders = await hubSharedFolders.clear();
4478
+ await agentAuditStore.clear();
4479
+ await securityAuditStore.reset();
4480
+ securityAuditHealthy = true;
4481
+ await securityAuditStore.record({
4482
+ action: 'privacy.local-data.deleted',
4483
+ phase: 'complete',
4484
+ result: 'success',
4485
+ authMethod: 'local-admin-session',
4486
+ details: {
4487
+ capturesRemoved: captures.removed,
4488
+ captureBytesRemoved: captures.removedBytes,
4489
+ sharedFolderDefinitionsRemoved: sharedFolders.removed,
4490
+ trustedDeviceCredentialsRemoved: trustedDeviceCredentials,
4491
+ transferJobRecordsRemoved: transferJobs.removed,
4492
+ providerSessionRevoked: providerLogout.revoked === true
4493
+ }
4494
+ });
4495
+ res.json({
4496
+ ok: true,
4497
+ localDeleted: true,
4498
+ providerLogout,
4499
+ hostTarget,
4500
+ captures,
4501
+ sharedFolders,
4502
+ trustedDeviceCredentials,
4503
+ transferJobs,
4504
+ preserved: ['settings-preferences', 'hub-device-identity', 'application-binaries']
4505
+ });
4506
+ } catch (error) {
4507
+ securityAuditHealthy = false;
4508
+ res.status(503).json({ ok: false, error: String(error?.code || error?.message || 'privacy-delete-failed').slice(0, 120) });
4509
+ }
4510
+ });
4511
+
4512
+ app.delete('/api/security/trusted-devices/:deviceId', (req, res) => {
4513
+ noStore(res);
4514
+ const disconnected = remoteHub.disconnectDevice(req.params.deviceId, 'trusted-device-revoked');
4515
+ const credentialRevoked = remoteHub.revokeDeviceCredential(req.params.deviceId, 'trusted-device-revoked');
4516
+ const revoked = disconnected === true || credentialRevoked === true;
4517
+ recordSecurityAudit({
4518
+ action: 'security.device.revoked',
4519
+ phase: 'complete',
4520
+ result: revoked ? 'success' : 'not-found',
4521
+ deviceId: req.params.deviceId,
4522
+ authMethod: 'local-admin-session'
4523
+ });
4524
+ res.json({ ok: revoked, revoked, deviceId: req.params.deviceId });
4525
+ });
4448
4526
 
4449
4527
  app.post('/api/security/revoke-all', (_req, res) => {
4450
4528
  noStore(res);
4451
- const devices = remoteHub.listDevices({ includeDataUrl: false });
4452
- let revoked = 0;
4453
- for (const device of devices) {
4454
- const disconnected = device.connected && remoteHub.disconnectDevice(device.deviceId, 'trusted-devices-revoked');
4455
- const credentialRevoked = remoteHub.revokeDeviceCredential(device.deviceId, 'trusted-devices-revoked');
4456
- if (disconnected || credentialRevoked) revoked += 1;
4457
- }
4458
- recordSecurityAudit({ action: 'security.devices.revoke-all', phase: 'complete', result: 'success', authMethod: 'local-admin-session', details: { revoked } });
4459
- res.json({ ok: true, revoked });
4460
- });
4529
+ const devices = remoteHub.listDevices({ includeDataUrl: false });
4530
+ let revoked = 0;
4531
+ for (const device of devices) {
4532
+ const disconnected = device.connected && remoteHub.disconnectDevice(device.deviceId, 'trusted-devices-revoked');
4533
+ const credentialRevoked = remoteHub.revokeDeviceCredential(device.deviceId, 'trusted-devices-revoked');
4534
+ if (disconnected || credentialRevoked) revoked += 1;
4535
+ }
4536
+ recordSecurityAudit({ action: 'security.devices.revoke-all', phase: 'complete', result: 'success', authMethod: 'local-admin-session', details: { revoked } });
4537
+ res.json({ ok: true, revoked });
4538
+ });
4461
4539
 
4462
4540
  app.get('/api/settings/agent', async (_req, res) => {
4463
4541
  noStore(res);
@@ -4819,17 +4897,17 @@ app.post('/api/runtime/restart', (_req, res) => {
4819
4897
  }, 150);
4820
4898
  });
4821
4899
 
4822
- app.post('/api/runtime/shutdown', (req, res) => {
4823
- noStore(res);
4824
- recordSecurityAudit({
4825
- action: 'runtime.shutdown',
4826
- phase: 'requested',
4827
- result: 'accepted',
4828
- authMethod: req.liveDeskLauncherShutdown === true
4829
- ? 'launcher-internal-shutdown-token'
4830
- : 'local-admin-session'
4831
- });
4832
- runtimeManager.emit('runtime.shutdown.requested', { role: runtimeRole });
4900
+ app.post('/api/runtime/shutdown', (req, res) => {
4901
+ noStore(res);
4902
+ recordSecurityAudit({
4903
+ action: 'runtime.shutdown',
4904
+ phase: 'requested',
4905
+ result: 'accepted',
4906
+ authMethod: req.liveDeskLauncherShutdown === true
4907
+ ? 'launcher-internal-shutdown-token'
4908
+ : 'local-admin-session'
4909
+ });
4910
+ runtimeManager.emit('runtime.shutdown.requested', { role: runtimeRole });
4833
4911
  res.json({ ok: true, shuttingDown: true, role: runtimeRole });
4834
4912
  setTimeout(() => {
4835
4913
  void shutdownHub('API_SHUTDOWN')
@@ -4866,10 +4944,10 @@ app.post('/api/auth/session', async (req, res) => {
4866
4944
  }
4867
4945
  const { access_token: accessToken, refresh_token: refreshToken, expires_at: expiresAt } = normalized.session;
4868
4946
  try {
4869
- const user = await verifySupabaseUser(accessToken);
4870
- runtimeSessionGeneration += 1;
4871
- runtimeRefreshPromise = null;
4872
- runtimeAccessToken = accessToken;
4947
+ const user = await verifySupabaseUser(accessToken);
4948
+ runtimeSessionGeneration += 1;
4949
+ runtimeRefreshPromise = null;
4950
+ runtimeAccessToken = accessToken;
4873
4951
  runtimeRefreshToken = refreshToken;
4874
4952
  runtimeAccessTokenExpiresAt = normalizeSessionExpiryMs(expiresAt);
4875
4953
  runtimeManager.setAuthenticated(true, user.id);
@@ -4882,29 +4960,29 @@ app.post('/api/auth/session', async (req, res) => {
4882
4960
  const hostTarget = runtimeRole === 'hub'
4883
4961
  ? await publishHubHostTargetWithPendingRoleTakeover('session-received')
4884
4962
  : { ok: true, active: false };
4885
- if (runtimeRole === 'hub') {
4886
- startHubHostTargetLeaseRenewal();
4887
- }
4888
- recordSecurityAudit({
4889
- action: 'auth.session.established',
4890
- phase: 'complete',
4891
- result: 'success',
4892
- actorAccountId: user.id,
4893
- actorUserId: user.id,
4894
- authMethod: 'supabase-access-refresh',
4895
- requestHash: requestAuditHash({ userId: user.id, expiresAt })
4896
- });
4897
- res.json({ ok: true, authenticated: true, persisted, userId: user.id, role: runtimeRole, hostTarget });
4898
- } catch (error) {
4899
- const message = error instanceof Error ? error.message : String(error);
4900
- recordSecurityAudit({
4901
- action: 'auth.session.establish',
4902
- phase: 'complete',
4903
- result: 'rejected',
4904
- reason: message,
4905
- authMethod: 'supabase-access-refresh',
4906
- requestHash: requestAuditHash({ hasAccessToken: Boolean(req.body?.accessToken || req.body?.access_token), hasRefreshToken: Boolean(req.body?.refreshToken || req.body?.refresh_token) })
4907
- });
4963
+ if (runtimeRole === 'hub') {
4964
+ startHubHostTargetLeaseRenewal();
4965
+ }
4966
+ recordSecurityAudit({
4967
+ action: 'auth.session.established',
4968
+ phase: 'complete',
4969
+ result: 'success',
4970
+ actorAccountId: user.id,
4971
+ actorUserId: user.id,
4972
+ authMethod: 'supabase-access-refresh',
4973
+ requestHash: requestAuditHash({ userId: user.id, expiresAt })
4974
+ });
4975
+ res.json({ ok: true, authenticated: true, persisted, userId: user.id, role: runtimeRole, hostTarget });
4976
+ } catch (error) {
4977
+ const message = error instanceof Error ? error.message : String(error);
4978
+ recordSecurityAudit({
4979
+ action: 'auth.session.establish',
4980
+ phase: 'complete',
4981
+ result: 'rejected',
4982
+ reason: message,
4983
+ authMethod: 'supabase-access-refresh',
4984
+ requestHash: requestAuditHash({ hasAccessToken: Boolean(req.body?.accessToken || req.body?.access_token), hasRefreshToken: Boolean(req.body?.refreshToken || req.body?.refresh_token) })
4985
+ });
4908
4986
  const status = authVerificationHttpStatus(message);
4909
4987
  res.status(status).json({ ok: false, error: message });
4910
4988
  }
@@ -5198,33 +5276,33 @@ function startHubHostTargetLeaseRenewal() {
5198
5276
  hubHostTargetRenewTimer.unref?.();
5199
5277
  }
5200
5278
 
5201
- app.delete('/api/auth/session', async (_req, res) => {
5202
- noStore(res);
5203
- const actorUserId = runtimeManager.getSnapshot().userId || '';
5204
- const hostTarget = runtimeRole === 'hub'
5205
- ? await clearHubHostTarget('logout')
5206
- : { ok: true, active: false };
5207
- const providerLogout = await revokeRuntimeProviderSession();
5208
- clearRuntimeSession();
5209
- recordSecurityAudit({
5210
- action: 'auth.session.revoked',
5211
- phase: 'complete',
5212
- result: providerLogout.ok ? 'success' : 'provider-error',
5213
- actorAccountId: actorUserId,
5214
- actorUserId,
5215
- reason: providerLogout.error || '',
5216
- authMethod: 'supabase-local-signout',
5217
- details: { localCleared: true, providerRevoked: providerLogout.revoked === true, alreadyInvalid: providerLogout.alreadyInvalid === true }
5218
- });
5219
- res.status(providerLogout.ok ? 200 : 502).json({
5220
- ok: providerLogout.ok,
5221
- authenticated: false,
5222
- localCleared: true,
5223
- role: runtimeRole,
5224
- hostTarget,
5225
- providerLogout
5226
- });
5227
- });
5279
+ app.delete('/api/auth/session', async (_req, res) => {
5280
+ noStore(res);
5281
+ const actorUserId = runtimeManager.getSnapshot().userId || '';
5282
+ const hostTarget = runtimeRole === 'hub'
5283
+ ? await clearHubHostTarget('logout')
5284
+ : { ok: true, active: false };
5285
+ const providerLogout = await revokeRuntimeProviderSession();
5286
+ clearRuntimeSession();
5287
+ recordSecurityAudit({
5288
+ action: 'auth.session.revoked',
5289
+ phase: 'complete',
5290
+ result: providerLogout.ok ? 'success' : 'provider-error',
5291
+ actorAccountId: actorUserId,
5292
+ actorUserId,
5293
+ reason: providerLogout.error || '',
5294
+ authMethod: 'supabase-local-signout',
5295
+ details: { localCleared: true, providerRevoked: providerLogout.revoked === true, alreadyInvalid: providerLogout.alreadyInvalid === true }
5296
+ });
5297
+ res.status(providerLogout.ok ? 200 : 502).json({
5298
+ ok: providerLogout.ok,
5299
+ authenticated: false,
5300
+ localCleared: true,
5301
+ role: runtimeRole,
5302
+ hostTarget,
5303
+ providerLogout
5304
+ });
5305
+ });
5228
5306
 
5229
5307
  app.get('/api/hub/status', (_req, res) => {
5230
5308
  noStore(res);
@@ -5571,37 +5649,37 @@ app.post('/api/remote/files/from-hub', requireHubFeatureAccess, (req, res) => {
5571
5649
  res.status(400).json({ ok: false, error: 'no-filesystem-items-selected' });
5572
5650
  return;
5573
5651
  }
5574
- const job = hubTransferJobs.create({
5575
- itemIds,
5576
- deviceIds,
5577
- remoteDirectory: req.body?.remoteDirectory,
5578
- onComplete: async ({ completed, job: completedJob }) => {
5579
- await recordSecurityAuditRequired({
5580
- action: 'remote.file.job',
5581
- phase: 'complete',
5582
- result: completed ? 'success' : 'failed',
5583
- reason: completed ? '' : completedJob?.error || 'file-transfer-failed',
5584
- deviceIds,
5585
- sessionId: completedJob?.jobId || '',
5586
- requestHash: requestAuditHash({
5587
- jobId: completedJob?.jobId || '',
5588
- deviceIds,
5589
- remoteDirectory: req.body?.remoteDirectory || ''
5590
- }),
5591
- authMethod: 'local-admin-session',
5592
- details: {
5593
- state: completedJob?.state || '',
5594
- totalFiles: Number(completedJob?.totalFiles || 0),
5595
- completedFiles: Number(completedJob?.completedFiles || 0),
5596
- totalBytes: Number(completedJob?.totalBytes || 0),
5597
- sentBytes: Number(completedJob?.sentBytes || 0),
5598
- failedTargets: Array.isArray(completedJob?.failedTargets)
5599
- ? completedJob.failedTargets.length
5600
- : 0
5601
- }
5602
- });
5603
- }
5604
- });
5652
+ const job = hubTransferJobs.create({
5653
+ itemIds,
5654
+ deviceIds,
5655
+ remoteDirectory: req.body?.remoteDirectory,
5656
+ onComplete: async ({ completed, job: completedJob }) => {
5657
+ await recordSecurityAuditRequired({
5658
+ action: 'remote.file.job',
5659
+ phase: 'complete',
5660
+ result: completed ? 'success' : 'failed',
5661
+ reason: completed ? '' : completedJob?.error || 'file-transfer-failed',
5662
+ deviceIds,
5663
+ sessionId: completedJob?.jobId || '',
5664
+ requestHash: requestAuditHash({
5665
+ jobId: completedJob?.jobId || '',
5666
+ deviceIds,
5667
+ remoteDirectory: req.body?.remoteDirectory || ''
5668
+ }),
5669
+ authMethod: 'local-admin-session',
5670
+ details: {
5671
+ state: completedJob?.state || '',
5672
+ totalFiles: Number(completedJob?.totalFiles || 0),
5673
+ completedFiles: Number(completedJob?.completedFiles || 0),
5674
+ totalBytes: Number(completedJob?.totalBytes || 0),
5675
+ sentBytes: Number(completedJob?.sentBytes || 0),
5676
+ failedTargets: Array.isArray(completedJob?.failedTargets)
5677
+ ? completedJob.failedTargets.length
5678
+ : 0
5679
+ }
5680
+ });
5681
+ }
5682
+ });
5605
5683
  res.status(202).json({ ok: true, jobId: job.jobId, state: job.state });
5606
5684
  } catch (error) {
5607
5685
  sendFilesystemError(res, error);
@@ -5682,80 +5760,80 @@ app.post('/api/remote/filesystem/shared-folders/:folderId/sync', requireHubFeatu
5682
5760
  }
5683
5761
  });
5684
5762
 
5685
- app.post('/api/remote/files/transfer', requireHubFeatureAccess, async (req, res) => {
5763
+ app.post('/api/remote/files/transfer', requireHubFeatureAccess, async (req, res) => {
5686
5764
  noStore(res);
5687
5765
  const deviceIds = normalizeDeviceIds(req.body?.deviceIds);
5688
- if (deviceIds.length === 0) {
5689
- recordSecurityAudit({ action: 'remote.file.transfer', phase: 'complete', result: 'rejected', reason: 'no-target-devices', authMethod: 'local-admin-session' });
5690
- res.status(400).json({ ok: false, error: 'no-target-devices' });
5766
+ if (deviceIds.length === 0) {
5767
+ recordSecurityAudit({ action: 'remote.file.transfer', phase: 'complete', result: 'rejected', reason: 'no-target-devices', authMethod: 'local-admin-session' });
5768
+ res.status(400).json({ ok: false, error: 'no-target-devices' });
5691
5769
  return;
5692
5770
  }
5693
5771
  const normalized = normalizeTransferFiles(req.body?.files);
5694
- if (!normalized.ok) {
5695
- recordSecurityAudit({ action: 'remote.file.transfer', phase: 'complete', result: 'rejected', reason: normalized.error, deviceIds, authMethod: 'local-admin-session' });
5772
+ if (!normalized.ok) {
5773
+ recordSecurityAudit({ action: 'remote.file.transfer', phase: 'complete', result: 'rejected', reason: normalized.error, deviceIds, authMethod: 'local-admin-session' });
5696
5774
  res.status(400).json({ ok: false, error: normalized.error, totalBytes: normalized.totalBytes || 0 });
5697
5775
  return;
5698
5776
  }
5699
5777
  const transferId = String(req.body?.transferId || crypto.randomUUID()).replace(/[^a-zA-Z0-9_.:-]/g, '-').slice(0, 128);
5700
5778
  const remoteDirectory = String(req.body?.remoteDirectory || '').replace(/[\0\r\n\t]/g, ' ').trim().slice(0, 600);
5701
5779
  const queuedAt = new Date().toISOString();
5702
- try {
5703
- const results = await Promise.all(deviceIds.map(async deviceId => ({
5704
- deviceId,
5705
- ...await remoteHub.sendCommandAwaitResult(deviceId, {
5706
- command: 'file.transfer',
5707
- payload: {
5708
- transferId,
5709
- remoteDirectory,
5710
- files: normalized.files,
5711
- totalBytes: normalized.totalBytes,
5712
- requestedAt: queuedAt
5713
- }
5714
- }, { timeoutMs: fileTransferCommandAckTimeoutMs })
5715
- })));
5716
- const queued = results.filter(result => result.queued).length;
5717
- const acknowledged = results.filter(result => result.acknowledged).length;
5718
- const success = acknowledged === deviceIds.length;
5719
- const partial = acknowledged > 0 && !success;
5720
- const error = success ? undefined : partial ? 'partial-transfer-failed' : 'no-transfer-acknowledged';
5721
- await recordSecurityAuditRequired({
5722
- action: 'remote.file.transfer',
5723
- phase: 'complete',
5724
- result: success ? 'success' : partial ? 'partial' : 'rejected',
5725
- reason: error || '',
5726
- deviceIds,
5727
- sessionId: transferId,
5728
- requestHash: requestAuditHash({ transferId, remoteDirectory, files: normalized.files.map(file => ({ name: file.name, relativePath: file.relativePath, byteLength: file.size })), totalBytes: normalized.totalBytes }),
5729
- authMethod: 'local-admin-session',
5730
- details: { queued, acknowledged, total: deviceIds.length, totalBytes: normalized.totalBytes, files: normalized.files.length }
5731
- });
5732
- res.json({
5733
- ok: success,
5734
- transferId,
5735
- queued,
5736
- acknowledged,
5737
- total: deviceIds.length,
5738
- totalBytes: normalized.totalBytes,
5739
- files: normalized.files.length,
5740
- results,
5741
- error
5742
- });
5743
- } catch (error) {
5744
- res.status(503).json({ ok: false, error: error?.code || 'security-audit-unavailable' });
5745
- }
5746
- });
5747
-
5748
- app.post('/api/remote/files/chunk', requireHubFeatureAccess, async (req, res) => {
5780
+ try {
5781
+ const results = await Promise.all(deviceIds.map(async deviceId => ({
5782
+ deviceId,
5783
+ ...await remoteHub.sendCommandAwaitResult(deviceId, {
5784
+ command: 'file.transfer',
5785
+ payload: {
5786
+ transferId,
5787
+ remoteDirectory,
5788
+ files: normalized.files,
5789
+ totalBytes: normalized.totalBytes,
5790
+ requestedAt: queuedAt
5791
+ }
5792
+ }, { timeoutMs: fileTransferCommandAckTimeoutMs })
5793
+ })));
5794
+ const queued = results.filter(result => result.queued).length;
5795
+ const acknowledged = results.filter(result => result.acknowledged).length;
5796
+ const success = acknowledged === deviceIds.length;
5797
+ const partial = acknowledged > 0 && !success;
5798
+ const error = success ? undefined : partial ? 'partial-transfer-failed' : 'no-transfer-acknowledged';
5799
+ await recordSecurityAuditRequired({
5800
+ action: 'remote.file.transfer',
5801
+ phase: 'complete',
5802
+ result: success ? 'success' : partial ? 'partial' : 'rejected',
5803
+ reason: error || '',
5804
+ deviceIds,
5805
+ sessionId: transferId,
5806
+ requestHash: requestAuditHash({ transferId, remoteDirectory, files: normalized.files.map(file => ({ name: file.name, relativePath: file.relativePath, byteLength: file.size })), totalBytes: normalized.totalBytes }),
5807
+ authMethod: 'local-admin-session',
5808
+ details: { queued, acknowledged, total: deviceIds.length, totalBytes: normalized.totalBytes, files: normalized.files.length }
5809
+ });
5810
+ res.json({
5811
+ ok: success,
5812
+ transferId,
5813
+ queued,
5814
+ acknowledged,
5815
+ total: deviceIds.length,
5816
+ totalBytes: normalized.totalBytes,
5817
+ files: normalized.files.length,
5818
+ results,
5819
+ error
5820
+ });
5821
+ } catch (error) {
5822
+ res.status(503).json({ ok: false, error: error?.code || 'security-audit-unavailable' });
5823
+ }
5824
+ });
5825
+
5826
+ app.post('/api/remote/files/chunk', requireHubFeatureAccess, async (req, res) => {
5749
5827
  noStore(res);
5750
5828
  const deviceIds = normalizeDeviceIds(req.body?.deviceIds);
5751
- if (deviceIds.length === 0) {
5752
- recordSecurityAudit({ action: 'remote.file.chunk', phase: 'complete', result: 'rejected', reason: 'no-target-devices', authMethod: 'local-admin-session' });
5829
+ if (deviceIds.length === 0) {
5830
+ recordSecurityAudit({ action: 'remote.file.chunk', phase: 'complete', result: 'rejected', reason: 'no-target-devices', authMethod: 'local-admin-session' });
5753
5831
  res.status(400).json({ ok: false, error: 'no-target-devices' });
5754
5832
  return;
5755
5833
  }
5756
5834
  const normalized = normalizeTransferChunk(req.body || {});
5757
- if (!normalized.ok) {
5758
- recordSecurityAudit({ action: 'remote.file.chunk', phase: 'complete', result: 'rejected', reason: normalized.error, deviceIds, authMethod: 'local-admin-session' });
5835
+ if (!normalized.ok) {
5836
+ recordSecurityAudit({ action: 'remote.file.chunk', phase: 'complete', result: 'rejected', reason: normalized.error, deviceIds, authMethod: 'local-admin-session' });
5759
5837
  res.status(400).json({ ok: false, error: normalized.error, byteLength: normalized.byteLength || 0 });
5760
5838
  return;
5761
5839
  }
@@ -5763,51 +5841,51 @@ app.post('/api/remote/files/chunk', requireHubFeatureAccess, async (req, res) =>
5763
5841
  const transferId = String(req.body?.transferId || crypto.randomUUID()).replace(/[^a-zA-Z0-9_.:-]/g, '-').slice(0, 128);
5764
5842
  const remoteDirectory = String(req.body?.remoteDirectory || '').replace(/[\0\r\n\t]/g, ' ').trim().slice(0, 600);
5765
5843
  const queuedAt = new Date().toISOString();
5766
- try {
5767
- const results = await Promise.all(deviceIds.map(async deviceId => ({
5768
- deviceId,
5769
- ...await remoteHub.sendCommandAwaitResult(deviceId, {
5770
- command: 'file.transfer.chunk',
5771
- payload: {
5772
- transferId,
5773
- remoteDirectory,
5774
- ...normalized.chunk,
5775
- requestedAt: queuedAt
5776
- }
5777
- }, { timeoutMs: fileTransferCommandAckTimeoutMs })
5778
- })));
5779
- const queued = results.filter(result => result.queued).length;
5780
- const acknowledged = results.filter(result => result.acknowledged).length;
5781
- const success = acknowledged === deviceIds.length;
5782
- const partial = acknowledged > 0 && !success;
5783
- const error = success ? undefined : partial ? 'partial-transfer-failed' : 'no-transfer-acknowledged';
5784
- await recordSecurityAuditRequired({
5785
- action: 'remote.file.chunk',
5786
- phase: normalized.chunk.final ? 'complete' : 'progress',
5787
- result: success ? 'success' : partial ? 'partial' : 'rejected',
5788
- reason: error || '',
5789
- deviceIds,
5790
- sessionId: transferId,
5791
- requestHash: requestAuditHash({ transferId, remoteDirectory, offset: normalized.chunk.offset, byteLength: normalized.chunk.byteLength, final: normalized.chunk.final }),
5792
- authMethod: 'local-admin-session',
5793
- details: { queued, acknowledged, total: deviceIds.length, byteLength: normalized.chunk.byteLength, offset: normalized.chunk.offset, final: normalized.chunk.final }
5794
- });
5795
- res.json({
5796
- ok: success,
5797
- transferId,
5798
- queued,
5799
- acknowledged,
5800
- total: deviceIds.length,
5801
- byteLength: normalized.chunk.byteLength,
5802
- offset: normalized.chunk.offset,
5803
- final: normalized.chunk.final,
5804
- results,
5805
- error
5806
- });
5807
- } catch (error) {
5808
- res.status(503).json({ ok: false, error: error?.code || 'security-audit-unavailable' });
5809
- }
5810
- });
5844
+ try {
5845
+ const results = await Promise.all(deviceIds.map(async deviceId => ({
5846
+ deviceId,
5847
+ ...await remoteHub.sendCommandAwaitResult(deviceId, {
5848
+ command: 'file.transfer.chunk',
5849
+ payload: {
5850
+ transferId,
5851
+ remoteDirectory,
5852
+ ...normalized.chunk,
5853
+ requestedAt: queuedAt
5854
+ }
5855
+ }, { timeoutMs: fileTransferCommandAckTimeoutMs })
5856
+ })));
5857
+ const queued = results.filter(result => result.queued).length;
5858
+ const acknowledged = results.filter(result => result.acknowledged).length;
5859
+ const success = acknowledged === deviceIds.length;
5860
+ const partial = acknowledged > 0 && !success;
5861
+ const error = success ? undefined : partial ? 'partial-transfer-failed' : 'no-transfer-acknowledged';
5862
+ await recordSecurityAuditRequired({
5863
+ action: 'remote.file.chunk',
5864
+ phase: normalized.chunk.final ? 'complete' : 'progress',
5865
+ result: success ? 'success' : partial ? 'partial' : 'rejected',
5866
+ reason: error || '',
5867
+ deviceIds,
5868
+ sessionId: transferId,
5869
+ requestHash: requestAuditHash({ transferId, remoteDirectory, offset: normalized.chunk.offset, byteLength: normalized.chunk.byteLength, final: normalized.chunk.final }),
5870
+ authMethod: 'local-admin-session',
5871
+ details: { queued, acknowledged, total: deviceIds.length, byteLength: normalized.chunk.byteLength, offset: normalized.chunk.offset, final: normalized.chunk.final }
5872
+ });
5873
+ res.json({
5874
+ ok: success,
5875
+ transferId,
5876
+ queued,
5877
+ acknowledged,
5878
+ total: deviceIds.length,
5879
+ byteLength: normalized.chunk.byteLength,
5880
+ offset: normalized.chunk.offset,
5881
+ final: normalized.chunk.final,
5882
+ results,
5883
+ error
5884
+ });
5885
+ } catch (error) {
5886
+ res.status(503).json({ ok: false, error: error?.code || 'security-audit-unavailable' });
5887
+ }
5888
+ });
5811
5889
 
5812
5890
  const MANUAL_POWER_ACTIONS = new Set(['lock', 'sleep', 'restart', 'shutdown']);
5813
5891
 
@@ -6080,7 +6158,7 @@ app.post('/api/remote/devices/:deviceId/audio/stop', async (req, res, next) => {
6080
6158
  }
6081
6159
  });
6082
6160
 
6083
- if (existsSync(webIndexPath)) {
6161
+ if (existsSync(webIndexPath)) {
6084
6162
  app.use(express.static(webDistPath, {
6085
6163
  etag: true,
6086
6164
  index: false,
@@ -6093,70 +6171,70 @@ if (existsSync(webIndexPath)) {
6093
6171
  }
6094
6172
  res.sendFile(webIndexPath);
6095
6173
  });
6096
- }
6097
-
6098
- function isAuthorizedAdminWebSocket(req) {
6099
- if (!enforceLocalAdminAuth) return true;
6100
- const protocols = String(req.headers['sec-websocket-protocol'] || '')
6101
- .split(',')
6102
- .map(value => value.trim())
6103
- .filter(Boolean);
6104
- const adminToken = protocols.find(value => value.startsWith('livedesk-admin-'))?.slice('livedesk-admin-'.length) || '';
6105
- const csrfToken = protocols.find(value => value.startsWith('livedesk-admin-csrf-'))?.slice('livedesk-admin-csrf-'.length) || '';
6106
- return secureExactTestTokenMatches(adminToken, localAdminSessionToken)
6107
- && secureExactTestTokenMatches(csrfToken, localAdminCsrfToken);
6108
- }
6109
-
6110
- httpServer.on('upgrade', (req, socket, head) => {
6111
- if (!isTrustedBrowserRequest(req)) {
6174
+ }
6175
+
6176
+ function isAuthorizedAdminWebSocket(req) {
6177
+ if (!enforceLocalAdminAuth) return true;
6178
+ const protocols = String(req.headers['sec-websocket-protocol'] || '')
6179
+ .split(',')
6180
+ .map(value => value.trim())
6181
+ .filter(Boolean);
6182
+ const adminToken = protocols.find(value => value.startsWith('livedesk-admin-'))?.slice('livedesk-admin-'.length) || '';
6183
+ const csrfToken = protocols.find(value => value.startsWith('livedesk-admin-csrf-'))?.slice('livedesk-admin-csrf-'.length) || '';
6184
+ return secureExactTestTokenMatches(adminToken, localAdminSessionToken)
6185
+ && secureExactTestTokenMatches(csrfToken, localAdminCsrfToken);
6186
+ }
6187
+
6188
+ httpServer.on('upgrade', (req, socket, head) => {
6189
+ if (!isTrustedBrowserRequest(req)) {
6112
6190
  socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
6113
6191
  socket.destroy();
6114
- return;
6115
- }
6116
- if (!isAuthorizedAdminWebSocket(req)) {
6117
- recordSecurityAudit({
6118
- action: 'local-admin.websocket',
6119
- phase: 'complete',
6120
- result: 'rejected',
6121
- reason: 'admin-session-required',
6122
- requestHash: requestAuditHash({ path: req.url || '' }),
6123
- authMethod: 'local-admin-websocket'
6124
- });
6125
- socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
6126
- socket.destroy();
6127
- return;
6128
- }
6129
- let parsed;
6130
- try {
6131
- parsed = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
6132
- } catch {
6133
- socket.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
6134
- socket.destroy();
6135
- return;
6136
- }
6137
- const websocketPath = parsed.pathname;
6138
- if (![
6139
- '/api/remote/frames/ws',
6140
- '/api/remote/atlas/ws',
6141
- '/api/remote/input/ws',
6142
- '/api/remote/audio/ws'
6143
- ].includes(websocketPath)) {
6144
- socket.write('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n');
6145
- socket.destroy();
6146
- return;
6147
- }
6148
- void recordSecurityAuditRequired({
6149
- action: 'local-admin.websocket',
6150
- phase: 'accepted',
6151
- result: 'accepted',
6152
- sessionId: localAdminSessionId,
6153
- requestHash: requestAuditHash({ path: websocketPath }),
6154
- authMethod: 'local-admin-websocket',
6155
- details: { path: websocketPath }
6156
- }).then(() => {
6157
- if (parsed.pathname === '/api/remote/frames/ws') {
6158
- frameWss.handleUpgrade(req, socket, head, ws => frameWss.emit('connection', ws, req));
6159
- return;
6192
+ return;
6193
+ }
6194
+ if (!isAuthorizedAdminWebSocket(req)) {
6195
+ recordSecurityAudit({
6196
+ action: 'local-admin.websocket',
6197
+ phase: 'complete',
6198
+ result: 'rejected',
6199
+ reason: 'admin-session-required',
6200
+ requestHash: requestAuditHash({ path: req.url || '' }),
6201
+ authMethod: 'local-admin-websocket'
6202
+ });
6203
+ socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
6204
+ socket.destroy();
6205
+ return;
6206
+ }
6207
+ let parsed;
6208
+ try {
6209
+ parsed = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
6210
+ } catch {
6211
+ socket.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
6212
+ socket.destroy();
6213
+ return;
6214
+ }
6215
+ const websocketPath = parsed.pathname;
6216
+ if (![
6217
+ '/api/remote/frames/ws',
6218
+ '/api/remote/atlas/ws',
6219
+ '/api/remote/input/ws',
6220
+ '/api/remote/audio/ws'
6221
+ ].includes(websocketPath)) {
6222
+ socket.write('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n');
6223
+ socket.destroy();
6224
+ return;
6225
+ }
6226
+ void recordSecurityAuditRequired({
6227
+ action: 'local-admin.websocket',
6228
+ phase: 'accepted',
6229
+ result: 'accepted',
6230
+ sessionId: localAdminSessionId,
6231
+ requestHash: requestAuditHash({ path: websocketPath }),
6232
+ authMethod: 'local-admin-websocket',
6233
+ details: { path: websocketPath }
6234
+ }).then(() => {
6235
+ if (parsed.pathname === '/api/remote/frames/ws') {
6236
+ frameWss.handleUpgrade(req, socket, head, ws => frameWss.emit('connection', ws, req));
6237
+ return;
6160
6238
  }
6161
6239
  if (parsed.pathname === '/api/remote/atlas/ws') {
6162
6240
  atlasWss.handleUpgrade(req, socket, head, ws => atlasWss.emit('connection', ws, req));
@@ -6167,14 +6245,14 @@ httpServer.on('upgrade', (req, socket, head) => {
6167
6245
  return;
6168
6246
  }
6169
6247
  if (parsed.pathname === '/api/remote/audio/ws') {
6170
- audioWss.handleUpgrade(req, socket, head, ws => audioWss.emit('connection', ws, req));
6171
- return;
6172
- }
6173
- }).catch(() => {
6174
- socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n');
6175
- socket.destroy();
6176
- });
6177
- });
6248
+ audioWss.handleUpgrade(req, socket, head, ws => audioWss.emit('connection', ws, req));
6249
+ return;
6250
+ }
6251
+ }).catch(() => {
6252
+ socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n');
6253
+ socket.destroy();
6254
+ });
6255
+ });
6178
6256
 
6179
6257
  frameWss.on('connection', (ws, req) => {
6180
6258
  frameClients.add(ws);
@@ -6286,22 +6364,22 @@ audioWss.on('connection', (ws, req) => {
6286
6364
  });
6287
6365
  });
6288
6366
 
6289
- inputWss.on('connection', ws => {
6290
- inputClients.add(ws);
6291
- ws.liveDeskInputClientId = `riws-${++inputClientSeq}`;
6292
- ws.liveDeskInputDeviceIds = new Set();
6293
- ws.liveDeskInputAuditReady = false;
6367
+ inputWss.on('connection', ws => {
6368
+ inputClients.add(ws);
6369
+ ws.liveDeskInputClientId = `riws-${++inputClientSeq}`;
6370
+ ws.liveDeskInputDeviceIds = new Set();
6371
+ ws.liveDeskInputAuditReady = false;
6294
6372
  try {
6295
6373
  ws._socket?.setNoDelay?.(true);
6296
6374
  } catch {
6297
6375
  // Best-effort latency hint for browser input sockets.
6298
- }
6299
- ws.on('message', data => {
6300
- if (ws.liveDeskInputAuditReady !== true) {
6301
- sendJson(ws, { type: 'RemoteInputError', error: 'security-audit-pending' });
6302
- return;
6303
- }
6304
- let payload;
6376
+ }
6377
+ ws.on('message', data => {
6378
+ if (ws.liveDeskInputAuditReady !== true) {
6379
+ sendJson(ws, { type: 'RemoteInputError', error: 'security-audit-pending' });
6380
+ return;
6381
+ }
6382
+ let payload;
6305
6383
  try {
6306
6384
  payload = JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data || ''));
6307
6385
  } catch {
@@ -6340,46 +6418,46 @@ inputWss.on('connection', ws => {
6340
6418
  timestamp: new Date().toISOString()
6341
6419
  });
6342
6420
  });
6343
- const releaseBrowserInputOwner = reason => {
6344
- if (ws.liveDeskInputReleased) return;
6345
- ws.liveDeskInputReleased = true;
6346
- inputClients.delete(ws);
6347
- for (const deviceId of ws.liveDeskInputDeviceIds || []) {
6348
- remoteHub.releaseInputOwner(deviceId, ws.liveDeskInputClientId, reason);
6349
- }
6350
- void recordSecurityAuditRequired({
6351
- action: 'remote.control.browser-session',
6352
- phase: 'complete',
6353
- result: 'success',
6354
- reason,
6355
- deviceIds: [...(ws.liveDeskInputDeviceIds || [])],
6356
- sessionId: ws.liveDeskInputClientId,
6357
- authMethod: 'local-admin-websocket'
6358
- }).catch(() => {
6359
- // The global fail-closed gate is marked by recordSecurityAuditRequired.
6360
- });
6361
- ws.liveDeskInputDeviceIds?.clear?.();
6362
- };
6363
- ws.on('close', () => releaseBrowserInputOwner('browser-input-websocket-closed'));
6364
- ws.on('error', () => releaseBrowserInputOwner('browser-input-websocket-error'));
6365
- void recordSecurityAuditRequired({
6366
- action: 'remote.control.browser-session',
6367
- phase: 'start',
6368
- result: 'success',
6369
- sessionId: ws.liveDeskInputClientId,
6370
- authMethod: 'local-admin-websocket'
6371
- }).then(() => {
6372
- ws.liveDeskInputAuditReady = true;
6373
- sendJson(ws, {
6374
- type: 'RemoteInputSocketReady',
6375
- protocol: 'livedesk.remote.input.json.v1',
6376
- clientId: ws.liveDeskInputClientId,
6377
- timestamp: new Date().toISOString()
6378
- });
6379
- }).catch(() => {
6380
- ws.close(1011, 'security-audit-unavailable');
6381
- });
6382
- });
6421
+ const releaseBrowserInputOwner = reason => {
6422
+ if (ws.liveDeskInputReleased) return;
6423
+ ws.liveDeskInputReleased = true;
6424
+ inputClients.delete(ws);
6425
+ for (const deviceId of ws.liveDeskInputDeviceIds || []) {
6426
+ remoteHub.releaseInputOwner(deviceId, ws.liveDeskInputClientId, reason);
6427
+ }
6428
+ void recordSecurityAuditRequired({
6429
+ action: 'remote.control.browser-session',
6430
+ phase: 'complete',
6431
+ result: 'success',
6432
+ reason,
6433
+ deviceIds: [...(ws.liveDeskInputDeviceIds || [])],
6434
+ sessionId: ws.liveDeskInputClientId,
6435
+ authMethod: 'local-admin-websocket'
6436
+ }).catch(() => {
6437
+ // The global fail-closed gate is marked by recordSecurityAuditRequired.
6438
+ });
6439
+ ws.liveDeskInputDeviceIds?.clear?.();
6440
+ };
6441
+ ws.on('close', () => releaseBrowserInputOwner('browser-input-websocket-closed'));
6442
+ ws.on('error', () => releaseBrowserInputOwner('browser-input-websocket-error'));
6443
+ void recordSecurityAuditRequired({
6444
+ action: 'remote.control.browser-session',
6445
+ phase: 'start',
6446
+ result: 'success',
6447
+ sessionId: ws.liveDeskInputClientId,
6448
+ authMethod: 'local-admin-websocket'
6449
+ }).then(() => {
6450
+ ws.liveDeskInputAuditReady = true;
6451
+ sendJson(ws, {
6452
+ type: 'RemoteInputSocketReady',
6453
+ protocol: 'livedesk.remote.input.json.v1',
6454
+ clientId: ws.liveDeskInputClientId,
6455
+ timestamp: new Date().toISOString()
6456
+ });
6457
+ }).catch(() => {
6458
+ ws.close(1011, 'security-audit-unavailable');
6459
+ });
6460
+ });
6383
6461
 
6384
6462
  await remoteHub.start();
6385
6463
  connectedDeviceCount = Number(remoteHub.getStatus({ includeSecrets: false }).connectedDeviceCount || 0);
@@ -6473,8 +6551,8 @@ function shutdownHub(signal) {
6473
6551
  hubShutdownPromise = (async () => {
6474
6552
  const startedAt = Date.now();
6475
6553
  console.log(`[LiveDesk Hub] Shutdown started signal=${signal} grace=${hubShutdownGraceMs}ms timeout=${hubShutdownTimeoutMs}ms.`);
6476
- if (roleWatchTimer) clearInterval(roleWatchTimer);
6477
- clearInterval(captureRetentionTimer);
6554
+ if (roleWatchTimer) clearInterval(roleWatchTimer);
6555
+ clearInterval(captureRetentionTimer);
6478
6556
  clearInterval(browserWebSocketHeartbeatTimer);
6479
6557
  runSynchronousShutdownStep('update manager close', () => liveDeskUpdateManager?.close());
6480
6558
  atlasClients.clear();