@livedesk/hub 0.1.32 → 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,81 +803,81 @@ function requireHubFeatureAccess(_req, res, next) {
803
803
  res.status(402).json({ ok: false, error: 'livedesk-plan-device-limit', license: licenseSnapshot() });
804
804
  }
805
805
 
806
- const agentDataDir = process.env.LIVEDESK_DATA_DIR || undefined;
807
- securityAuditStore = createSecurityAuditStore({ dataDir: agentDataDir });
808
-
809
- function requestAuditHash(value) {
810
- try {
811
- return crypto.createHash('sha256').update(JSON.stringify(value ?? {}), 'utf8').digest('base64url');
812
- } catch {
813
- return crypto.createHash('sha256').update('[unserializable]', 'utf8').digest('base64url');
814
- }
815
- }
816
-
817
- function securityAuditEvent(event = {}) {
818
- const runtime = runtimeManager.getSnapshot();
819
- return {
820
- actorAccountId: event.actorAccountId || runtime.userId || '',
821
- actorUserId: event.actorUserId || runtime.userId || '',
822
- hubId: event.hubId || runtime.deviceId || '',
823
- ...event
824
- };
825
- }
826
-
827
- function markSecurityAuditFailed(error) {
828
- if (securityAuditHealthy) {
829
- securityAuditHealthy = false;
830
- console.error(`[LiveDesk Hub] SECURITY AUDIT FAILURE: ${error?.message || error}`);
831
- }
832
- }
833
-
834
- async function recordSecurityAuditRequired(event = {}) {
835
- if (!securityAuditStore || !securityAuditHealthy) {
836
- const error = new Error('security-audit-unavailable');
837
- error.code = 'security-audit-unavailable';
838
- throw error;
839
- }
840
- try {
841
- return await securityAuditStore.record(securityAuditEvent(event));
842
- } catch (error) {
843
- markSecurityAuditFailed(error);
844
- throw error;
845
- }
846
- }
847
-
848
- function recordSecurityAudit(event = {}) {
849
- void recordSecurityAuditRequired(event).catch(() => {
850
- // Required mutation paths await recordSecurityAuditRequired directly.
851
- // Best-effort lifecycle telemetry still marks the global audit gate failed.
852
- });
853
- }
854
-
855
- const liveDeskSettingsStore = new LiveDeskSettingsStore({ dataDir: agentDataDir });
856
- const captureStore = new CaptureStore({
857
- dataDir: agentDataDir,
858
- getRetentionPolicy: () => liveDeskSettingsStore.getCached()?.filesAudio?.captureAutoDelete || 'never',
859
- onRetentionDelete: result => recordSecurityAudit({
860
- action: 'privacy.capture.retention-delete',
861
- phase: 'complete',
862
- result: 'success',
863
- authMethod: 'settings-retention-policy',
864
- details: result
865
- })
866
- });
867
- void captureStore.initialize().catch(error => {
868
- console.warn(`[LiveDesk Hub] capture store initialization failed: ${error instanceof Error ? error.message : String(error)}`);
869
- });
870
- void liveDeskSettingsStore.getRecord()
871
- .then(() => captureStore.enforceRetention())
872
- .catch(error => {
873
- console.warn(`[LiveDesk Hub] settings or capture retention load failed: ${error instanceof Error ? error.message : String(error)}`);
874
- });
875
- const captureRetentionTimer = setInterval(() => {
876
- void captureStore.enforceRetention().catch(error => {
877
- console.warn(`[LiveDesk Hub] capture retention failed: ${error instanceof Error ? error.message : String(error)}`);
878
- });
879
- }, 60 * 60_000);
880
- captureRetentionTimer.unref?.();
806
+ const agentDataDir = process.env.LIVEDESK_DATA_DIR || undefined;
807
+ securityAuditStore = createSecurityAuditStore({ dataDir: agentDataDir });
808
+
809
+ function requestAuditHash(value) {
810
+ try {
811
+ return crypto.createHash('sha256').update(JSON.stringify(value ?? {}), 'utf8').digest('base64url');
812
+ } catch {
813
+ return crypto.createHash('sha256').update('[unserializable]', 'utf8').digest('base64url');
814
+ }
815
+ }
816
+
817
+ function securityAuditEvent(event = {}) {
818
+ const runtime = runtimeManager.getSnapshot();
819
+ return {
820
+ actorAccountId: event.actorAccountId || runtime.userId || '',
821
+ actorUserId: event.actorUserId || runtime.userId || '',
822
+ hubId: event.hubId || runtime.deviceId || '',
823
+ ...event
824
+ };
825
+ }
826
+
827
+ function markSecurityAuditFailed(error) {
828
+ if (securityAuditHealthy) {
829
+ securityAuditHealthy = false;
830
+ console.error(`[LiveDesk Hub] SECURITY AUDIT FAILURE: ${error?.message || error}`);
831
+ }
832
+ }
833
+
834
+ async function recordSecurityAuditRequired(event = {}) {
835
+ if (!securityAuditStore || !securityAuditHealthy) {
836
+ const error = new Error('security-audit-unavailable');
837
+ error.code = 'security-audit-unavailable';
838
+ throw error;
839
+ }
840
+ try {
841
+ return await securityAuditStore.record(securityAuditEvent(event));
842
+ } catch (error) {
843
+ markSecurityAuditFailed(error);
844
+ throw error;
845
+ }
846
+ }
847
+
848
+ function recordSecurityAudit(event = {}) {
849
+ void recordSecurityAuditRequired(event).catch(() => {
850
+ // Required mutation paths await recordSecurityAuditRequired directly.
851
+ // Best-effort lifecycle telemetry still marks the global audit gate failed.
852
+ });
853
+ }
854
+
855
+ const liveDeskSettingsStore = new LiveDeskSettingsStore({ dataDir: agentDataDir });
856
+ const captureStore = new CaptureStore({
857
+ dataDir: agentDataDir,
858
+ getRetentionPolicy: () => liveDeskSettingsStore.getCached()?.filesAudio?.captureAutoDelete || 'never',
859
+ onRetentionDelete: result => recordSecurityAudit({
860
+ action: 'privacy.capture.retention-delete',
861
+ phase: 'complete',
862
+ result: 'success',
863
+ authMethod: 'settings-retention-policy',
864
+ details: result
865
+ })
866
+ });
867
+ void captureStore.initialize().catch(error => {
868
+ console.warn(`[LiveDesk Hub] capture store initialization failed: ${error instanceof Error ? error.message : String(error)}`);
869
+ });
870
+ void liveDeskSettingsStore.getRecord()
871
+ .then(() => captureStore.enforceRetention())
872
+ .catch(error => {
873
+ console.warn(`[LiveDesk Hub] settings or capture retention load failed: ${error instanceof Error ? error.message : String(error)}`);
874
+ });
875
+ const captureRetentionTimer = setInterval(() => {
876
+ void captureStore.enforceRetention().catch(error => {
877
+ console.warn(`[LiveDesk Hub] capture retention failed: ${error instanceof Error ? error.message : String(error)}`);
878
+ });
879
+ }, 60 * 60_000);
880
+ captureRetentionTimer.unref?.();
881
881
 
882
882
  const udpTransport = createHubUdpTransport({
883
883
  env: process.env,
@@ -885,10 +885,10 @@ const udpTransport = createHubUdpTransport({
885
885
  logWarn: (_scope, message) => console.warn(`[LiveDesk Hub] ${message}`)
886
886
  });
887
887
 
888
- const remoteHub = createRemoteHub({
889
- managerPackage: '@livedesk/hub',
890
- managerVersion: packageInfo.version,
891
- dataDir: agentDataDir,
888
+ const remoteHub = createRemoteHub({
889
+ managerPackage: '@livedesk/hub',
890
+ managerVersion: packageInfo.version,
891
+ dataDir: agentDataDir,
892
892
  env: {
893
893
  ...process.env,
894
894
  MINDEXEC_MANAGER_PACKAGE: '@livedesk/hub',
@@ -899,11 +899,11 @@ const remoteHub = createRemoteHub({
899
899
  logWarn: (_scope, message) => console.warn(`[LiveDesk Hub] ${message}`),
900
900
  emitEvent: handleRemoteHubEvent,
901
901
  emitFrame: broadcastRemoteBinaryFrame,
902
- emitAudio: broadcastRemoteBinaryAudio,
903
- getSecurityIdentity: () => ({
904
- accountId: runtimeManager.getSnapshot().userId || testSecurityAccountId,
905
- hubId: remoteHub?.getSecurityStatus?.().hubId || ''
906
- }),
902
+ emitAudio: broadcastRemoteBinaryAudio,
903
+ getSecurityIdentity: () => ({
904
+ accountId: runtimeManager.getSnapshot().userId || testSecurityAccountId,
905
+ hubId: remoteHub?.getSecurityStatus?.().hubId || ''
906
+ }),
907
907
  getEffectiveDevicePolicy: ({ deviceId, capabilities }) => buildEffectiveDevicePolicy(liveDeskSettingsStore.getCached(), { deviceId, capabilities }),
908
908
  getWelcomeDevicePolicy: ({ deviceId, capabilities }) => {
909
909
  const policy = buildEffectiveDevicePolicy(liveDeskSettingsStore.getCached(), { deviceId, capabilities });
@@ -1006,11 +1006,11 @@ function getLiveDeskUpdateStatus() {
1006
1006
  if (process.env.LIVEDESK_DESKTOP_HOST !== '1') {
1007
1007
  liveDeskUpdateManager = createLiveDeskUpdateManager({
1008
1008
  remoteHub,
1009
- currentManagerVersion: process.env.LIVEDESK_MANAGER_VERSION || packageInfo.version,
1010
- currentClientVersion: process.env.LIVEDESK_CLIENT_PACKAGE_VERSION || '',
1011
- updateManifestUrl: process.env.LIVEDESK_UPDATE_MANIFEST_URL || '',
1012
- updatePublicKey: process.env.LIVEDESK_UPDATE_PUBLIC_KEY || '',
1013
- restartSupported: !!String(process.env.LIVEDESK_HUB_UPDATE_REQUEST_PATH || '').trim(),
1009
+ currentManagerVersion: process.env.LIVEDESK_MANAGER_VERSION || packageInfo.version,
1010
+ currentClientVersion: process.env.LIVEDESK_CLIENT_PACKAGE_VERSION || '',
1011
+ updateManifestUrl: process.env.LIVEDESK_UPDATE_MANIFEST_URL || '',
1012
+ updatePublicKey: process.env.LIVEDESK_UPDATE_PUBLIC_KEY || '',
1013
+ restartSupported: !!String(process.env.LIVEDESK_HUB_UPDATE_REQUEST_PATH || '').trim(),
1014
1014
  requestHubRestart
1015
1015
  });
1016
1016
  }
@@ -1328,7 +1328,7 @@ function validateAgentMcpArguments(name, args) {
1328
1328
  return '';
1329
1329
  }
1330
1330
 
1331
- async function dispatchAgentMcpTool(session, name, args = {}) {
1331
+ async function dispatchAgentMcpTool(session, name, args = {}) {
1332
1332
  if (session.cancelled || session.signal?.aborted) return { ok: false, error: 'cancelled-by-user' };
1333
1333
  // This check runs synchronously before the first await, so concurrent Node
1334
1334
  // requests cannot pass the same remaining budget and queue extra Client work.
@@ -1336,23 +1336,23 @@ async function dispatchAgentMcpTool(session, name, args = {}) {
1336
1336
  session.toolLimitReached = true;
1337
1337
  return { ok: false, error: 'codex-tool-limit-reached' };
1338
1338
  }
1339
- session.toolCallCount += 1;
1340
- if (isRetiredAgentMutatingToolName(name)) {
1341
- recordAgentAudit({
1342
- event: 'tool-rejected',
1343
- runId: session.runId,
1344
- deviceIds: [],
1345
- toolName: name,
1346
- category: 'mutation',
1347
- decision: 'deny',
1348
- status: 'rejected',
1349
- permissionMode: session.permissionPolicy?.mode,
1350
- policyHash: session.permissionPolicyHash,
1351
- details: { reason: 'agent-mutating-tool-disabled' }
1352
- });
1353
- return { ok: false, error: 'agent-mutating-tool-disabled' };
1354
- }
1355
- const tool = getAgentToolDefinition(name);
1339
+ session.toolCallCount += 1;
1340
+ if (isRetiredAgentMutatingToolName(name)) {
1341
+ recordAgentAudit({
1342
+ event: 'tool-rejected',
1343
+ runId: session.runId,
1344
+ deviceIds: [],
1345
+ toolName: name,
1346
+ category: 'mutation',
1347
+ decision: 'deny',
1348
+ status: 'rejected',
1349
+ permissionMode: session.permissionPolicy?.mode,
1350
+ policyHash: session.permissionPolicyHash,
1351
+ details: { reason: 'agent-mutating-tool-disabled' }
1352
+ });
1353
+ return { ok: false, error: 'agent-mutating-tool-disabled' };
1354
+ }
1355
+ const tool = getAgentToolDefinition(name);
1356
1356
  if (!tool) return { ok: false, error: 'codex-tool-not-allowed' };
1357
1357
  const validationError = validateAgentMcpArguments(name, args);
1358
1358
  if (validationError) return { ok: false, error: validationError };
@@ -1543,20 +1543,20 @@ async function synchronizeAgentEnablement() {
1543
1543
  return enabled;
1544
1544
  }
1545
1545
 
1546
- const hubFilesystem = createHubFilesystem();
1547
- const fileTransferCommandAckTimeoutMs = readPositiveIntegerEnv(
1548
- 'LIVEDESK_FILE_CHUNK_ACK_TIMEOUT_MS',
1549
- 30_000
1550
- );
1551
- hubTransferJobs = createHubTransferJobs({
1552
- filesystem: hubFilesystem,
1553
- remoteHub,
1554
- maxConcurrent: Number(process.env.LIVEDESK_MAX_FILE_JOBS || 2),
1555
- commandResultTimeoutMs: fileTransferCommandAckTimeoutMs,
1556
- getMaxFileSizeBytes: () => Number(
1557
- liveDeskSettingsStore.getCached()?.filesAudio?.maxFileSizeBytes
1558
- || 1024 * 1024 * 1024)
1559
- });
1546
+ const hubFilesystem = createHubFilesystem();
1547
+ const fileTransferCommandAckTimeoutMs = readPositiveIntegerEnv(
1548
+ 'LIVEDESK_FILE_CHUNK_ACK_TIMEOUT_MS',
1549
+ 30_000
1550
+ );
1551
+ hubTransferJobs = createHubTransferJobs({
1552
+ filesystem: hubFilesystem,
1553
+ remoteHub,
1554
+ maxConcurrent: Number(process.env.LIVEDESK_MAX_FILE_JOBS || 2),
1555
+ commandResultTimeoutMs: fileTransferCommandAckTimeoutMs,
1556
+ getMaxFileSizeBytes: () => Number(
1557
+ liveDeskSettingsStore.getCached()?.filesAudio?.maxFileSizeBytes
1558
+ || 1024 * 1024 * 1024)
1559
+ });
1560
1560
  const hubSharedFolders = createHubSharedFolders({
1561
1561
  filesystem: hubFilesystem,
1562
1562
  transferJobs: hubTransferJobs,
@@ -1676,125 +1676,152 @@ app.use((req, res, next) => {
1676
1676
  res.setHeader('Access-Control-Allow-Origin', origin);
1677
1677
  res.setHeader('Access-Control-Allow-Private-Network', 'true');
1678
1678
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
1679
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-LiveDesk-Admin-Session, X-LiveDesk-Admin-CSRF, X-LiveDesk-CSRF');
1679
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-LiveDesk-Admin-Session, X-LiveDesk-Admin-CSRF, X-LiveDesk-CSRF');
1680
1680
  res.setHeader('Vary', 'Origin, Access-Control-Request-Headers, Access-Control-Request-Private-Network');
1681
1681
  }
1682
1682
  if (req.method === 'OPTIONS') {
1683
1683
  res.status(204).end();
1684
1684
  return;
1685
1685
  }
1686
- next();
1687
- });
1688
- app.use((req, res, next) => {
1689
- if (!enforceLocalAdminAuth || req.path === '/api/security/session' || req.method === 'OPTIONS') {
1690
- next();
1691
- return;
1692
- }
1693
- const authorizedLauncherShutdown = req.method === 'POST'
1694
- && req.path === '/api/runtime/shutdown'
1695
- && isLoopbackAddress(req.socket?.remoteAddress)
1696
- && launcherShutdownToken
1697
- && secureExactTestTokenMatches(
1698
- String(req.headers['x-livedesk-launcher-shutdown'] || ''),
1699
- launcherShutdownToken
1700
- );
1701
- if (authorizedLauncherShutdown) {
1702
- req.liveDeskLauncherShutdown = true;
1703
- next();
1704
- return;
1705
- }
1706
- const mutating = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method);
1707
- const sensitiveRead = req.method === 'GET'
1708
- && ['/api/security/audit', '/api/security/rendezvous-issuer', '/api/privacy/inventory'].includes(req.path);
1709
- if (!mutating && !sensitiveRead) {
1710
- next();
1711
- return;
1712
- }
1713
- const adminToken = String(req.headers['x-livedesk-admin-session'] || '');
1714
- const csrfToken = String(req.headers['x-livedesk-admin-csrf'] || '');
1715
- if (!secureExactTestTokenMatches(adminToken, localAdminSessionToken)) {
1716
- recordSecurityAudit({
1717
- action: 'local-admin.request',
1718
- phase: 'complete',
1719
- result: 'rejected',
1720
- reason: 'admin-session-required',
1721
- requestHash: requestAuditHash({ method: req.method, path: req.path }),
1722
- authMethod: 'local-admin-session'
1723
- });
1724
- res.status(401).json({ ok: false, error: 'local-admin-session-required' });
1725
- return;
1726
- }
1727
- if (mutating && !secureExactTestTokenMatches(csrfToken, localAdminCsrfToken)) {
1728
- recordSecurityAudit({
1729
- action: 'local-admin.request',
1730
- phase: 'complete',
1731
- result: 'rejected',
1732
- reason: 'csrf-token-required',
1733
- requestHash: requestAuditHash({ method: req.method, path: req.path }),
1734
- authMethod: 'local-admin-session'
1735
- });
1736
- res.status(403).json({ ok: false, error: 'csrf-token-required' });
1737
- return;
1738
- }
1739
- if (!securityAuditHealthy) {
1740
- res.status(503).json({ ok: false, error: 'security-audit-unavailable' });
1741
- return;
1742
- }
1743
- req.liveDeskAdminSessionId = localAdminSessionId;
1744
- if (!mutating) {
1745
- next();
1746
- return;
1747
- }
1748
-
1749
- const mutationRequestHash = requestAuditHash({ method: req.method, path: req.path });
1750
- void recordSecurityAuditRequired({
1751
- action: 'local-admin.mutation',
1752
- phase: 'accepted',
1753
- result: 'accepted',
1754
- sessionId: localAdminSessionId,
1755
- requestHash: mutationRequestHash,
1756
- authMethod: 'local-admin-session',
1757
- details: { method: req.method, path: req.path }
1758
- }).then(() => {
1759
- const originalEnd = res.end.bind(res);
1760
- let completionStarted = false;
1761
- res.end = (...args) => {
1762
- if (completionStarted) return res;
1763
- completionStarted = true;
1764
- const statusCode = Number(res.statusCode || 200);
1765
- void recordSecurityAuditRequired({
1766
- action: 'local-admin.mutation',
1767
- phase: 'complete',
1768
- result: statusCode >= 200 && statusCode < 400 ? 'success' : 'rejected',
1769
- reason: statusCode >= 400 ? `http-${statusCode}` : '',
1770
- sessionId: localAdminSessionId,
1771
- requestHash: mutationRequestHash,
1772
- authMethod: 'local-admin-session',
1773
- details: { method: req.method, path: req.path, statusCode }
1774
- }).then(() => {
1775
- originalEnd(...args);
1776
- }).catch(error => {
1777
- if (res.headersSent) {
1778
- res.destroy(error);
1779
- return;
1780
- }
1781
- const callback = [...args].reverse().find(value => typeof value === 'function');
1782
- const body = JSON.stringify({ ok: false, error: 'security-audit-unavailable' });
1783
- res.statusCode = 503;
1784
- res.removeHeader('Content-Length');
1785
- res.removeHeader('Transfer-Encoding');
1786
- res.setHeader('Content-Type', 'application/json; charset=utf-8');
1787
- res.setHeader('Content-Length', Buffer.byteLength(body));
1788
- originalEnd(body, 'utf8', callback);
1789
- });
1790
- return res;
1791
- };
1792
- next();
1793
- }).catch(() => {
1794
- res.status(503).json({ ok: false, error: 'security-audit-unavailable' });
1795
- });
1796
- });
1797
- app.use(express.json({ limit: '32mb' }));
1686
+ next();
1687
+ });
1688
+ const parseHubJsonBody = express.json({ limit: '32mb' });
1689
+ function parseHubJsonRequest(req, res, next, onParsed = next) {
1690
+ parseHubJsonBody(req, res, error => {
1691
+ if (!error) {
1692
+ onParsed();
1693
+ return;
1694
+ }
1695
+ const type = String(error?.type || '');
1696
+ const code = String(error?.code || '');
1697
+ const bodyError = type === 'entity.parse.failed'
1698
+ ? { status: 400, error: 'invalid-json' }
1699
+ : type === 'entity.too.large'
1700
+ ? { status: 413, error: 'request-too-large' }
1701
+ : type === 'request.aborted' || type === 'stream.not.readable' || code === 'ECONNABORTED'
1702
+ ? { status: 400, error: 'request-body-unavailable' }
1703
+ : null;
1704
+ if (!bodyError) {
1705
+ next(error);
1706
+ return;
1707
+ }
1708
+ if (res.headersSent || res.destroyed || req.destroyed) return;
1709
+ res.status(bodyError.status).json({ ok: false, error: bodyError.error });
1710
+ });
1711
+ }
1712
+ app.use((req, res, next) => {
1713
+ const mutating = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method);
1714
+ if (!enforceLocalAdminAuth || req.path === '/api/security/session' || req.method === 'OPTIONS') {
1715
+ if (mutating) parseHubJsonRequest(req, res, next);
1716
+ else next();
1717
+ return;
1718
+ }
1719
+ const authorizedLauncherShutdown = req.method === 'POST'
1720
+ && req.path === '/api/runtime/shutdown'
1721
+ && isLoopbackAddress(req.socket?.remoteAddress)
1722
+ && launcherShutdownToken
1723
+ && secureExactTestTokenMatches(
1724
+ String(req.headers['x-livedesk-launcher-shutdown'] || ''),
1725
+ launcherShutdownToken
1726
+ );
1727
+ if (authorizedLauncherShutdown) {
1728
+ req.liveDeskLauncherShutdown = true;
1729
+ parseHubJsonRequest(req, res, next);
1730
+ return;
1731
+ }
1732
+ const sensitiveRead = req.method === 'GET'
1733
+ && ['/api/security/audit', '/api/security/rendezvous-issuer', '/api/privacy/inventory'].includes(req.path);
1734
+ if (!mutating && !sensitiveRead) {
1735
+ next();
1736
+ return;
1737
+ }
1738
+ const adminToken = String(req.headers['x-livedesk-admin-session'] || '');
1739
+ const csrfToken = String(req.headers['x-livedesk-admin-csrf'] || '');
1740
+ if (!secureExactTestTokenMatches(adminToken, localAdminSessionToken)) {
1741
+ recordSecurityAudit({
1742
+ action: 'local-admin.request',
1743
+ phase: 'complete',
1744
+ result: 'rejected',
1745
+ reason: 'admin-session-required',
1746
+ requestHash: requestAuditHash({ method: req.method, path: req.path }),
1747
+ authMethod: 'local-admin-session'
1748
+ });
1749
+ res.status(401).json({ ok: false, error: 'local-admin-session-required' });
1750
+ return;
1751
+ }
1752
+ if (mutating && !secureExactTestTokenMatches(csrfToken, localAdminCsrfToken)) {
1753
+ recordSecurityAudit({
1754
+ action: 'local-admin.request',
1755
+ phase: 'complete',
1756
+ result: 'rejected',
1757
+ reason: 'csrf-token-required',
1758
+ requestHash: requestAuditHash({ method: req.method, path: req.path }),
1759
+ authMethod: 'local-admin-session'
1760
+ });
1761
+ res.status(403).json({ ok: false, error: 'csrf-token-required' });
1762
+ return;
1763
+ }
1764
+ if (!securityAuditHealthy) {
1765
+ res.status(503).json({ ok: false, error: 'security-audit-unavailable' });
1766
+ return;
1767
+ }
1768
+ req.liveDeskAdminSessionId = localAdminSessionId;
1769
+ if (!mutating) {
1770
+ next();
1771
+ return;
1772
+ }
1773
+
1774
+ const beginMutationAudit = () => {
1775
+ const mutationRequestHash = requestAuditHash({ method: req.method, path: req.path });
1776
+ void recordSecurityAuditRequired({
1777
+ action: 'local-admin.mutation',
1778
+ phase: 'accepted',
1779
+ result: 'accepted',
1780
+ sessionId: localAdminSessionId,
1781
+ requestHash: mutationRequestHash,
1782
+ authMethod: 'local-admin-session',
1783
+ details: { method: req.method, path: req.path }
1784
+ }).then(() => {
1785
+ const originalEnd = res.end.bind(res);
1786
+ let completionStarted = false;
1787
+ res.end = (...args) => {
1788
+ if (completionStarted) return res;
1789
+ completionStarted = true;
1790
+ const statusCode = Number(res.statusCode || 200);
1791
+ void recordSecurityAuditRequired({
1792
+ action: 'local-admin.mutation',
1793
+ phase: 'complete',
1794
+ result: statusCode >= 200 && statusCode < 400 ? 'success' : 'rejected',
1795
+ reason: statusCode >= 400 ? `http-${statusCode}` : '',
1796
+ sessionId: localAdminSessionId,
1797
+ requestHash: mutationRequestHash,
1798
+ authMethod: 'local-admin-session',
1799
+ details: { method: req.method, path: req.path, statusCode }
1800
+ }).then(() => {
1801
+ originalEnd(...args);
1802
+ }).catch(error => {
1803
+ if (res.headersSent) {
1804
+ res.destroy(error);
1805
+ return;
1806
+ }
1807
+ const callback = [...args].reverse().find(value => typeof value === 'function');
1808
+ const body = JSON.stringify({ ok: false, error: 'security-audit-unavailable' });
1809
+ res.statusCode = 503;
1810
+ res.removeHeader('Content-Length');
1811
+ res.removeHeader('Transfer-Encoding');
1812
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
1813
+ res.setHeader('Content-Length', Buffer.byteLength(body));
1814
+ originalEnd(body, 'utf8', callback);
1815
+ });
1816
+ return res;
1817
+ };
1818
+ next();
1819
+ }).catch(() => {
1820
+ res.status(503).json({ ok: false, error: 'security-audit-unavailable' });
1821
+ });
1822
+ };
1823
+ parseHubJsonRequest(req, res, next, beginMutationAudit);
1824
+ });
1798
1825
  app.use((req, res, next) => {
1799
1826
  if (runtimeRole === 'client' && req.path.startsWith('/api/remote')) {
1800
1827
  res.status(403).json(roleGuardResponse(runtimeRole, 'hub'));
@@ -1945,12 +1972,12 @@ function normalizeTransferFiles(value) {
1945
1972
  if (totalBytes > MAX_FILE_TRANSFER_BYTES) {
1946
1973
  return { ok: false, error: 'file-transfer-too-large', files: [], totalBytes };
1947
1974
  }
1948
- files.push({
1949
- name,
1950
- relativePath,
1951
- size: byteLength,
1952
- sha256: crypto.createHash('sha256').update(Buffer.from(dataBase64, 'base64')).digest('hex'),
1953
- mimeType: String(entry?.type || entry?.mimeType || '').slice(0, 160),
1975
+ files.push({
1976
+ name,
1977
+ relativePath,
1978
+ size: byteLength,
1979
+ sha256: crypto.createHash('sha256').update(Buffer.from(dataBase64, 'base64')).digest('hex'),
1980
+ mimeType: String(entry?.type || entry?.mimeType || '').slice(0, 160),
1954
1981
  lastModified: Number(entry?.lastModified || 0) || 0,
1955
1982
  dataBase64
1956
1983
  });
@@ -1961,39 +1988,39 @@ function normalizeTransferFiles(value) {
1961
1988
  return { ok: true, files, totalBytes };
1962
1989
  }
1963
1990
 
1964
- function normalizeTransferChunk(body = {}) {
1991
+ function normalizeTransferChunk(body = {}) {
1965
1992
  const dataBase64 = String(body.dataBase64 || '').replace(/^data:[^,]*,/i, '').trim();
1966
1993
  const name = String(body.name || '').replace(/[\r\n\t\0]/g, ' ').trim().slice(0, 240);
1967
1994
  const relativePath = String(body.relativePath || name).replace(/[\r\n\t\0]/g, ' ').trim().slice(0, 600);
1968
1995
  const offset = Math.max(0, Math.floor(Number(body.offset) || 0));
1969
1996
  const totalBytes = Math.max(0, Math.floor(Number(body.totalBytes) || 0));
1970
- const byteLength = dataBase64 ? Buffer.byteLength(dataBase64, 'base64') : 0;
1971
- const final = body.final === true;
1972
- const maxFileSizeBytes = Math.max(1, Number(
1973
- liveDeskSettingsStore.getCached()?.filesAudio?.maxFileSizeBytes
1974
- || 1024 * 1024 * 1024));
1975
-
1976
- if (!name || !relativePath || offset > totalBytes || offset + byteLength > totalBytes) {
1977
- return { ok: false, error: 'invalid-file-transfer-chunk' };
1978
- }
1979
- if (totalBytes > maxFileSizeBytes) {
1980
- return { ok: false, error: 'file-transfer-file-too-large', totalBytes };
1981
- }
1997
+ const byteLength = dataBase64 ? Buffer.byteLength(dataBase64, 'base64') : 0;
1998
+ const final = body.final === true;
1999
+ const maxFileSizeBytes = Math.max(1, Number(
2000
+ liveDeskSettingsStore.getCached()?.filesAudio?.maxFileSizeBytes
2001
+ || 1024 * 1024 * 1024));
2002
+
2003
+ if (!name || !relativePath || offset > totalBytes || offset + byteLength > totalBytes) {
2004
+ return { ok: false, error: 'invalid-file-transfer-chunk' };
2005
+ }
2006
+ if (totalBytes > maxFileSizeBytes) {
2007
+ return { ok: false, error: 'file-transfer-file-too-large', totalBytes };
2008
+ }
1982
2009
  if (byteLength > MAX_FILE_TRANSFER_CHUNK_BYTES) {
1983
2010
  return { ok: false, error: 'file-transfer-chunk-too-large', byteLength };
1984
2011
  }
1985
- if (byteLength === 0 && !(final && totalBytes === 0 && offset === 0)) {
1986
- return { ok: false, error: 'empty-file-transfer-chunk' };
1987
- }
1988
- const suppliedSha256 = String(body.sha256 || '').trim().toLowerCase();
1989
- const sha256 = /^[a-f0-9]{64}$/.test(suppliedSha256)
1990
- ? suppliedSha256
1991
- : final && offset === 0 && totalBytes === byteLength
1992
- ? crypto.createHash('sha256').update(Buffer.from(dataBase64, 'base64')).digest('hex')
1993
- : '';
1994
- if (final && !sha256) {
1995
- return { ok: false, error: 'file-transfer-sha256-required', byteLength };
1996
- }
2012
+ if (byteLength === 0 && !(final && totalBytes === 0 && offset === 0)) {
2013
+ return { ok: false, error: 'empty-file-transfer-chunk' };
2014
+ }
2015
+ const suppliedSha256 = String(body.sha256 || '').trim().toLowerCase();
2016
+ const sha256 = /^[a-f0-9]{64}$/.test(suppliedSha256)
2017
+ ? suppliedSha256
2018
+ : final && offset === 0 && totalBytes === byteLength
2019
+ ? crypto.createHash('sha256').update(Buffer.from(dataBase64, 'base64')).digest('hex')
2020
+ : '';
2021
+ if (final && !sha256) {
2022
+ return { ok: false, error: 'file-transfer-sha256-required', byteLength };
2023
+ }
1997
2024
 
1998
2025
  return {
1999
2026
  ok: true,
@@ -2003,9 +2030,9 @@ function normalizeTransferChunk(body = {}) {
2003
2030
  offset,
2004
2031
  totalBytes,
2005
2032
  byteLength,
2006
- dataBase64,
2007
- final,
2008
- sha256,
2033
+ dataBase64,
2034
+ final,
2035
+ sha256,
2009
2036
  mimeType: String(body.type || body.mimeType || '').slice(0, 160),
2010
2037
  lastModified: Number(body.lastModified || 0) || 0
2011
2038
  }
@@ -4073,26 +4100,26 @@ function buildHubHealthPayload({
4073
4100
  };
4074
4101
  }
4075
4102
 
4076
- app.get('/api/health', (_req, res) => {
4077
- noStore(res);
4078
- res.json(buildHubHealthPayload());
4079
- });
4080
-
4081
- app.get('/api/security/session', (req, res) => {
4082
- noStore(res);
4083
- if (!isLoopbackAddress(req.socket?.remoteAddress) || !isLoopbackHostname(requestHostname(req))) {
4084
- res.status(403).json({ ok: false, error: 'local-admin-session-loopback-only' });
4085
- return;
4086
- }
4087
- res.json({
4088
- ok: true,
4089
- sessionId: localAdminSessionId,
4090
- adminToken: localAdminSessionToken,
4091
- csrfToken: localAdminCsrfToken,
4092
- issuedAt: new Date().toISOString(),
4093
- expiresOnProcessExit: true
4094
- });
4095
- });
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
+ });
4096
4123
 
4097
4124
  if (persistentSessionGcEnabled) {
4098
4125
  app.post('/api/test/persistent-session/gc', (req, res) => {
@@ -4145,18 +4172,18 @@ app.patch('/api/settings', async (req, res) => {
4145
4172
  const record = await liveDeskSettingsStore.update(patch, revision);
4146
4173
  if (patch.agent && Object.prototype.hasOwnProperty.call(patch.agent, 'enabled')) {
4147
4174
  await agentSettingsStore.update({ enabled: record.settings.agent?.enabled === true });
4148
- }
4149
- remoteHub.refreshDevicePolicies();
4150
- void captureStore.enforceRetention().catch(() => undefined);
4151
- recordSecurityAudit({
4152
- action: 'settings.security-policy.updated',
4153
- phase: 'complete',
4154
- result: 'success',
4155
- requestHash: requestAuditHash(patch),
4156
- authMethod: 'local-admin-session',
4157
- details: { revision: record.revision, sections: Object.keys(patch) }
4158
- });
4159
- res.json({ ok: true, revision: record.revision, updatedAt: record.updatedAt, settings: record.settings });
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 });
4160
4187
  } catch (error) {
4161
4188
  if (error instanceof SettingsConflictError) {
4162
4189
  res.status(409).json({ ok: false, error: error.code, revision: error.settings.revision, updatedAt: error.settings.updatedAt, settings: error.settings.settings });
@@ -4311,7 +4338,7 @@ app.get('/api/settings/capabilities', async (_req, res) => {
4311
4338
  });
4312
4339
  });
4313
4340
 
4314
- app.get('/api/security/trusted-devices', (_req, res) => {
4341
+ app.get('/api/security/trusted-devices', (_req, res) => {
4315
4342
  noStore(res);
4316
4343
  const devices = remoteHub.listDevices({ includeDataUrl: false }).map(device => ({
4317
4344
  deviceId: device.deviceId,
@@ -4323,141 +4350,141 @@ app.get('/api/security/trusted-devices', (_req, res) => {
4323
4350
  lastSeenAt: device.lastSeenAt || '',
4324
4351
  ipRange: String(device.remoteAddress || '').replace(/^::ffff:/, '').split(':')[0]
4325
4352
  }));
4326
- res.json({ ok: true, devices });
4327
- });
4328
-
4329
- app.get('/api/security/rendezvous-issuer', (_req, res) => {
4330
- noStore(res);
4331
- const security = remoteHub.getSecurityStatus();
4332
- res.json({
4333
- ok: true,
4334
- hubId: security.hubId,
4335
- issuerKeyId: security.hubIssuerKeyId,
4336
- publicKey: security.hubPublicKey,
4337
- algorithm: 'P-256/SHA-256',
4338
- environmentVariable: 'LIVEDESK_UDP_RENDEZVOUS_TRUSTED_ISSUER_PUBLIC_KEYS'
4339
- });
4340
- });
4341
-
4342
- app.get('/api/security/audit', async (req, res) => {
4343
- noStore(res);
4344
- try {
4345
- res.json({
4346
- ok: true,
4347
- healthy: securityAuditHealthy,
4348
- verification: await securityAuditStore.verify(),
4349
- audit: await securityAuditStore.list({ limit: req.query?.limit, action: req.query?.action })
4350
- });
4351
- } catch (error) {
4352
- securityAuditHealthy = false;
4353
- res.status(503).json({ ok: false, error: error?.code || 'security-audit-unavailable' });
4354
- }
4355
- });
4356
-
4357
- app.get('/api/privacy/inventory', async (_req, res) => {
4358
- noStore(res);
4359
- try {
4360
- const [captures, sharedFolders, verification] = await Promise.all([
4361
- captureStore.list(),
4362
- hubSharedFolders.list(),
4363
- securityAuditStore.verify()
4364
- ]);
4365
- res.json({
4366
- ok: true,
4367
- inventory: {
4368
- authenticated: runtimeManager.getSnapshot().authenticated === true,
4369
- captures: captures.length,
4370
- sharedFolderDefinitions: sharedFolders.length,
4371
- trustedDeviceCredentials: remoteHub.getSecurityStatus().devices.length,
4372
- securityAuditRecords: verification.records,
4373
- agentAuditRetained: (await agentAuditStore.list({ limit: 500 })).length
4374
- }
4375
- });
4376
- } catch (error) {
4377
- res.status(503).json({ ok: false, error: String(error?.code || error?.message || 'privacy-inventory-unavailable').slice(0, 120) });
4378
- }
4379
- });
4380
-
4381
- app.delete('/api/privacy/local-data', async (req, res) => {
4382
- noStore(res);
4383
- if (String(req.body?.confirmation || '') !== 'DELETE-LIVEDESK-LOCAL-DATA') {
4384
- res.status(400).json({ ok: false, error: 'privacy-delete-confirmation-required' });
4385
- return;
4386
- }
4387
- try {
4388
- const providerLogout = await revokeRuntimeProviderSession();
4389
- const hostTarget = runtimeRole === 'hub'
4390
- ? await clearHubHostTarget('privacy-delete')
4391
- : { ok: true, active: false };
4392
- clearRuntimeSession();
4393
- for (const device of remoteHub.getSecurityStatus().devices) {
4394
- remoteHub.disconnectDevice(device.deviceId, 'privacy-local-data-delete');
4395
- }
4396
- const trustedDeviceCredentials = remoteHub.clearDeviceCredentials();
4397
- const transferJobs = hubTransferJobs.clear();
4398
- const captures = await captureStore.removeAll();
4399
- const sharedFolders = await hubSharedFolders.clear();
4400
- await agentAuditStore.clear();
4401
- await securityAuditStore.reset();
4402
- securityAuditHealthy = true;
4403
- await securityAuditStore.record({
4404
- action: 'privacy.local-data.deleted',
4405
- phase: 'complete',
4406
- result: 'success',
4407
- authMethod: 'local-admin-session',
4408
- details: {
4409
- capturesRemoved: captures.removed,
4410
- captureBytesRemoved: captures.removedBytes,
4411
- sharedFolderDefinitionsRemoved: sharedFolders.removed,
4412
- trustedDeviceCredentialsRemoved: trustedDeviceCredentials,
4413
- transferJobRecordsRemoved: transferJobs.removed,
4414
- providerSessionRevoked: providerLogout.revoked === true
4415
- }
4416
- });
4417
- res.json({
4418
- ok: true,
4419
- localDeleted: true,
4420
- providerLogout,
4421
- hostTarget,
4422
- captures,
4423
- sharedFolders,
4424
- trustedDeviceCredentials,
4425
- transferJobs,
4426
- preserved: ['settings-preferences', 'hub-device-identity', 'application-binaries']
4427
- });
4428
- } catch (error) {
4429
- securityAuditHealthy = false;
4430
- res.status(503).json({ ok: false, error: String(error?.code || error?.message || 'privacy-delete-failed').slice(0, 120) });
4431
- }
4432
- });
4433
-
4434
- app.delete('/api/security/trusted-devices/:deviceId', (req, res) => {
4435
- noStore(res);
4436
- const disconnected = remoteHub.disconnectDevice(req.params.deviceId, 'trusted-device-revoked');
4437
- const credentialRevoked = remoteHub.revokeDeviceCredential(req.params.deviceId, 'trusted-device-revoked');
4438
- const revoked = disconnected === true || credentialRevoked === true;
4439
- recordSecurityAudit({
4440
- action: 'security.device.revoked',
4441
- phase: 'complete',
4442
- result: revoked ? 'success' : 'not-found',
4443
- deviceId: req.params.deviceId,
4444
- authMethod: 'local-admin-session'
4445
- });
4446
- res.json({ ok: revoked, revoked, deviceId: req.params.deviceId });
4447
- });
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
+ });
4448
4475
 
4449
4476
  app.post('/api/security/revoke-all', (_req, res) => {
4450
4477
  noStore(res);
4451
- const devices = remoteHub.listDevices({ includeDataUrl: false });
4452
- let revoked = 0;
4453
- for (const device of devices) {
4454
- const disconnected = device.connected && remoteHub.disconnectDevice(device.deviceId, 'trusted-devices-revoked');
4455
- const credentialRevoked = remoteHub.revokeDeviceCredential(device.deviceId, 'trusted-devices-revoked');
4456
- if (disconnected || credentialRevoked) revoked += 1;
4457
- }
4458
- recordSecurityAudit({ action: 'security.devices.revoke-all', phase: 'complete', result: 'success', authMethod: 'local-admin-session', details: { revoked } });
4459
- res.json({ ok: true, revoked });
4460
- });
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
+ });
4461
4488
 
4462
4489
  app.get('/api/settings/agent', async (_req, res) => {
4463
4490
  noStore(res);
@@ -4819,17 +4846,17 @@ app.post('/api/runtime/restart', (_req, res) => {
4819
4846
  }, 150);
4820
4847
  });
4821
4848
 
4822
- app.post('/api/runtime/shutdown', (req, res) => {
4823
- noStore(res);
4824
- recordSecurityAudit({
4825
- action: 'runtime.shutdown',
4826
- phase: 'requested',
4827
- result: 'accepted',
4828
- authMethod: req.liveDeskLauncherShutdown === true
4829
- ? 'launcher-internal-shutdown-token'
4830
- : 'local-admin-session'
4831
- });
4832
- runtimeManager.emit('runtime.shutdown.requested', { role: runtimeRole });
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 });
4833
4860
  res.json({ ok: true, shuttingDown: true, role: runtimeRole });
4834
4861
  setTimeout(() => {
4835
4862
  void shutdownHub('API_SHUTDOWN')
@@ -4866,10 +4893,10 @@ app.post('/api/auth/session', async (req, res) => {
4866
4893
  }
4867
4894
  const { access_token: accessToken, refresh_token: refreshToken, expires_at: expiresAt } = normalized.session;
4868
4895
  try {
4869
- const user = await verifySupabaseUser(accessToken);
4870
- runtimeSessionGeneration += 1;
4871
- runtimeRefreshPromise = null;
4872
- runtimeAccessToken = accessToken;
4896
+ const user = await verifySupabaseUser(accessToken);
4897
+ runtimeSessionGeneration += 1;
4898
+ runtimeRefreshPromise = null;
4899
+ runtimeAccessToken = accessToken;
4873
4900
  runtimeRefreshToken = refreshToken;
4874
4901
  runtimeAccessTokenExpiresAt = normalizeSessionExpiryMs(expiresAt);
4875
4902
  runtimeManager.setAuthenticated(true, user.id);
@@ -4882,29 +4909,29 @@ app.post('/api/auth/session', async (req, res) => {
4882
4909
  const hostTarget = runtimeRole === 'hub'
4883
4910
  ? await publishHubHostTargetWithPendingRoleTakeover('session-received')
4884
4911
  : { ok: true, active: false };
4885
- if (runtimeRole === 'hub') {
4886
- startHubHostTargetLeaseRenewal();
4887
- }
4888
- recordSecurityAudit({
4889
- action: 'auth.session.established',
4890
- phase: 'complete',
4891
- result: 'success',
4892
- actorAccountId: user.id,
4893
- actorUserId: user.id,
4894
- authMethod: 'supabase-access-refresh',
4895
- requestHash: requestAuditHash({ userId: user.id, expiresAt })
4896
- });
4897
- res.json({ ok: true, authenticated: true, persisted, userId: user.id, role: runtimeRole, hostTarget });
4898
- } catch (error) {
4899
- const message = error instanceof Error ? error.message : String(error);
4900
- recordSecurityAudit({
4901
- action: 'auth.session.establish',
4902
- phase: 'complete',
4903
- result: 'rejected',
4904
- reason: message,
4905
- authMethod: 'supabase-access-refresh',
4906
- requestHash: requestAuditHash({ hasAccessToken: Boolean(req.body?.accessToken || req.body?.access_token), hasRefreshToken: Boolean(req.body?.refreshToken || req.body?.refresh_token) })
4907
- });
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
+ });
4908
4935
  const status = authVerificationHttpStatus(message);
4909
4936
  res.status(status).json({ ok: false, error: message });
4910
4937
  }
@@ -5198,33 +5225,33 @@ function startHubHostTargetLeaseRenewal() {
5198
5225
  hubHostTargetRenewTimer.unref?.();
5199
5226
  }
5200
5227
 
5201
- app.delete('/api/auth/session', async (_req, res) => {
5202
- noStore(res);
5203
- const actorUserId = runtimeManager.getSnapshot().userId || '';
5204
- const hostTarget = runtimeRole === 'hub'
5205
- ? await clearHubHostTarget('logout')
5206
- : { ok: true, active: false };
5207
- const providerLogout = await revokeRuntimeProviderSession();
5208
- clearRuntimeSession();
5209
- recordSecurityAudit({
5210
- action: 'auth.session.revoked',
5211
- phase: 'complete',
5212
- result: providerLogout.ok ? 'success' : 'provider-error',
5213
- actorAccountId: actorUserId,
5214
- actorUserId,
5215
- reason: providerLogout.error || '',
5216
- authMethod: 'supabase-local-signout',
5217
- details: { localCleared: true, providerRevoked: providerLogout.revoked === true, alreadyInvalid: providerLogout.alreadyInvalid === true }
5218
- });
5219
- res.status(providerLogout.ok ? 200 : 502).json({
5220
- ok: providerLogout.ok,
5221
- authenticated: false,
5222
- localCleared: true,
5223
- role: runtimeRole,
5224
- hostTarget,
5225
- providerLogout
5226
- });
5227
- });
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
+ });
5228
5255
 
5229
5256
  app.get('/api/hub/status', (_req, res) => {
5230
5257
  noStore(res);
@@ -5571,37 +5598,37 @@ app.post('/api/remote/files/from-hub', requireHubFeatureAccess, (req, res) => {
5571
5598
  res.status(400).json({ ok: false, error: 'no-filesystem-items-selected' });
5572
5599
  return;
5573
5600
  }
5574
- const job = hubTransferJobs.create({
5575
- itemIds,
5576
- deviceIds,
5577
- remoteDirectory: req.body?.remoteDirectory,
5578
- onComplete: async ({ completed, job: completedJob }) => {
5579
- await recordSecurityAuditRequired({
5580
- action: 'remote.file.job',
5581
- phase: 'complete',
5582
- result: completed ? 'success' : 'failed',
5583
- reason: completed ? '' : completedJob?.error || 'file-transfer-failed',
5584
- deviceIds,
5585
- sessionId: completedJob?.jobId || '',
5586
- requestHash: requestAuditHash({
5587
- jobId: completedJob?.jobId || '',
5588
- deviceIds,
5589
- remoteDirectory: req.body?.remoteDirectory || ''
5590
- }),
5591
- authMethod: 'local-admin-session',
5592
- details: {
5593
- state: completedJob?.state || '',
5594
- totalFiles: Number(completedJob?.totalFiles || 0),
5595
- completedFiles: Number(completedJob?.completedFiles || 0),
5596
- totalBytes: Number(completedJob?.totalBytes || 0),
5597
- sentBytes: Number(completedJob?.sentBytes || 0),
5598
- failedTargets: Array.isArray(completedJob?.failedTargets)
5599
- ? completedJob.failedTargets.length
5600
- : 0
5601
- }
5602
- });
5603
- }
5604
- });
5601
+ const job = hubTransferJobs.create({
5602
+ itemIds,
5603
+ deviceIds,
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
+ }
5631
+ });
5605
5632
  res.status(202).json({ ok: true, jobId: job.jobId, state: job.state });
5606
5633
  } catch (error) {
5607
5634
  sendFilesystemError(res, error);
@@ -5682,80 +5709,80 @@ app.post('/api/remote/filesystem/shared-folders/:folderId/sync', requireHubFeatu
5682
5709
  }
5683
5710
  });
5684
5711
 
5685
- app.post('/api/remote/files/transfer', requireHubFeatureAccess, async (req, res) => {
5712
+ app.post('/api/remote/files/transfer', requireHubFeatureAccess, async (req, res) => {
5686
5713
  noStore(res);
5687
5714
  const deviceIds = normalizeDeviceIds(req.body?.deviceIds);
5688
- if (deviceIds.length === 0) {
5689
- recordSecurityAudit({ action: 'remote.file.transfer', phase: 'complete', result: 'rejected', reason: 'no-target-devices', authMethod: 'local-admin-session' });
5690
- res.status(400).json({ ok: false, error: 'no-target-devices' });
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' });
5691
5718
  return;
5692
5719
  }
5693
5720
  const normalized = normalizeTransferFiles(req.body?.files);
5694
- if (!normalized.ok) {
5695
- recordSecurityAudit({ action: 'remote.file.transfer', phase: 'complete', result: 'rejected', reason: normalized.error, deviceIds, authMethod: 'local-admin-session' });
5721
+ if (!normalized.ok) {
5722
+ recordSecurityAudit({ action: 'remote.file.transfer', phase: 'complete', result: 'rejected', reason: normalized.error, deviceIds, authMethod: 'local-admin-session' });
5696
5723
  res.status(400).json({ ok: false, error: normalized.error, totalBytes: normalized.totalBytes || 0 });
5697
5724
  return;
5698
5725
  }
5699
5726
  const transferId = String(req.body?.transferId || crypto.randomUUID()).replace(/[^a-zA-Z0-9_.:-]/g, '-').slice(0, 128);
5700
5727
  const remoteDirectory = String(req.body?.remoteDirectory || '').replace(/[\0\r\n\t]/g, ' ').trim().slice(0, 600);
5701
5728
  const queuedAt = new Date().toISOString();
5702
- try {
5703
- const results = await Promise.all(deviceIds.map(async deviceId => ({
5704
- deviceId,
5705
- ...await remoteHub.sendCommandAwaitResult(deviceId, {
5706
- command: 'file.transfer',
5707
- payload: {
5708
- transferId,
5709
- remoteDirectory,
5710
- files: normalized.files,
5711
- totalBytes: normalized.totalBytes,
5712
- requestedAt: queuedAt
5713
- }
5714
- }, { timeoutMs: fileTransferCommandAckTimeoutMs })
5715
- })));
5716
- const queued = results.filter(result => result.queued).length;
5717
- const acknowledged = results.filter(result => result.acknowledged).length;
5718
- const success = acknowledged === deviceIds.length;
5719
- const partial = acknowledged > 0 && !success;
5720
- const error = success ? undefined : partial ? 'partial-transfer-failed' : 'no-transfer-acknowledged';
5721
- await recordSecurityAuditRequired({
5722
- action: 'remote.file.transfer',
5723
- phase: 'complete',
5724
- result: success ? 'success' : partial ? 'partial' : 'rejected',
5725
- reason: error || '',
5726
- deviceIds,
5727
- sessionId: transferId,
5728
- requestHash: requestAuditHash({ transferId, remoteDirectory, files: normalized.files.map(file => ({ name: file.name, relativePath: file.relativePath, byteLength: file.size })), totalBytes: normalized.totalBytes }),
5729
- authMethod: 'local-admin-session',
5730
- details: { queued, acknowledged, total: deviceIds.length, totalBytes: normalized.totalBytes, files: normalized.files.length }
5731
- });
5732
- res.json({
5733
- ok: success,
5734
- transferId,
5735
- queued,
5736
- acknowledged,
5737
- total: deviceIds.length,
5738
- totalBytes: normalized.totalBytes,
5739
- files: normalized.files.length,
5740
- results,
5741
- error
5742
- });
5743
- } catch (error) {
5744
- res.status(503).json({ ok: false, error: error?.code || 'security-audit-unavailable' });
5745
- }
5746
- });
5747
-
5748
- app.post('/api/remote/files/chunk', requireHubFeatureAccess, async (req, res) => {
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
+ }
5773
+ });
5774
+
5775
+ app.post('/api/remote/files/chunk', requireHubFeatureAccess, async (req, res) => {
5749
5776
  noStore(res);
5750
5777
  const deviceIds = normalizeDeviceIds(req.body?.deviceIds);
5751
- if (deviceIds.length === 0) {
5752
- recordSecurityAudit({ action: 'remote.file.chunk', phase: 'complete', result: 'rejected', reason: 'no-target-devices', authMethod: 'local-admin-session' });
5778
+ if (deviceIds.length === 0) {
5779
+ recordSecurityAudit({ action: 'remote.file.chunk', phase: 'complete', result: 'rejected', reason: 'no-target-devices', authMethod: 'local-admin-session' });
5753
5780
  res.status(400).json({ ok: false, error: 'no-target-devices' });
5754
5781
  return;
5755
5782
  }
5756
5783
  const normalized = normalizeTransferChunk(req.body || {});
5757
- if (!normalized.ok) {
5758
- recordSecurityAudit({ action: 'remote.file.chunk', phase: 'complete', result: 'rejected', reason: normalized.error, deviceIds, authMethod: 'local-admin-session' });
5784
+ if (!normalized.ok) {
5785
+ recordSecurityAudit({ action: 'remote.file.chunk', phase: 'complete', result: 'rejected', reason: normalized.error, deviceIds, authMethod: 'local-admin-session' });
5759
5786
  res.status(400).json({ ok: false, error: normalized.error, byteLength: normalized.byteLength || 0 });
5760
5787
  return;
5761
5788
  }
@@ -5763,51 +5790,51 @@ app.post('/api/remote/files/chunk', requireHubFeatureAccess, async (req, res) =>
5763
5790
  const transferId = String(req.body?.transferId || crypto.randomUUID()).replace(/[^a-zA-Z0-9_.:-]/g, '-').slice(0, 128);
5764
5791
  const remoteDirectory = String(req.body?.remoteDirectory || '').replace(/[\0\r\n\t]/g, ' ').trim().slice(0, 600);
5765
5792
  const queuedAt = new Date().toISOString();
5766
- try {
5767
- const results = await Promise.all(deviceIds.map(async deviceId => ({
5768
- deviceId,
5769
- ...await remoteHub.sendCommandAwaitResult(deviceId, {
5770
- command: 'file.transfer.chunk',
5771
- payload: {
5772
- transferId,
5773
- remoteDirectory,
5774
- ...normalized.chunk,
5775
- requestedAt: queuedAt
5776
- }
5777
- }, { timeoutMs: fileTransferCommandAckTimeoutMs })
5778
- })));
5779
- const queued = results.filter(result => result.queued).length;
5780
- const acknowledged = results.filter(result => result.acknowledged).length;
5781
- const success = acknowledged === deviceIds.length;
5782
- const partial = acknowledged > 0 && !success;
5783
- const error = success ? undefined : partial ? 'partial-transfer-failed' : 'no-transfer-acknowledged';
5784
- await recordSecurityAuditRequired({
5785
- action: 'remote.file.chunk',
5786
- phase: normalized.chunk.final ? 'complete' : 'progress',
5787
- result: success ? 'success' : partial ? 'partial' : 'rejected',
5788
- reason: error || '',
5789
- deviceIds,
5790
- sessionId: transferId,
5791
- requestHash: requestAuditHash({ transferId, remoteDirectory, offset: normalized.chunk.offset, byteLength: normalized.chunk.byteLength, final: normalized.chunk.final }),
5792
- authMethod: 'local-admin-session',
5793
- details: { queued, acknowledged, total: deviceIds.length, byteLength: normalized.chunk.byteLength, offset: normalized.chunk.offset, final: normalized.chunk.final }
5794
- });
5795
- res.json({
5796
- ok: success,
5797
- transferId,
5798
- queued,
5799
- acknowledged,
5800
- total: deviceIds.length,
5801
- byteLength: normalized.chunk.byteLength,
5802
- offset: normalized.chunk.offset,
5803
- final: normalized.chunk.final,
5804
- results,
5805
- error
5806
- });
5807
- } catch (error) {
5808
- res.status(503).json({ ok: false, error: error?.code || 'security-audit-unavailable' });
5809
- }
5810
- });
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
+ }
5837
+ });
5811
5838
 
5812
5839
  const MANUAL_POWER_ACTIONS = new Set(['lock', 'sleep', 'restart', 'shutdown']);
5813
5840
 
@@ -6080,7 +6107,7 @@ app.post('/api/remote/devices/:deviceId/audio/stop', async (req, res, next) => {
6080
6107
  }
6081
6108
  });
6082
6109
 
6083
- if (existsSync(webIndexPath)) {
6110
+ if (existsSync(webIndexPath)) {
6084
6111
  app.use(express.static(webDistPath, {
6085
6112
  etag: true,
6086
6113
  index: false,
@@ -6093,70 +6120,70 @@ if (existsSync(webIndexPath)) {
6093
6120
  }
6094
6121
  res.sendFile(webIndexPath);
6095
6122
  });
6096
- }
6097
-
6098
- function isAuthorizedAdminWebSocket(req) {
6099
- if (!enforceLocalAdminAuth) return true;
6100
- const protocols = String(req.headers['sec-websocket-protocol'] || '')
6101
- .split(',')
6102
- .map(value => value.trim())
6103
- .filter(Boolean);
6104
- const adminToken = protocols.find(value => value.startsWith('livedesk-admin-'))?.slice('livedesk-admin-'.length) || '';
6105
- const csrfToken = protocols.find(value => value.startsWith('livedesk-admin-csrf-'))?.slice('livedesk-admin-csrf-'.length) || '';
6106
- return secureExactTestTokenMatches(adminToken, localAdminSessionToken)
6107
- && secureExactTestTokenMatches(csrfToken, localAdminCsrfToken);
6108
- }
6109
-
6110
- httpServer.on('upgrade', (req, socket, head) => {
6111
- if (!isTrustedBrowserRequest(req)) {
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)) {
6112
6139
  socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
6113
6140
  socket.destroy();
6114
- return;
6115
- }
6116
- if (!isAuthorizedAdminWebSocket(req)) {
6117
- recordSecurityAudit({
6118
- action: 'local-admin.websocket',
6119
- phase: 'complete',
6120
- result: 'rejected',
6121
- reason: 'admin-session-required',
6122
- requestHash: requestAuditHash({ path: req.url || '' }),
6123
- authMethod: 'local-admin-websocket'
6124
- });
6125
- socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
6126
- socket.destroy();
6127
- return;
6128
- }
6129
- let parsed;
6130
- try {
6131
- parsed = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
6132
- } catch {
6133
- socket.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
6134
- socket.destroy();
6135
- return;
6136
- }
6137
- const websocketPath = parsed.pathname;
6138
- if (![
6139
- '/api/remote/frames/ws',
6140
- '/api/remote/atlas/ws',
6141
- '/api/remote/input/ws',
6142
- '/api/remote/audio/ws'
6143
- ].includes(websocketPath)) {
6144
- socket.write('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n');
6145
- socket.destroy();
6146
- return;
6147
- }
6148
- void recordSecurityAuditRequired({
6149
- action: 'local-admin.websocket',
6150
- phase: 'accepted',
6151
- result: 'accepted',
6152
- sessionId: localAdminSessionId,
6153
- requestHash: requestAuditHash({ path: websocketPath }),
6154
- authMethod: 'local-admin-websocket',
6155
- details: { path: websocketPath }
6156
- }).then(() => {
6157
- if (parsed.pathname === '/api/remote/frames/ws') {
6158
- frameWss.handleUpgrade(req, socket, head, ws => frameWss.emit('connection', ws, req));
6159
- return;
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;
6157
+ try {
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(() => {
6184
+ if (parsed.pathname === '/api/remote/frames/ws') {
6185
+ frameWss.handleUpgrade(req, socket, head, ws => frameWss.emit('connection', ws, req));
6186
+ return;
6160
6187
  }
6161
6188
  if (parsed.pathname === '/api/remote/atlas/ws') {
6162
6189
  atlasWss.handleUpgrade(req, socket, head, ws => atlasWss.emit('connection', ws, req));
@@ -6167,14 +6194,14 @@ httpServer.on('upgrade', (req, socket, head) => {
6167
6194
  return;
6168
6195
  }
6169
6196
  if (parsed.pathname === '/api/remote/audio/ws') {
6170
- audioWss.handleUpgrade(req, socket, head, ws => audioWss.emit('connection', ws, req));
6171
- return;
6172
- }
6173
- }).catch(() => {
6174
- socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n');
6175
- socket.destroy();
6176
- });
6177
- });
6197
+ audioWss.handleUpgrade(req, socket, head, ws => audioWss.emit('connection', ws, req));
6198
+ return;
6199
+ }
6200
+ }).catch(() => {
6201
+ socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n');
6202
+ socket.destroy();
6203
+ });
6204
+ });
6178
6205
 
6179
6206
  frameWss.on('connection', (ws, req) => {
6180
6207
  frameClients.add(ws);
@@ -6286,22 +6313,22 @@ audioWss.on('connection', (ws, req) => {
6286
6313
  });
6287
6314
  });
6288
6315
 
6289
- inputWss.on('connection', ws => {
6290
- inputClients.add(ws);
6291
- ws.liveDeskInputClientId = `riws-${++inputClientSeq}`;
6292
- ws.liveDeskInputDeviceIds = new Set();
6293
- ws.liveDeskInputAuditReady = false;
6316
+ inputWss.on('connection', ws => {
6317
+ inputClients.add(ws);
6318
+ ws.liveDeskInputClientId = `riws-${++inputClientSeq}`;
6319
+ ws.liveDeskInputDeviceIds = new Set();
6320
+ ws.liveDeskInputAuditReady = false;
6294
6321
  try {
6295
6322
  ws._socket?.setNoDelay?.(true);
6296
6323
  } catch {
6297
6324
  // Best-effort latency hint for browser input sockets.
6298
- }
6299
- ws.on('message', data => {
6300
- if (ws.liveDeskInputAuditReady !== true) {
6301
- sendJson(ws, { type: 'RemoteInputError', error: 'security-audit-pending' });
6302
- return;
6303
- }
6304
- let payload;
6325
+ }
6326
+ ws.on('message', data => {
6327
+ if (ws.liveDeskInputAuditReady !== true) {
6328
+ sendJson(ws, { type: 'RemoteInputError', error: 'security-audit-pending' });
6329
+ return;
6330
+ }
6331
+ let payload;
6305
6332
  try {
6306
6333
  payload = JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data || ''));
6307
6334
  } catch {
@@ -6340,46 +6367,46 @@ inputWss.on('connection', ws => {
6340
6367
  timestamp: new Date().toISOString()
6341
6368
  });
6342
6369
  });
6343
- const releaseBrowserInputOwner = reason => {
6344
- if (ws.liveDeskInputReleased) return;
6345
- ws.liveDeskInputReleased = true;
6346
- inputClients.delete(ws);
6347
- for (const deviceId of ws.liveDeskInputDeviceIds || []) {
6348
- remoteHub.releaseInputOwner(deviceId, ws.liveDeskInputClientId, reason);
6349
- }
6350
- void recordSecurityAuditRequired({
6351
- action: 'remote.control.browser-session',
6352
- phase: 'complete',
6353
- result: 'success',
6354
- reason,
6355
- deviceIds: [...(ws.liveDeskInputDeviceIds || [])],
6356
- sessionId: ws.liveDeskInputClientId,
6357
- authMethod: 'local-admin-websocket'
6358
- }).catch(() => {
6359
- // The global fail-closed gate is marked by recordSecurityAuditRequired.
6360
- });
6361
- ws.liveDeskInputDeviceIds?.clear?.();
6362
- };
6363
- ws.on('close', () => releaseBrowserInputOwner('browser-input-websocket-closed'));
6364
- ws.on('error', () => releaseBrowserInputOwner('browser-input-websocket-error'));
6365
- void recordSecurityAuditRequired({
6366
- action: 'remote.control.browser-session',
6367
- phase: 'start',
6368
- result: 'success',
6369
- sessionId: ws.liveDeskInputClientId,
6370
- authMethod: 'local-admin-websocket'
6371
- }).then(() => {
6372
- ws.liveDeskInputAuditReady = true;
6373
- sendJson(ws, {
6374
- type: 'RemoteInputSocketReady',
6375
- protocol: 'livedesk.remote.input.json.v1',
6376
- clientId: ws.liveDeskInputClientId,
6377
- timestamp: new Date().toISOString()
6378
- });
6379
- }).catch(() => {
6380
- ws.close(1011, 'security-audit-unavailable');
6381
- });
6382
- });
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
+ });
6388
+ ws.liveDeskInputDeviceIds?.clear?.();
6389
+ };
6390
+ ws.on('close', () => releaseBrowserInputOwner('browser-input-websocket-closed'));
6391
+ ws.on('error', () => releaseBrowserInputOwner('browser-input-websocket-error'));
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');
6408
+ });
6409
+ });
6383
6410
 
6384
6411
  await remoteHub.start();
6385
6412
  connectedDeviceCount = Number(remoteHub.getStatus({ includeSecrets: false }).connectedDeviceCount || 0);
@@ -6473,8 +6500,8 @@ function shutdownHub(signal) {
6473
6500
  hubShutdownPromise = (async () => {
6474
6501
  const startedAt = Date.now();
6475
6502
  console.log(`[LiveDesk Hub] Shutdown started signal=${signal} grace=${hubShutdownGraceMs}ms timeout=${hubShutdownTimeoutMs}ms.`);
6476
- if (roleWatchTimer) clearInterval(roleWatchTimer);
6477
- clearInterval(captureRetentionTimer);
6503
+ if (roleWatchTimer) clearInterval(roleWatchTimer);
6504
+ clearInterval(captureRetentionTimer);
6478
6505
  clearInterval(browserWebSocketHeartbeatTimer);
6479
6506
  runSynchronousShutdownStep('update manager close', () => liveDeskUpdateManager?.close());
6480
6507
  atlasClients.clear();