@livedesk/hub 0.1.31 → 0.1.33

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,57 +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 recordSecurityAudit(event = {}) {
818
- if (!securityAuditStore) return;
819
- const runtime = runtimeManager.getSnapshot();
820
- void securityAuditStore.record({
821
- actorAccountId: event.actorAccountId || runtime.userId || '',
822
- actorUserId: event.actorUserId || runtime.userId || '',
823
- hubId: event.hubId || runtime.deviceId || '',
824
- ...event
825
- }).catch(error => {
826
- securityAuditHealthy = false;
827
- console.error(`[LiveDesk Hub] SECURITY AUDIT FAILURE: ${error?.message || error}`);
828
- });
829
- }
830
-
831
- const liveDeskSettingsStore = new LiveDeskSettingsStore({ dataDir: agentDataDir });
832
- const captureStore = new CaptureStore({
833
- dataDir: agentDataDir,
834
- getRetentionPolicy: () => liveDeskSettingsStore.getCached()?.filesAudio?.captureAutoDelete || 'never',
835
- onRetentionDelete: result => recordSecurityAudit({
836
- action: 'privacy.capture.retention-delete',
837
- phase: 'complete',
838
- result: 'success',
839
- authMethod: 'settings-retention-policy',
840
- details: result
841
- })
842
- });
843
- void captureStore.initialize().catch(error => {
844
- console.warn(`[LiveDesk Hub] capture store initialization failed: ${error instanceof Error ? error.message : String(error)}`);
845
- });
846
- void liveDeskSettingsStore.getRecord()
847
- .then(() => captureStore.enforceRetention())
848
- .catch(error => {
849
- console.warn(`[LiveDesk Hub] settings or capture retention load failed: ${error instanceof Error ? error.message : String(error)}`);
850
- });
851
- const captureRetentionTimer = setInterval(() => {
852
- void captureStore.enforceRetention().catch(error => {
853
- console.warn(`[LiveDesk Hub] capture retention failed: ${error instanceof Error ? error.message : String(error)}`);
854
- });
855
- }, 60 * 60_000);
856
- 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?.();
857
881
 
858
882
  const udpTransport = createHubUdpTransport({
859
883
  env: process.env,
@@ -861,10 +885,10 @@ const udpTransport = createHubUdpTransport({
861
885
  logWarn: (_scope, message) => console.warn(`[LiveDesk Hub] ${message}`)
862
886
  });
863
887
 
864
- const remoteHub = createRemoteHub({
865
- managerPackage: '@livedesk/hub',
866
- managerVersion: packageInfo.version,
867
- dataDir: agentDataDir,
888
+ const remoteHub = createRemoteHub({
889
+ managerPackage: '@livedesk/hub',
890
+ managerVersion: packageInfo.version,
891
+ dataDir: agentDataDir,
868
892
  env: {
869
893
  ...process.env,
870
894
  MINDEXEC_MANAGER_PACKAGE: '@livedesk/hub',
@@ -875,11 +899,11 @@ const remoteHub = createRemoteHub({
875
899
  logWarn: (_scope, message) => console.warn(`[LiveDesk Hub] ${message}`),
876
900
  emitEvent: handleRemoteHubEvent,
877
901
  emitFrame: broadcastRemoteBinaryFrame,
878
- emitAudio: broadcastRemoteBinaryAudio,
879
- getSecurityIdentity: () => ({
880
- accountId: runtimeManager.getSnapshot().userId || testSecurityAccountId,
881
- hubId: remoteHub?.getSecurityStatus?.().hubId || ''
882
- }),
902
+ emitAudio: broadcastRemoteBinaryAudio,
903
+ getSecurityIdentity: () => ({
904
+ accountId: runtimeManager.getSnapshot().userId || testSecurityAccountId,
905
+ hubId: remoteHub?.getSecurityStatus?.().hubId || ''
906
+ }),
883
907
  getEffectiveDevicePolicy: ({ deviceId, capabilities }) => buildEffectiveDevicePolicy(liveDeskSettingsStore.getCached(), { deviceId, capabilities }),
884
908
  getWelcomeDevicePolicy: ({ deviceId, capabilities }) => {
885
909
  const policy = buildEffectiveDevicePolicy(liveDeskSettingsStore.getCached(), { deviceId, capabilities });
@@ -982,11 +1006,11 @@ function getLiveDeskUpdateStatus() {
982
1006
  if (process.env.LIVEDESK_DESKTOP_HOST !== '1') {
983
1007
  liveDeskUpdateManager = createLiveDeskUpdateManager({
984
1008
  remoteHub,
985
- currentManagerVersion: process.env.LIVEDESK_MANAGER_VERSION || packageInfo.version,
986
- currentClientVersion: process.env.LIVEDESK_CLIENT_PACKAGE_VERSION || '',
987
- updateManifestUrl: process.env.LIVEDESK_UPDATE_MANIFEST_URL || '',
988
- updatePublicKey: process.env.LIVEDESK_UPDATE_PUBLIC_KEY || '',
989
- 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(),
990
1014
  requestHubRestart
991
1015
  });
992
1016
  }
@@ -1304,7 +1328,7 @@ function validateAgentMcpArguments(name, args) {
1304
1328
  return '';
1305
1329
  }
1306
1330
 
1307
- async function dispatchAgentMcpTool(session, name, args = {}) {
1331
+ async function dispatchAgentMcpTool(session, name, args = {}) {
1308
1332
  if (session.cancelled || session.signal?.aborted) return { ok: false, error: 'cancelled-by-user' };
1309
1333
  // This check runs synchronously before the first await, so concurrent Node
1310
1334
  // requests cannot pass the same remaining budget and queue extra Client work.
@@ -1312,23 +1336,23 @@ async function dispatchAgentMcpTool(session, name, args = {}) {
1312
1336
  session.toolLimitReached = true;
1313
1337
  return { ok: false, error: 'codex-tool-limit-reached' };
1314
1338
  }
1315
- session.toolCallCount += 1;
1316
- if (isRetiredAgentMutatingToolName(name)) {
1317
- recordAgentAudit({
1318
- event: 'tool-rejected',
1319
- runId: session.runId,
1320
- deviceIds: [],
1321
- toolName: name,
1322
- category: 'mutation',
1323
- decision: 'deny',
1324
- status: 'rejected',
1325
- permissionMode: session.permissionPolicy?.mode,
1326
- policyHash: session.permissionPolicyHash,
1327
- details: { reason: 'agent-mutating-tool-disabled' }
1328
- });
1329
- return { ok: false, error: 'agent-mutating-tool-disabled' };
1330
- }
1331
- 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);
1332
1356
  if (!tool) return { ok: false, error: 'codex-tool-not-allowed' };
1333
1357
  const validationError = validateAgentMcpArguments(name, args);
1334
1358
  if (validationError) return { ok: false, error: validationError };
@@ -1520,15 +1544,19 @@ async function synchronizeAgentEnablement() {
1520
1544
  }
1521
1545
 
1522
1546
  const hubFilesystem = createHubFilesystem();
1523
- hubTransferJobs = createHubTransferJobs({
1524
- filesystem: hubFilesystem,
1525
- remoteHub,
1526
- maxConcurrent: Number(process.env.LIVEDESK_MAX_FILE_JOBS || 2),
1527
- commandResultTimeoutMs: readPositiveIntegerEnv('LIVEDESK_FILE_CHUNK_ACK_TIMEOUT_MS', 30_000),
1528
- getMaxFileSizeBytes: () => Number(
1529
- liveDeskSettingsStore.getCached()?.filesAudio?.maxFileSizeBytes
1530
- || 1024 * 1024 * 1024)
1531
- });
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
+ });
1532
1560
  const hubSharedFolders = createHubSharedFolders({
1533
1561
  filesystem: hubFilesystem,
1534
1562
  transferJobs: hubTransferJobs,
@@ -1648,74 +1676,152 @@ app.use((req, res, next) => {
1648
1676
  res.setHeader('Access-Control-Allow-Origin', origin);
1649
1677
  res.setHeader('Access-Control-Allow-Private-Network', 'true');
1650
1678
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
1651
- 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');
1652
1680
  res.setHeader('Vary', 'Origin, Access-Control-Request-Headers, Access-Control-Request-Private-Network');
1653
1681
  }
1654
1682
  if (req.method === 'OPTIONS') {
1655
1683
  res.status(204).end();
1656
1684
  return;
1657
1685
  }
1658
- next();
1659
- });
1660
- app.use((req, res, next) => {
1661
- if (!enforceLocalAdminAuth || req.path === '/api/security/session' || req.method === 'OPTIONS') {
1662
- next();
1663
- return;
1664
- }
1665
- const authorizedLauncherShutdown = req.method === 'POST'
1666
- && req.path === '/api/runtime/shutdown'
1667
- && isLoopbackAddress(req.socket?.remoteAddress)
1668
- && launcherShutdownToken
1669
- && secureExactTestTokenMatches(
1670
- String(req.headers['x-livedesk-launcher-shutdown'] || ''),
1671
- launcherShutdownToken
1672
- );
1673
- if (authorizedLauncherShutdown) {
1674
- req.liveDeskLauncherShutdown = true;
1675
- next();
1676
- return;
1677
- }
1678
- const mutating = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method);
1679
- const sensitiveRead = req.method === 'GET'
1680
- && ['/api/security/audit', '/api/privacy/inventory'].includes(req.path);
1681
- if (!mutating && !sensitiveRead) {
1682
- next();
1683
- return;
1684
- }
1685
- const adminToken = String(req.headers['x-livedesk-admin-session'] || '');
1686
- const csrfToken = String(req.headers['x-livedesk-admin-csrf'] || '');
1687
- if (!secureExactTestTokenMatches(adminToken, localAdminSessionToken)) {
1688
- recordSecurityAudit({
1689
- action: 'local-admin.request',
1690
- phase: 'complete',
1691
- result: 'rejected',
1692
- reason: 'admin-session-required',
1693
- requestHash: requestAuditHash({ method: req.method, path: req.path }),
1694
- authMethod: 'local-admin-session'
1695
- });
1696
- res.status(401).json({ ok: false, error: 'local-admin-session-required' });
1697
- return;
1698
- }
1699
- if (mutating && !secureExactTestTokenMatches(csrfToken, localAdminCsrfToken)) {
1700
- recordSecurityAudit({
1701
- action: 'local-admin.request',
1702
- phase: 'complete',
1703
- result: 'rejected',
1704
- reason: 'csrf-token-required',
1705
- requestHash: requestAuditHash({ method: req.method, path: req.path }),
1706
- authMethod: 'local-admin-session'
1707
- });
1708
- res.status(403).json({ ok: false, error: 'csrf-token-required' });
1709
- return;
1710
- }
1711
- if (!securityAuditHealthy) {
1712
- res.status(503).json({ ok: false, error: 'security-audit-unavailable' });
1713
- return;
1714
- }
1715
- req.liveDeskAdminSessionId = localAdminSessionId;
1716
- next();
1717
- });
1718
- 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
+ });
1719
1825
  app.use((req, res, next) => {
1720
1826
  if (runtimeRole === 'client' && req.path.startsWith('/api/remote')) {
1721
1827
  res.status(403).json(roleGuardResponse(runtimeRole, 'hub'));
@@ -1866,12 +1972,12 @@ function normalizeTransferFiles(value) {
1866
1972
  if (totalBytes > MAX_FILE_TRANSFER_BYTES) {
1867
1973
  return { ok: false, error: 'file-transfer-too-large', files: [], totalBytes };
1868
1974
  }
1869
- files.push({
1870
- name,
1871
- relativePath,
1872
- size: byteLength,
1873
- sha256: crypto.createHash('sha256').update(Buffer.from(dataBase64, 'base64')).digest('hex'),
1874
- 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),
1875
1981
  lastModified: Number(entry?.lastModified || 0) || 0,
1876
1982
  dataBase64
1877
1983
  });
@@ -1882,39 +1988,39 @@ function normalizeTransferFiles(value) {
1882
1988
  return { ok: true, files, totalBytes };
1883
1989
  }
1884
1990
 
1885
- function normalizeTransferChunk(body = {}) {
1991
+ function normalizeTransferChunk(body = {}) {
1886
1992
  const dataBase64 = String(body.dataBase64 || '').replace(/^data:[^,]*,/i, '').trim();
1887
1993
  const name = String(body.name || '').replace(/[\r\n\t\0]/g, ' ').trim().slice(0, 240);
1888
1994
  const relativePath = String(body.relativePath || name).replace(/[\r\n\t\0]/g, ' ').trim().slice(0, 600);
1889
1995
  const offset = Math.max(0, Math.floor(Number(body.offset) || 0));
1890
1996
  const totalBytes = Math.max(0, Math.floor(Number(body.totalBytes) || 0));
1891
- const byteLength = dataBase64 ? Buffer.byteLength(dataBase64, 'base64') : 0;
1892
- const final = body.final === true;
1893
- const maxFileSizeBytes = Math.max(1, Number(
1894
- liveDeskSettingsStore.getCached()?.filesAudio?.maxFileSizeBytes
1895
- || 1024 * 1024 * 1024));
1896
-
1897
- if (!name || !relativePath || offset > totalBytes || offset + byteLength > totalBytes) {
1898
- return { ok: false, error: 'invalid-file-transfer-chunk' };
1899
- }
1900
- if (totalBytes > maxFileSizeBytes) {
1901
- return { ok: false, error: 'file-transfer-file-too-large', totalBytes };
1902
- }
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
+ }
1903
2009
  if (byteLength > MAX_FILE_TRANSFER_CHUNK_BYTES) {
1904
2010
  return { ok: false, error: 'file-transfer-chunk-too-large', byteLength };
1905
2011
  }
1906
- if (byteLength === 0 && !(final && totalBytes === 0 && offset === 0)) {
1907
- return { ok: false, error: 'empty-file-transfer-chunk' };
1908
- }
1909
- const suppliedSha256 = String(body.sha256 || '').trim().toLowerCase();
1910
- const sha256 = /^[a-f0-9]{64}$/.test(suppliedSha256)
1911
- ? suppliedSha256
1912
- : final && offset === 0 && totalBytes === byteLength
1913
- ? crypto.createHash('sha256').update(Buffer.from(dataBase64, 'base64')).digest('hex')
1914
- : '';
1915
- if (final && !sha256) {
1916
- return { ok: false, error: 'file-transfer-sha256-required', byteLength };
1917
- }
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
+ }
1918
2024
 
1919
2025
  return {
1920
2026
  ok: true,
@@ -1924,9 +2030,9 @@ function normalizeTransferChunk(body = {}) {
1924
2030
  offset,
1925
2031
  totalBytes,
1926
2032
  byteLength,
1927
- dataBase64,
1928
- final,
1929
- sha256,
2033
+ dataBase64,
2034
+ final,
2035
+ sha256,
1930
2036
  mimeType: String(body.type || body.mimeType || '').slice(0, 160),
1931
2037
  lastModified: Number(body.lastModified || 0) || 0
1932
2038
  }
@@ -3994,26 +4100,26 @@ function buildHubHealthPayload({
3994
4100
  };
3995
4101
  }
3996
4102
 
3997
- app.get('/api/health', (_req, res) => {
3998
- noStore(res);
3999
- res.json(buildHubHealthPayload());
4000
- });
4001
-
4002
- app.get('/api/security/session', (req, res) => {
4003
- noStore(res);
4004
- if (!isLoopbackAddress(req.socket?.remoteAddress) || !isLoopbackHostname(requestHostname(req))) {
4005
- res.status(403).json({ ok: false, error: 'local-admin-session-loopback-only' });
4006
- return;
4007
- }
4008
- res.json({
4009
- ok: true,
4010
- sessionId: localAdminSessionId,
4011
- adminToken: localAdminSessionToken,
4012
- csrfToken: localAdminCsrfToken,
4013
- issuedAt: new Date().toISOString(),
4014
- expiresOnProcessExit: true
4015
- });
4016
- });
4103
+ app.get('/api/health', (_req, res) => {
4104
+ noStore(res);
4105
+ res.json(buildHubHealthPayload());
4106
+ });
4107
+
4108
+ app.get('/api/security/session', (req, res) => {
4109
+ noStore(res);
4110
+ if (!isLoopbackAddress(req.socket?.remoteAddress) || !isLoopbackHostname(requestHostname(req))) {
4111
+ res.status(403).json({ ok: false, error: 'local-admin-session-loopback-only' });
4112
+ return;
4113
+ }
4114
+ res.json({
4115
+ ok: true,
4116
+ sessionId: localAdminSessionId,
4117
+ adminToken: localAdminSessionToken,
4118
+ csrfToken: localAdminCsrfToken,
4119
+ issuedAt: new Date().toISOString(),
4120
+ expiresOnProcessExit: true
4121
+ });
4122
+ });
4017
4123
 
4018
4124
  if (persistentSessionGcEnabled) {
4019
4125
  app.post('/api/test/persistent-session/gc', (req, res) => {
@@ -4066,18 +4172,18 @@ app.patch('/api/settings', async (req, res) => {
4066
4172
  const record = await liveDeskSettingsStore.update(patch, revision);
4067
4173
  if (patch.agent && Object.prototype.hasOwnProperty.call(patch.agent, 'enabled')) {
4068
4174
  await agentSettingsStore.update({ enabled: record.settings.agent?.enabled === true });
4069
- }
4070
- remoteHub.refreshDevicePolicies();
4071
- void captureStore.enforceRetention().catch(() => undefined);
4072
- recordSecurityAudit({
4073
- action: 'settings.security-policy.updated',
4074
- phase: 'complete',
4075
- result: 'success',
4076
- requestHash: requestAuditHash(patch),
4077
- authMethod: 'local-admin-session',
4078
- details: { revision: record.revision, sections: Object.keys(patch) }
4079
- });
4080
- res.json({ ok: true, revision: record.revision, updatedAt: record.updatedAt, settings: record.settings });
4175
+ }
4176
+ remoteHub.refreshDevicePolicies();
4177
+ void captureStore.enforceRetention().catch(() => undefined);
4178
+ recordSecurityAudit({
4179
+ action: 'settings.security-policy.updated',
4180
+ phase: 'complete',
4181
+ result: 'success',
4182
+ requestHash: requestAuditHash(patch),
4183
+ authMethod: 'local-admin-session',
4184
+ details: { revision: record.revision, sections: Object.keys(patch) }
4185
+ });
4186
+ res.json({ ok: true, revision: record.revision, updatedAt: record.updatedAt, settings: record.settings });
4081
4187
  } catch (error) {
4082
4188
  if (error instanceof SettingsConflictError) {
4083
4189
  res.status(409).json({ ok: false, error: error.code, revision: error.settings.revision, updatedAt: error.settings.updatedAt, settings: error.settings.settings });
@@ -4232,7 +4338,7 @@ app.get('/api/settings/capabilities', async (_req, res) => {
4232
4338
  });
4233
4339
  });
4234
4340
 
4235
- app.get('/api/security/trusted-devices', (_req, res) => {
4341
+ app.get('/api/security/trusted-devices', (_req, res) => {
4236
4342
  noStore(res);
4237
4343
  const devices = remoteHub.listDevices({ includeDataUrl: false }).map(device => ({
4238
4344
  deviceId: device.deviceId,
@@ -4244,128 +4350,141 @@ app.get('/api/security/trusted-devices', (_req, res) => {
4244
4350
  lastSeenAt: device.lastSeenAt || '',
4245
4351
  ipRange: String(device.remoteAddress || '').replace(/^::ffff:/, '').split(':')[0]
4246
4352
  }));
4247
- res.json({ ok: true, devices });
4248
- });
4249
-
4250
- app.get('/api/security/audit', async (req, res) => {
4251
- noStore(res);
4252
- try {
4253
- res.json({
4254
- ok: true,
4255
- healthy: securityAuditHealthy,
4256
- verification: await securityAuditStore.verify(),
4257
- audit: await securityAuditStore.list({ limit: req.query?.limit, action: req.query?.action })
4258
- });
4259
- } catch (error) {
4260
- securityAuditHealthy = false;
4261
- res.status(503).json({ ok: false, error: error?.code || 'security-audit-unavailable' });
4262
- }
4263
- });
4264
-
4265
- app.get('/api/privacy/inventory', async (_req, res) => {
4266
- noStore(res);
4267
- try {
4268
- const [captures, sharedFolders, verification] = await Promise.all([
4269
- captureStore.list(),
4270
- hubSharedFolders.list(),
4271
- securityAuditStore.verify()
4272
- ]);
4273
- res.json({
4274
- ok: true,
4275
- inventory: {
4276
- authenticated: runtimeManager.getSnapshot().authenticated === true,
4277
- captures: captures.length,
4278
- sharedFolderDefinitions: sharedFolders.length,
4279
- trustedDeviceCredentials: remoteHub.getSecurityStatus().devices.length,
4280
- securityAuditRecords: verification.records,
4281
- agentAuditRetained: (await agentAuditStore.list({ limit: 500 })).length
4282
- }
4283
- });
4284
- } catch (error) {
4285
- res.status(503).json({ ok: false, error: String(error?.code || error?.message || 'privacy-inventory-unavailable').slice(0, 120) });
4286
- }
4287
- });
4288
-
4289
- app.delete('/api/privacy/local-data', async (req, res) => {
4290
- noStore(res);
4291
- if (String(req.body?.confirmation || '') !== 'DELETE-LIVEDESK-LOCAL-DATA') {
4292
- res.status(400).json({ ok: false, error: 'privacy-delete-confirmation-required' });
4293
- return;
4294
- }
4295
- try {
4296
- const providerLogout = await revokeRuntimeProviderSession();
4297
- const hostTarget = runtimeRole === 'hub'
4298
- ? await clearHubHostTarget('privacy-delete')
4299
- : { ok: true, active: false };
4300
- clearRuntimeSession();
4301
- for (const device of remoteHub.getSecurityStatus().devices) {
4302
- remoteHub.disconnectDevice(device.deviceId, 'privacy-local-data-delete');
4303
- }
4304
- const trustedDeviceCredentials = remoteHub.clearDeviceCredentials();
4305
- const transferJobs = hubTransferJobs.clear();
4306
- const captures = await captureStore.removeAll();
4307
- const sharedFolders = await hubSharedFolders.clear();
4308
- await agentAuditStore.clear();
4309
- await securityAuditStore.reset();
4310
- securityAuditHealthy = true;
4311
- await securityAuditStore.record({
4312
- action: 'privacy.local-data.deleted',
4313
- phase: 'complete',
4314
- result: 'success',
4315
- authMethod: 'local-admin-session',
4316
- details: {
4317
- capturesRemoved: captures.removed,
4318
- captureBytesRemoved: captures.removedBytes,
4319
- sharedFolderDefinitionsRemoved: sharedFolders.removed,
4320
- trustedDeviceCredentialsRemoved: trustedDeviceCredentials,
4321
- transferJobRecordsRemoved: transferJobs.removed,
4322
- providerSessionRevoked: providerLogout.revoked === true
4323
- }
4324
- });
4325
- res.json({
4326
- ok: true,
4327
- localDeleted: true,
4328
- providerLogout,
4329
- hostTarget,
4330
- captures,
4331
- sharedFolders,
4332
- trustedDeviceCredentials,
4333
- transferJobs,
4334
- preserved: ['settings-preferences', 'hub-device-identity', 'application-binaries']
4335
- });
4336
- } catch (error) {
4337
- securityAuditHealthy = false;
4338
- res.status(503).json({ ok: false, error: String(error?.code || error?.message || 'privacy-delete-failed').slice(0, 120) });
4339
- }
4340
- });
4341
-
4342
- app.delete('/api/security/trusted-devices/:deviceId', (req, res) => {
4343
- noStore(res);
4344
- const disconnected = remoteHub.disconnectDevice(req.params.deviceId, 'trusted-device-revoked');
4345
- const credentialRevoked = remoteHub.revokeDeviceCredential(req.params.deviceId, 'trusted-device-revoked');
4346
- const revoked = disconnected === true || credentialRevoked === true;
4347
- recordSecurityAudit({
4348
- action: 'security.device.revoked',
4349
- phase: 'complete',
4350
- result: revoked ? 'success' : 'not-found',
4351
- deviceId: req.params.deviceId,
4352
- authMethod: 'local-admin-session'
4353
- });
4354
- res.json({ ok: revoked, revoked, deviceId: req.params.deviceId });
4355
- });
4353
+ res.json({ ok: true, devices });
4354
+ });
4355
+
4356
+ app.get('/api/security/rendezvous-issuer', (_req, res) => {
4357
+ noStore(res);
4358
+ const security = remoteHub.getSecurityStatus();
4359
+ res.json({
4360
+ ok: true,
4361
+ hubId: security.hubId,
4362
+ issuerKeyId: security.hubIssuerKeyId,
4363
+ publicKey: security.hubPublicKey,
4364
+ algorithm: 'P-256/SHA-256',
4365
+ environmentVariable: 'LIVEDESK_UDP_RENDEZVOUS_TRUSTED_ISSUER_PUBLIC_KEYS'
4366
+ });
4367
+ });
4368
+
4369
+ app.get('/api/security/audit', async (req, res) => {
4370
+ noStore(res);
4371
+ try {
4372
+ res.json({
4373
+ ok: true,
4374
+ healthy: securityAuditHealthy,
4375
+ verification: await securityAuditStore.verify(),
4376
+ audit: await securityAuditStore.list({ limit: req.query?.limit, action: req.query?.action })
4377
+ });
4378
+ } catch (error) {
4379
+ securityAuditHealthy = false;
4380
+ res.status(503).json({ ok: false, error: error?.code || 'security-audit-unavailable' });
4381
+ }
4382
+ });
4383
+
4384
+ app.get('/api/privacy/inventory', async (_req, res) => {
4385
+ noStore(res);
4386
+ try {
4387
+ const [captures, sharedFolders, verification] = await Promise.all([
4388
+ captureStore.list(),
4389
+ hubSharedFolders.list(),
4390
+ securityAuditStore.verify()
4391
+ ]);
4392
+ res.json({
4393
+ ok: true,
4394
+ inventory: {
4395
+ authenticated: runtimeManager.getSnapshot().authenticated === true,
4396
+ captures: captures.length,
4397
+ sharedFolderDefinitions: sharedFolders.length,
4398
+ trustedDeviceCredentials: remoteHub.getSecurityStatus().devices.length,
4399
+ securityAuditRecords: verification.records,
4400
+ agentAuditRetained: (await agentAuditStore.list({ limit: 500 })).length
4401
+ }
4402
+ });
4403
+ } catch (error) {
4404
+ res.status(503).json({ ok: false, error: String(error?.code || error?.message || 'privacy-inventory-unavailable').slice(0, 120) });
4405
+ }
4406
+ });
4407
+
4408
+ app.delete('/api/privacy/local-data', async (req, res) => {
4409
+ noStore(res);
4410
+ if (String(req.body?.confirmation || '') !== 'DELETE-LIVEDESK-LOCAL-DATA') {
4411
+ res.status(400).json({ ok: false, error: 'privacy-delete-confirmation-required' });
4412
+ return;
4413
+ }
4414
+ try {
4415
+ const providerLogout = await revokeRuntimeProviderSession();
4416
+ const hostTarget = runtimeRole === 'hub'
4417
+ ? await clearHubHostTarget('privacy-delete')
4418
+ : { ok: true, active: false };
4419
+ clearRuntimeSession();
4420
+ for (const device of remoteHub.getSecurityStatus().devices) {
4421
+ remoteHub.disconnectDevice(device.deviceId, 'privacy-local-data-delete');
4422
+ }
4423
+ const trustedDeviceCredentials = remoteHub.clearDeviceCredentials();
4424
+ const transferJobs = hubTransferJobs.clear();
4425
+ const captures = await captureStore.removeAll();
4426
+ const sharedFolders = await hubSharedFolders.clear();
4427
+ await agentAuditStore.clear();
4428
+ await securityAuditStore.reset();
4429
+ securityAuditHealthy = true;
4430
+ await securityAuditStore.record({
4431
+ action: 'privacy.local-data.deleted',
4432
+ phase: 'complete',
4433
+ result: 'success',
4434
+ authMethod: 'local-admin-session',
4435
+ details: {
4436
+ capturesRemoved: captures.removed,
4437
+ captureBytesRemoved: captures.removedBytes,
4438
+ sharedFolderDefinitionsRemoved: sharedFolders.removed,
4439
+ trustedDeviceCredentialsRemoved: trustedDeviceCredentials,
4440
+ transferJobRecordsRemoved: transferJobs.removed,
4441
+ providerSessionRevoked: providerLogout.revoked === true
4442
+ }
4443
+ });
4444
+ res.json({
4445
+ ok: true,
4446
+ localDeleted: true,
4447
+ providerLogout,
4448
+ hostTarget,
4449
+ captures,
4450
+ sharedFolders,
4451
+ trustedDeviceCredentials,
4452
+ transferJobs,
4453
+ preserved: ['settings-preferences', 'hub-device-identity', 'application-binaries']
4454
+ });
4455
+ } catch (error) {
4456
+ securityAuditHealthy = false;
4457
+ res.status(503).json({ ok: false, error: String(error?.code || error?.message || 'privacy-delete-failed').slice(0, 120) });
4458
+ }
4459
+ });
4460
+
4461
+ app.delete('/api/security/trusted-devices/:deviceId', (req, res) => {
4462
+ noStore(res);
4463
+ const disconnected = remoteHub.disconnectDevice(req.params.deviceId, 'trusted-device-revoked');
4464
+ const credentialRevoked = remoteHub.revokeDeviceCredential(req.params.deviceId, 'trusted-device-revoked');
4465
+ const revoked = disconnected === true || credentialRevoked === true;
4466
+ recordSecurityAudit({
4467
+ action: 'security.device.revoked',
4468
+ phase: 'complete',
4469
+ result: revoked ? 'success' : 'not-found',
4470
+ deviceId: req.params.deviceId,
4471
+ authMethod: 'local-admin-session'
4472
+ });
4473
+ res.json({ ok: revoked, revoked, deviceId: req.params.deviceId });
4474
+ });
4356
4475
 
4357
4476
  app.post('/api/security/revoke-all', (_req, res) => {
4358
4477
  noStore(res);
4359
- const devices = remoteHub.listDevices({ includeDataUrl: false });
4360
- let revoked = 0;
4361
- for (const device of devices) {
4362
- const disconnected = device.connected && remoteHub.disconnectDevice(device.deviceId, 'trusted-devices-revoked');
4363
- const credentialRevoked = remoteHub.revokeDeviceCredential(device.deviceId, 'trusted-devices-revoked');
4364
- if (disconnected || credentialRevoked) revoked += 1;
4365
- }
4366
- recordSecurityAudit({ action: 'security.devices.revoke-all', phase: 'complete', result: 'success', authMethod: 'local-admin-session', details: { revoked } });
4367
- res.json({ ok: true, revoked });
4368
- });
4478
+ const devices = remoteHub.listDevices({ includeDataUrl: false });
4479
+ let revoked = 0;
4480
+ for (const device of devices) {
4481
+ const disconnected = device.connected && remoteHub.disconnectDevice(device.deviceId, 'trusted-devices-revoked');
4482
+ const credentialRevoked = remoteHub.revokeDeviceCredential(device.deviceId, 'trusted-devices-revoked');
4483
+ if (disconnected || credentialRevoked) revoked += 1;
4484
+ }
4485
+ recordSecurityAudit({ action: 'security.devices.revoke-all', phase: 'complete', result: 'success', authMethod: 'local-admin-session', details: { revoked } });
4486
+ res.json({ ok: true, revoked });
4487
+ });
4369
4488
 
4370
4489
  app.get('/api/settings/agent', async (_req, res) => {
4371
4490
  noStore(res);
@@ -4727,17 +4846,17 @@ app.post('/api/runtime/restart', (_req, res) => {
4727
4846
  }, 150);
4728
4847
  });
4729
4848
 
4730
- app.post('/api/runtime/shutdown', (req, res) => {
4731
- noStore(res);
4732
- recordSecurityAudit({
4733
- action: 'runtime.shutdown',
4734
- phase: 'requested',
4735
- result: 'accepted',
4736
- authMethod: req.liveDeskLauncherShutdown === true
4737
- ? 'launcher-internal-shutdown-token'
4738
- : 'local-admin-session'
4739
- });
4740
- runtimeManager.emit('runtime.shutdown.requested', { role: runtimeRole });
4849
+ app.post('/api/runtime/shutdown', (req, res) => {
4850
+ noStore(res);
4851
+ recordSecurityAudit({
4852
+ action: 'runtime.shutdown',
4853
+ phase: 'requested',
4854
+ result: 'accepted',
4855
+ authMethod: req.liveDeskLauncherShutdown === true
4856
+ ? 'launcher-internal-shutdown-token'
4857
+ : 'local-admin-session'
4858
+ });
4859
+ runtimeManager.emit('runtime.shutdown.requested', { role: runtimeRole });
4741
4860
  res.json({ ok: true, shuttingDown: true, role: runtimeRole });
4742
4861
  setTimeout(() => {
4743
4862
  void shutdownHub('API_SHUTDOWN')
@@ -4774,10 +4893,10 @@ app.post('/api/auth/session', async (req, res) => {
4774
4893
  }
4775
4894
  const { access_token: accessToken, refresh_token: refreshToken, expires_at: expiresAt } = normalized.session;
4776
4895
  try {
4777
- const user = await verifySupabaseUser(accessToken);
4778
- runtimeSessionGeneration += 1;
4779
- runtimeRefreshPromise = null;
4780
- runtimeAccessToken = accessToken;
4896
+ const user = await verifySupabaseUser(accessToken);
4897
+ runtimeSessionGeneration += 1;
4898
+ runtimeRefreshPromise = null;
4899
+ runtimeAccessToken = accessToken;
4781
4900
  runtimeRefreshToken = refreshToken;
4782
4901
  runtimeAccessTokenExpiresAt = normalizeSessionExpiryMs(expiresAt);
4783
4902
  runtimeManager.setAuthenticated(true, user.id);
@@ -4790,29 +4909,29 @@ app.post('/api/auth/session', async (req, res) => {
4790
4909
  const hostTarget = runtimeRole === 'hub'
4791
4910
  ? await publishHubHostTargetWithPendingRoleTakeover('session-received')
4792
4911
  : { ok: true, active: false };
4793
- if (runtimeRole === 'hub') {
4794
- startHubHostTargetLeaseRenewal();
4795
- }
4796
- recordSecurityAudit({
4797
- action: 'auth.session.established',
4798
- phase: 'complete',
4799
- result: 'success',
4800
- actorAccountId: user.id,
4801
- actorUserId: user.id,
4802
- authMethod: 'supabase-access-refresh',
4803
- requestHash: requestAuditHash({ userId: user.id, expiresAt })
4804
- });
4805
- res.json({ ok: true, authenticated: true, persisted, userId: user.id, role: runtimeRole, hostTarget });
4806
- } catch (error) {
4807
- const message = error instanceof Error ? error.message : String(error);
4808
- recordSecurityAudit({
4809
- action: 'auth.session.establish',
4810
- phase: 'complete',
4811
- result: 'rejected',
4812
- reason: message,
4813
- authMethod: 'supabase-access-refresh',
4814
- requestHash: requestAuditHash({ hasAccessToken: Boolean(req.body?.accessToken || req.body?.access_token), hasRefreshToken: Boolean(req.body?.refreshToken || req.body?.refresh_token) })
4815
- });
4912
+ if (runtimeRole === 'hub') {
4913
+ startHubHostTargetLeaseRenewal();
4914
+ }
4915
+ recordSecurityAudit({
4916
+ action: 'auth.session.established',
4917
+ phase: 'complete',
4918
+ result: 'success',
4919
+ actorAccountId: user.id,
4920
+ actorUserId: user.id,
4921
+ authMethod: 'supabase-access-refresh',
4922
+ requestHash: requestAuditHash({ userId: user.id, expiresAt })
4923
+ });
4924
+ res.json({ ok: true, authenticated: true, persisted, userId: user.id, role: runtimeRole, hostTarget });
4925
+ } catch (error) {
4926
+ const message = error instanceof Error ? error.message : String(error);
4927
+ recordSecurityAudit({
4928
+ action: 'auth.session.establish',
4929
+ phase: 'complete',
4930
+ result: 'rejected',
4931
+ reason: message,
4932
+ authMethod: 'supabase-access-refresh',
4933
+ requestHash: requestAuditHash({ hasAccessToken: Boolean(req.body?.accessToken || req.body?.access_token), hasRefreshToken: Boolean(req.body?.refreshToken || req.body?.refresh_token) })
4934
+ });
4816
4935
  const status = authVerificationHttpStatus(message);
4817
4936
  res.status(status).json({ ok: false, error: message });
4818
4937
  }
@@ -5106,33 +5225,33 @@ function startHubHostTargetLeaseRenewal() {
5106
5225
  hubHostTargetRenewTimer.unref?.();
5107
5226
  }
5108
5227
 
5109
- app.delete('/api/auth/session', async (_req, res) => {
5110
- noStore(res);
5111
- const actorUserId = runtimeManager.getSnapshot().userId || '';
5112
- const hostTarget = runtimeRole === 'hub'
5113
- ? await clearHubHostTarget('logout')
5114
- : { ok: true, active: false };
5115
- const providerLogout = await revokeRuntimeProviderSession();
5116
- clearRuntimeSession();
5117
- recordSecurityAudit({
5118
- action: 'auth.session.revoked',
5119
- phase: 'complete',
5120
- result: providerLogout.ok ? 'success' : 'provider-error',
5121
- actorAccountId: actorUserId,
5122
- actorUserId,
5123
- reason: providerLogout.error || '',
5124
- authMethod: 'supabase-local-signout',
5125
- details: { localCleared: true, providerRevoked: providerLogout.revoked === true, alreadyInvalid: providerLogout.alreadyInvalid === true }
5126
- });
5127
- res.status(providerLogout.ok ? 200 : 502).json({
5128
- ok: providerLogout.ok,
5129
- authenticated: false,
5130
- localCleared: true,
5131
- role: runtimeRole,
5132
- hostTarget,
5133
- providerLogout
5134
- });
5135
- });
5228
+ app.delete('/api/auth/session', async (_req, res) => {
5229
+ noStore(res);
5230
+ const actorUserId = runtimeManager.getSnapshot().userId || '';
5231
+ const hostTarget = runtimeRole === 'hub'
5232
+ ? await clearHubHostTarget('logout')
5233
+ : { ok: true, active: false };
5234
+ const providerLogout = await revokeRuntimeProviderSession();
5235
+ clearRuntimeSession();
5236
+ recordSecurityAudit({
5237
+ action: 'auth.session.revoked',
5238
+ phase: 'complete',
5239
+ result: providerLogout.ok ? 'success' : 'provider-error',
5240
+ actorAccountId: actorUserId,
5241
+ actorUserId,
5242
+ reason: providerLogout.error || '',
5243
+ authMethod: 'supabase-local-signout',
5244
+ details: { localCleared: true, providerRevoked: providerLogout.revoked === true, alreadyInvalid: providerLogout.alreadyInvalid === true }
5245
+ });
5246
+ res.status(providerLogout.ok ? 200 : 502).json({
5247
+ ok: providerLogout.ok,
5248
+ authenticated: false,
5249
+ localCleared: true,
5250
+ role: runtimeRole,
5251
+ hostTarget,
5252
+ providerLogout
5253
+ });
5254
+ });
5136
5255
 
5137
5256
  app.get('/api/hub/status', (_req, res) => {
5138
5257
  noStore(res);
@@ -5482,7 +5601,33 @@ app.post('/api/remote/files/from-hub', requireHubFeatureAccess, (req, res) => {
5482
5601
  const job = hubTransferJobs.create({
5483
5602
  itemIds,
5484
5603
  deviceIds,
5485
- remoteDirectory: req.body?.remoteDirectory
5604
+ remoteDirectory: req.body?.remoteDirectory,
5605
+ onComplete: async ({ completed, job: completedJob }) => {
5606
+ await recordSecurityAuditRequired({
5607
+ action: 'remote.file.job',
5608
+ phase: 'complete',
5609
+ result: completed ? 'success' : 'failed',
5610
+ reason: completed ? '' : completedJob?.error || 'file-transfer-failed',
5611
+ deviceIds,
5612
+ sessionId: completedJob?.jobId || '',
5613
+ requestHash: requestAuditHash({
5614
+ jobId: completedJob?.jobId || '',
5615
+ deviceIds,
5616
+ remoteDirectory: req.body?.remoteDirectory || ''
5617
+ }),
5618
+ authMethod: 'local-admin-session',
5619
+ details: {
5620
+ state: completedJob?.state || '',
5621
+ totalFiles: Number(completedJob?.totalFiles || 0),
5622
+ completedFiles: Number(completedJob?.completedFiles || 0),
5623
+ totalBytes: Number(completedJob?.totalBytes || 0),
5624
+ sentBytes: Number(completedJob?.sentBytes || 0),
5625
+ failedTargets: Array.isArray(completedJob?.failedTargets)
5626
+ ? completedJob.failedTargets.length
5627
+ : 0
5628
+ }
5629
+ });
5630
+ }
5486
5631
  });
5487
5632
  res.status(202).json({ ok: true, jobId: job.jobId, state: job.state });
5488
5633
  } catch (error) {
@@ -5564,71 +5709,80 @@ app.post('/api/remote/filesystem/shared-folders/:folderId/sync', requireHubFeatu
5564
5709
  }
5565
5710
  });
5566
5711
 
5567
- app.post('/api/remote/files/transfer', requireHubFeatureAccess, (req, res) => {
5712
+ app.post('/api/remote/files/transfer', requireHubFeatureAccess, async (req, res) => {
5568
5713
  noStore(res);
5569
5714
  const deviceIds = normalizeDeviceIds(req.body?.deviceIds);
5570
- if (deviceIds.length === 0) {
5571
- recordSecurityAudit({ action: 'remote.file.transfer', phase: 'complete', result: 'rejected', reason: 'no-target-devices', authMethod: 'local-admin-session' });
5572
- res.status(400).json({ ok: false, error: 'no-target-devices' });
5715
+ if (deviceIds.length === 0) {
5716
+ recordSecurityAudit({ action: 'remote.file.transfer', phase: 'complete', result: 'rejected', reason: 'no-target-devices', authMethod: 'local-admin-session' });
5717
+ res.status(400).json({ ok: false, error: 'no-target-devices' });
5573
5718
  return;
5574
5719
  }
5575
5720
  const normalized = normalizeTransferFiles(req.body?.files);
5576
- if (!normalized.ok) {
5577
- recordSecurityAudit({ action: 'remote.file.transfer', phase: 'complete', result: 'rejected', reason: normalized.error, deviceIds, authMethod: 'local-admin-session' });
5721
+ if (!normalized.ok) {
5722
+ recordSecurityAudit({ action: 'remote.file.transfer', phase: 'complete', result: 'rejected', reason: normalized.error, deviceIds, authMethod: 'local-admin-session' });
5578
5723
  res.status(400).json({ ok: false, error: normalized.error, totalBytes: normalized.totalBytes || 0 });
5579
5724
  return;
5580
5725
  }
5581
5726
  const transferId = String(req.body?.transferId || crypto.randomUUID()).replace(/[^a-zA-Z0-9_.:-]/g, '-').slice(0, 128);
5582
5727
  const remoteDirectory = String(req.body?.remoteDirectory || '').replace(/[\0\r\n\t]/g, ' ').trim().slice(0, 600);
5583
5728
  const queuedAt = new Date().toISOString();
5584
- const results = deviceIds.map(deviceId => ({
5585
- deviceId,
5586
- ...remoteHub.sendCommand(deviceId, {
5587
- command: 'file.transfer',
5588
- payload: {
5589
- transferId,
5590
- remoteDirectory,
5591
- files: normalized.files,
5592
- totalBytes: normalized.totalBytes,
5593
- requestedAt: queuedAt
5594
- }
5595
- })
5596
- }));
5597
- const queued = results.filter(result => result.ok).length;
5598
- recordSecurityAudit({
5599
- action: 'remote.file.transfer',
5600
- phase: 'start',
5601
- result: queued > 0 ? 'queued' : 'rejected',
5602
- reason: queued > 0 ? '' : 'no-transfer-queued',
5603
- deviceIds,
5604
- sessionId: transferId,
5605
- requestHash: requestAuditHash({ transferId, remoteDirectory, files: normalized.files.map(file => ({ name: file.name, relativePath: file.relativePath, byteLength: file.size })), totalBytes: normalized.totalBytes }),
5606
- authMethod: 'local-admin-session',
5607
- details: { queued, total: deviceIds.length, totalBytes: normalized.totalBytes, files: normalized.files.length }
5608
- });
5609
- res.json({
5610
- ok: queued > 0,
5611
- transferId,
5612
- queued,
5613
- total: deviceIds.length,
5614
- totalBytes: normalized.totalBytes,
5615
- files: normalized.files.length,
5616
- results,
5617
- error: queued > 0 ? undefined : 'no-transfer-queued'
5618
- });
5729
+ try {
5730
+ const results = await Promise.all(deviceIds.map(async deviceId => ({
5731
+ deviceId,
5732
+ ...await remoteHub.sendCommandAwaitResult(deviceId, {
5733
+ command: 'file.transfer',
5734
+ payload: {
5735
+ transferId,
5736
+ remoteDirectory,
5737
+ files: normalized.files,
5738
+ totalBytes: normalized.totalBytes,
5739
+ requestedAt: queuedAt
5740
+ }
5741
+ }, { timeoutMs: fileTransferCommandAckTimeoutMs })
5742
+ })));
5743
+ const queued = results.filter(result => result.queued).length;
5744
+ const acknowledged = results.filter(result => result.acknowledged).length;
5745
+ const success = acknowledged === deviceIds.length;
5746
+ const partial = acknowledged > 0 && !success;
5747
+ const error = success ? undefined : partial ? 'partial-transfer-failed' : 'no-transfer-acknowledged';
5748
+ await recordSecurityAuditRequired({
5749
+ action: 'remote.file.transfer',
5750
+ phase: 'complete',
5751
+ result: success ? 'success' : partial ? 'partial' : 'rejected',
5752
+ reason: error || '',
5753
+ deviceIds,
5754
+ sessionId: transferId,
5755
+ requestHash: requestAuditHash({ transferId, remoteDirectory, files: normalized.files.map(file => ({ name: file.name, relativePath: file.relativePath, byteLength: file.size })), totalBytes: normalized.totalBytes }),
5756
+ authMethod: 'local-admin-session',
5757
+ details: { queued, acknowledged, total: deviceIds.length, totalBytes: normalized.totalBytes, files: normalized.files.length }
5758
+ });
5759
+ res.json({
5760
+ ok: success,
5761
+ transferId,
5762
+ queued,
5763
+ acknowledged,
5764
+ total: deviceIds.length,
5765
+ totalBytes: normalized.totalBytes,
5766
+ files: normalized.files.length,
5767
+ results,
5768
+ error
5769
+ });
5770
+ } catch (error) {
5771
+ res.status(503).json({ ok: false, error: error?.code || 'security-audit-unavailable' });
5772
+ }
5619
5773
  });
5620
5774
 
5621
- app.post('/api/remote/files/chunk', requireHubFeatureAccess, (req, res) => {
5775
+ app.post('/api/remote/files/chunk', requireHubFeatureAccess, async (req, res) => {
5622
5776
  noStore(res);
5623
5777
  const deviceIds = normalizeDeviceIds(req.body?.deviceIds);
5624
- if (deviceIds.length === 0) {
5625
- recordSecurityAudit({ action: 'remote.file.chunk', phase: 'complete', result: 'rejected', reason: 'no-target-devices', authMethod: 'local-admin-session' });
5778
+ if (deviceIds.length === 0) {
5779
+ recordSecurityAudit({ action: 'remote.file.chunk', phase: 'complete', result: 'rejected', reason: 'no-target-devices', authMethod: 'local-admin-session' });
5626
5780
  res.status(400).json({ ok: false, error: 'no-target-devices' });
5627
5781
  return;
5628
5782
  }
5629
5783
  const normalized = normalizeTransferChunk(req.body || {});
5630
- if (!normalized.ok) {
5631
- recordSecurityAudit({ action: 'remote.file.chunk', phase: 'complete', result: 'rejected', reason: normalized.error, deviceIds, authMethod: 'local-admin-session' });
5784
+ if (!normalized.ok) {
5785
+ recordSecurityAudit({ action: 'remote.file.chunk', phase: 'complete', result: 'rejected', reason: normalized.error, deviceIds, authMethod: 'local-admin-session' });
5632
5786
  res.status(400).json({ ok: false, error: normalized.error, byteLength: normalized.byteLength || 0 });
5633
5787
  return;
5634
5788
  }
@@ -5636,41 +5790,50 @@ app.post('/api/remote/files/chunk', requireHubFeatureAccess, (req, res) => {
5636
5790
  const transferId = String(req.body?.transferId || crypto.randomUUID()).replace(/[^a-zA-Z0-9_.:-]/g, '-').slice(0, 128);
5637
5791
  const remoteDirectory = String(req.body?.remoteDirectory || '').replace(/[\0\r\n\t]/g, ' ').trim().slice(0, 600);
5638
5792
  const queuedAt = new Date().toISOString();
5639
- const results = deviceIds.map(deviceId => ({
5640
- deviceId,
5641
- ...remoteHub.sendCommand(deviceId, {
5642
- command: 'file.transfer.chunk',
5643
- payload: {
5644
- transferId,
5645
- remoteDirectory,
5646
- ...normalized.chunk,
5647
- requestedAt: queuedAt
5648
- }
5649
- })
5650
- }));
5651
- const queued = results.filter(result => result.ok).length;
5652
- recordSecurityAudit({
5653
- action: 'remote.file.chunk',
5654
- phase: normalized.chunk.final ? 'complete' : 'progress',
5655
- result: queued > 0 ? 'queued' : 'rejected',
5656
- reason: queued > 0 ? '' : 'no-transfer-queued',
5657
- deviceIds,
5658
- sessionId: transferId,
5659
- requestHash: requestAuditHash({ transferId, remoteDirectory, offset: normalized.chunk.offset, byteLength: normalized.chunk.byteLength, final: normalized.chunk.final }),
5660
- authMethod: 'local-admin-session',
5661
- details: { queued, total: deviceIds.length, byteLength: normalized.chunk.byteLength, offset: normalized.chunk.offset, final: normalized.chunk.final }
5662
- });
5663
- res.json({
5664
- ok: queued > 0,
5665
- transferId,
5666
- queued,
5667
- total: deviceIds.length,
5668
- byteLength: normalized.chunk.byteLength,
5669
- offset: normalized.chunk.offset,
5670
- final: normalized.chunk.final,
5671
- results,
5672
- error: queued > 0 ? undefined : 'no-transfer-queued'
5673
- });
5793
+ try {
5794
+ const results = await Promise.all(deviceIds.map(async deviceId => ({
5795
+ deviceId,
5796
+ ...await remoteHub.sendCommandAwaitResult(deviceId, {
5797
+ command: 'file.transfer.chunk',
5798
+ payload: {
5799
+ transferId,
5800
+ remoteDirectory,
5801
+ ...normalized.chunk,
5802
+ requestedAt: queuedAt
5803
+ }
5804
+ }, { timeoutMs: fileTransferCommandAckTimeoutMs })
5805
+ })));
5806
+ const queued = results.filter(result => result.queued).length;
5807
+ const acknowledged = results.filter(result => result.acknowledged).length;
5808
+ const success = acknowledged === deviceIds.length;
5809
+ const partial = acknowledged > 0 && !success;
5810
+ const error = success ? undefined : partial ? 'partial-transfer-failed' : 'no-transfer-acknowledged';
5811
+ await recordSecurityAuditRequired({
5812
+ action: 'remote.file.chunk',
5813
+ phase: normalized.chunk.final ? 'complete' : 'progress',
5814
+ result: success ? 'success' : partial ? 'partial' : 'rejected',
5815
+ reason: error || '',
5816
+ deviceIds,
5817
+ sessionId: transferId,
5818
+ requestHash: requestAuditHash({ transferId, remoteDirectory, offset: normalized.chunk.offset, byteLength: normalized.chunk.byteLength, final: normalized.chunk.final }),
5819
+ authMethod: 'local-admin-session',
5820
+ details: { queued, acknowledged, total: deviceIds.length, byteLength: normalized.chunk.byteLength, offset: normalized.chunk.offset, final: normalized.chunk.final }
5821
+ });
5822
+ res.json({
5823
+ ok: success,
5824
+ transferId,
5825
+ queued,
5826
+ acknowledged,
5827
+ total: deviceIds.length,
5828
+ byteLength: normalized.chunk.byteLength,
5829
+ offset: normalized.chunk.offset,
5830
+ final: normalized.chunk.final,
5831
+ results,
5832
+ error
5833
+ });
5834
+ } catch (error) {
5835
+ res.status(503).json({ ok: false, error: error?.code || 'security-audit-unavailable' });
5836
+ }
5674
5837
  });
5675
5838
 
5676
5839
  const MANUAL_POWER_ACTIONS = new Set(['lock', 'sleep', 'restart', 'shutdown']);
@@ -5944,7 +6107,7 @@ app.post('/api/remote/devices/:deviceId/audio/stop', async (req, res, next) => {
5944
6107
  }
5945
6108
  });
5946
6109
 
5947
- if (existsSync(webIndexPath)) {
6110
+ if (existsSync(webIndexPath)) {
5948
6111
  app.use(express.static(webDistPath, {
5949
6112
  etag: true,
5950
6113
  index: false,
@@ -5957,41 +6120,67 @@ if (existsSync(webIndexPath)) {
5957
6120
  }
5958
6121
  res.sendFile(webIndexPath);
5959
6122
  });
5960
- }
5961
-
5962
- function isAuthorizedAdminWebSocket(req) {
5963
- if (!enforceLocalAdminAuth) return true;
5964
- const protocols = String(req.headers['sec-websocket-protocol'] || '')
5965
- .split(',')
5966
- .map(value => value.trim())
5967
- .filter(Boolean);
5968
- const adminToken = protocols.find(value => value.startsWith('livedesk-admin-'))?.slice('livedesk-admin-'.length) || '';
5969
- const csrfToken = protocols.find(value => value.startsWith('livedesk-admin-csrf-'))?.slice('livedesk-admin-csrf-'.length) || '';
5970
- return secureExactTestTokenMatches(adminToken, localAdminSessionToken)
5971
- && secureExactTestTokenMatches(csrfToken, localAdminCsrfToken);
5972
- }
5973
-
5974
- httpServer.on('upgrade', (req, socket, head) => {
5975
- if (!isTrustedBrowserRequest(req)) {
6123
+ }
6124
+
6125
+ function isAuthorizedAdminWebSocket(req) {
6126
+ if (!enforceLocalAdminAuth) return true;
6127
+ const protocols = String(req.headers['sec-websocket-protocol'] || '')
6128
+ .split(',')
6129
+ .map(value => value.trim())
6130
+ .filter(Boolean);
6131
+ const adminToken = protocols.find(value => value.startsWith('livedesk-admin-'))?.slice('livedesk-admin-'.length) || '';
6132
+ const csrfToken = protocols.find(value => value.startsWith('livedesk-admin-csrf-'))?.slice('livedesk-admin-csrf-'.length) || '';
6133
+ return secureExactTestTokenMatches(adminToken, localAdminSessionToken)
6134
+ && secureExactTestTokenMatches(csrfToken, localAdminCsrfToken);
6135
+ }
6136
+
6137
+ httpServer.on('upgrade', (req, socket, head) => {
6138
+ if (!isTrustedBrowserRequest(req)) {
5976
6139
  socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
5977
6140
  socket.destroy();
5978
- return;
5979
- }
5980
- if (!isAuthorizedAdminWebSocket(req)) {
5981
- recordSecurityAudit({
5982
- action: 'local-admin.websocket',
5983
- phase: 'complete',
5984
- result: 'rejected',
5985
- reason: 'admin-session-required',
5986
- requestHash: requestAuditHash({ path: req.url || '' }),
5987
- authMethod: 'local-admin-websocket'
5988
- });
5989
- socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
5990
- socket.destroy();
5991
- return;
5992
- }
6141
+ return;
6142
+ }
6143
+ if (!isAuthorizedAdminWebSocket(req)) {
6144
+ recordSecurityAudit({
6145
+ action: 'local-admin.websocket',
6146
+ phase: 'complete',
6147
+ result: 'rejected',
6148
+ reason: 'admin-session-required',
6149
+ requestHash: requestAuditHash({ path: req.url || '' }),
6150
+ authMethod: 'local-admin-websocket'
6151
+ });
6152
+ socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
6153
+ socket.destroy();
6154
+ return;
6155
+ }
6156
+ let parsed;
5993
6157
  try {
5994
- const parsed = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
6158
+ parsed = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
6159
+ } catch {
6160
+ socket.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
6161
+ socket.destroy();
6162
+ return;
6163
+ }
6164
+ const websocketPath = parsed.pathname;
6165
+ if (![
6166
+ '/api/remote/frames/ws',
6167
+ '/api/remote/atlas/ws',
6168
+ '/api/remote/input/ws',
6169
+ '/api/remote/audio/ws'
6170
+ ].includes(websocketPath)) {
6171
+ socket.write('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n');
6172
+ socket.destroy();
6173
+ return;
6174
+ }
6175
+ void recordSecurityAuditRequired({
6176
+ action: 'local-admin.websocket',
6177
+ phase: 'accepted',
6178
+ result: 'accepted',
6179
+ sessionId: localAdminSessionId,
6180
+ requestHash: requestAuditHash({ path: websocketPath }),
6181
+ authMethod: 'local-admin-websocket',
6182
+ details: { path: websocketPath }
6183
+ }).then(() => {
5995
6184
  if (parsed.pathname === '/api/remote/frames/ws') {
5996
6185
  frameWss.handleUpgrade(req, socket, head, ws => frameWss.emit('connection', ws, req));
5997
6186
  return;
@@ -6008,12 +6197,10 @@ httpServer.on('upgrade', (req, socket, head) => {
6008
6197
  audioWss.handleUpgrade(req, socket, head, ws => audioWss.emit('connection', ws, req));
6009
6198
  return;
6010
6199
  }
6011
- socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
6012
- socket.destroy();
6013
- } catch {
6014
- socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
6200
+ }).catch(() => {
6201
+ socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n');
6015
6202
  socket.destroy();
6016
- }
6203
+ });
6017
6204
  });
6018
6205
 
6019
6206
  frameWss.on('connection', (ws, req) => {
@@ -6126,23 +6313,21 @@ audioWss.on('connection', (ws, req) => {
6126
6313
  });
6127
6314
  });
6128
6315
 
6129
- inputWss.on('connection', ws => {
6316
+ inputWss.on('connection', ws => {
6130
6317
  inputClients.add(ws);
6131
6318
  ws.liveDeskInputClientId = `riws-${++inputClientSeq}`;
6132
- ws.liveDeskInputDeviceIds = new Set();
6133
- recordSecurityAudit({
6134
- action: 'remote.control.browser-session',
6135
- phase: 'start',
6136
- result: 'success',
6137
- sessionId: ws.liveDeskInputClientId,
6138
- authMethod: 'local-admin-websocket'
6139
- });
6319
+ ws.liveDeskInputDeviceIds = new Set();
6320
+ ws.liveDeskInputAuditReady = false;
6140
6321
  try {
6141
6322
  ws._socket?.setNoDelay?.(true);
6142
6323
  } catch {
6143
6324
  // Best-effort latency hint for browser input sockets.
6144
6325
  }
6145
6326
  ws.on('message', data => {
6327
+ if (ws.liveDeskInputAuditReady !== true) {
6328
+ sendJson(ws, { type: 'RemoteInputError', error: 'security-audit-pending' });
6329
+ return;
6330
+ }
6146
6331
  let payload;
6147
6332
  try {
6148
6333
  payload = JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data || ''));
@@ -6182,31 +6367,44 @@ inputWss.on('connection', ws => {
6182
6367
  timestamp: new Date().toISOString()
6183
6368
  });
6184
6369
  });
6185
- const releaseBrowserInputOwner = reason => {
6186
- if (ws.liveDeskInputReleased) return;
6187
- ws.liveDeskInputReleased = true;
6188
- inputClients.delete(ws);
6189
- for (const deviceId of ws.liveDeskInputDeviceIds || []) {
6190
- remoteHub.releaseInputOwner(deviceId, ws.liveDeskInputClientId, reason);
6191
- }
6192
- recordSecurityAudit({
6193
- action: 'remote.control.browser-session',
6194
- phase: 'complete',
6195
- result: 'success',
6196
- reason,
6197
- deviceIds: [...(ws.liveDeskInputDeviceIds || [])],
6198
- sessionId: ws.liveDeskInputClientId,
6199
- authMethod: 'local-admin-websocket'
6200
- });
6370
+ const releaseBrowserInputOwner = reason => {
6371
+ if (ws.liveDeskInputReleased) return;
6372
+ ws.liveDeskInputReleased = true;
6373
+ inputClients.delete(ws);
6374
+ for (const deviceId of ws.liveDeskInputDeviceIds || []) {
6375
+ remoteHub.releaseInputOwner(deviceId, ws.liveDeskInputClientId, reason);
6376
+ }
6377
+ void recordSecurityAuditRequired({
6378
+ action: 'remote.control.browser-session',
6379
+ phase: 'complete',
6380
+ result: 'success',
6381
+ reason,
6382
+ deviceIds: [...(ws.liveDeskInputDeviceIds || [])],
6383
+ sessionId: ws.liveDeskInputClientId,
6384
+ authMethod: 'local-admin-websocket'
6385
+ }).catch(() => {
6386
+ // The global fail-closed gate is marked by recordSecurityAuditRequired.
6387
+ });
6201
6388
  ws.liveDeskInputDeviceIds?.clear?.();
6202
6389
  };
6203
6390
  ws.on('close', () => releaseBrowserInputOwner('browser-input-websocket-closed'));
6204
6391
  ws.on('error', () => releaseBrowserInputOwner('browser-input-websocket-error'));
6205
- sendJson(ws, {
6206
- type: 'RemoteInputSocketReady',
6207
- protocol: 'livedesk.remote.input.json.v1',
6208
- clientId: ws.liveDeskInputClientId,
6209
- timestamp: new Date().toISOString()
6392
+ void recordSecurityAuditRequired({
6393
+ action: 'remote.control.browser-session',
6394
+ phase: 'start',
6395
+ result: 'success',
6396
+ sessionId: ws.liveDeskInputClientId,
6397
+ authMethod: 'local-admin-websocket'
6398
+ }).then(() => {
6399
+ ws.liveDeskInputAuditReady = true;
6400
+ sendJson(ws, {
6401
+ type: 'RemoteInputSocketReady',
6402
+ protocol: 'livedesk.remote.input.json.v1',
6403
+ clientId: ws.liveDeskInputClientId,
6404
+ timestamp: new Date().toISOString()
6405
+ });
6406
+ }).catch(() => {
6407
+ ws.close(1011, 'security-audit-unavailable');
6210
6408
  });
6211
6409
  });
6212
6410
 
@@ -6302,8 +6500,8 @@ function shutdownHub(signal) {
6302
6500
  hubShutdownPromise = (async () => {
6303
6501
  const startedAt = Date.now();
6304
6502
  console.log(`[LiveDesk Hub] Shutdown started signal=${signal} grace=${hubShutdownGraceMs}ms timeout=${hubShutdownTimeoutMs}ms.`);
6305
- if (roleWatchTimer) clearInterval(roleWatchTimer);
6306
- clearInterval(captureRetentionTimer);
6503
+ if (roleWatchTimer) clearInterval(roleWatchTimer);
6504
+ clearInterval(captureRetentionTimer);
6307
6505
  clearInterval(browserWebSocketHeartbeatTimer);
6308
6506
  runSynchronousShutdownStep('update manager close', () => liveDeskUpdateManager?.close());
6309
6507
  atlasClients.clear();