@livedesk/hub 0.1.30 → 0.1.32

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,15 +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 { getAgentToolDefinition } 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';
44
45
  import { LiveDeskSettingsStore, SettingsConflictError } from './settings/settings-store.js';
45
46
  import { effectiveDevicePolicy } from './settings/settings-schema.js';
46
47
  import { buildEffectiveDevicePolicy } from './settings/effective-device-policy.js';
47
48
  import { CaptureStore } from './captures/capture-store.js';
48
49
  import { createLiveDeskUpdateManager } from './live-desk-update.js';
49
50
  import { createHubUdpTransport } from './transport/udp-hub-transport.js';
50
- import { normalizeRuntimeAuthSession, runtimeRoleError } from '../../runtime-core/src/index.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
53
  import { fetchAuthResponse, AUTH_REQUEST_TIMEOUT_MS } from '../../runtime-core/src/auth-http.js';
52
54
  import { PRODUCTION_SUPABASE_PUBLISHABLE_KEY, PRODUCTION_SUPABASE_URL } from '../../runtime-core/src/auth-config.js';
53
55
  import { createHubRuntime } from './runtime/hub-runtime.js';
@@ -63,8 +65,20 @@ const webDistCandidates = [
63
65
  const webDistPath = webDistCandidates.find(candidate => existsSync(resolve(candidate, 'index.html'))) || webDistCandidates[webDistCandidates.length - 1];
64
66
  const webIndexPath = resolve(webDistPath, 'index.html');
65
67
  const packageInfo = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf8'));
66
- const httpHost = process.env.LIVEDESK_HUB_HTTP_HOST || '127.0.0.1';
67
- const httpPort = Number(process.env.LIVEDESK_HUB_HTTP_PORT || process.env.PORT || 5179);
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
82
  const runtimeRole = String(process.env.LIVEDESK_RUNTIME_ROLE || 'hub').trim().toLowerCase() === 'client' ? 'client' : 'hub';
69
83
  const runtimeDeviceId = String(process.env.LIVEDESK_DEVICE_ID || '').trim();
70
84
  const runtimeDeviceName = String(process.env.LIVEDESK_DEVICE_NAME || os.hostname()).trim() || os.hostname();
@@ -165,13 +179,21 @@ const SUPABASE_AUTH_TIMEOUT_MS = process.env.LIVEDESK_AUTH_TEST_MODE === '1'
165
179
  ? readPositiveIntegerEnv('LIVEDESK_AUTH_TEST_TIMEOUT_MS', AUTH_REQUEST_TIMEOUT_MS)
166
180
  : AUTH_REQUEST_TIMEOUT_MS;
167
181
  const CLIENT_AUTH_STORAGE_KEY = 'livedesk.client.supabase.auth';
168
- const runtimeAuthStatePath = process.env.LIVEDESK_DESKTOP_HOST === '1'
182
+ const runtimeAuthStatePath = process.env.LIVEDESK_DESKTOP_HOST === '1'
169
183
  ? ''
170
- : String(process.env.LIVEDESK_AUTH_STATE_PATH || '').trim();
171
- 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'
172
191
  && ['ltd', 'pro'].includes(String(process.env.LIVEDESK_TEST_LICENSE_PLAN || '').toLowerCase())
173
- ? String(process.env.LIVEDESK_TEST_LICENSE_PLAN).toLowerCase()
174
- : '';
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
+ : '';
175
197
  const persistentSessionGcEnabled =
176
198
  process.env.LIVEDESK_TEST_MODE === '1'
177
199
  && process.env.LIVEDESK_PERSISTENT_SESSION_TEST_GC === '1';
@@ -181,10 +203,12 @@ const traceRemoteTestEventsEnabled =
181
203
  const persistentSessionGcToken = persistentSessionGcEnabled
182
204
  ? String(process.env.LIVEDESK_PERSISTENT_SESSION_TEST_TOKEN || '').trim()
183
205
  : '';
184
- let connectedDeviceCount = 0;
185
- let runtimeAccessToken = '';
186
- let runtimeRefreshToken = '';
187
- let runtimeAccessTokenExpiresAt = 0;
206
+ let connectedDeviceCount = 0;
207
+ let runtimeAccessToken = '';
208
+ let runtimeRefreshToken = '';
209
+ let runtimeAccessTokenExpiresAt = 0;
210
+ let runtimeSessionGeneration = 0;
211
+ let runtimeRefreshPromise = null;
188
212
  let roleWatchInFlight = false;
189
213
  let verifiedLicense = {
190
214
  userId: '',
@@ -196,8 +220,10 @@ let verifiedLicense = {
196
220
  let frameClientSeq = 0;
197
221
  let inputClientSeq = 0;
198
222
  let audioClientSeq = 0;
199
- let liveDeskUpdateManager = null;
200
- let hubTransferJobs = null;
223
+ let liveDeskUpdateManager = null;
224
+ let hubTransferJobs = null;
225
+ let securityAuditStore = null;
226
+ let securityAuditHealthy = true;
201
227
  const HUB_HOST_TARGET_LEASE_MS = Math.max(5000, readPositiveIntegerEnv('LIVEDESK_HOST_TARGET_LEASE_MS', 60_000));
202
228
  const HUB_HOST_TARGET_RENEW_MS = Math.max(1000, Math.min(
203
229
  readPositiveIntegerEnv('LIVEDESK_HOST_TARGET_RENEW_MS', 20_000),
@@ -228,7 +254,7 @@ let hubWakeNotificationState = {
228
254
  };
229
255
  let hubWakeNextAttemptAtMs = 0;
230
256
 
231
- function readPositiveIntegerEnv(name, fallback) {
257
+ function readPositiveIntegerEnv(name, fallback) {
232
258
  const value = Number(process.env[name]);
233
259
  return Number.isFinite(value) && value > 0 ? Math.round(value) : fallback;
234
260
  }
@@ -241,9 +267,42 @@ function secureExactTestTokenMatches(actual, expected) {
241
267
  && crypto.timingSafeEqual(actualBytes, expectedBytes);
242
268
  }
243
269
 
244
- function handleRemoteHubEvent(type, event) {
245
- liveDeskUpdateManager?.handleRemoteEvent(type, event);
246
- hubTransferJobs?.handleRemoteEvent(type, event);
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
+ }
247
306
  if (traceRemoteTestEventsEnabled
248
307
  && (type === 'RemoteFrameDropped' || type === 'RemoteFrameTransportProofAccepted')) {
249
308
  console.warn(`[LiveDesk Hub Test Event] ${type} ${JSON.stringify({
@@ -290,10 +349,16 @@ function handleRemoteHubEvent(type, event) {
290
349
  }
291
350
  return;
292
351
  }
293
- if (type === 'RemoteDeviceConnected' || type === 'RemoteDeviceDisconnected') {
294
- connectedDeviceCount = Number(remoteHub.getStatus({ includeSecrets: false }).connectedDeviceCount || 0);
295
- }
296
- 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') {
297
362
  return;
298
363
  }
299
364
  const deviceId = String(event?.deviceId || event?.device?.deviceId || '').trim();
@@ -425,11 +490,23 @@ function readRuntimeAuthState() {
425
490
  }
426
491
  }
427
492
 
428
- function readPersistedRuntimeSession(state = readRuntimeAuthState()) {
493
+ function readPersistedRuntimeSession(state = readRuntimeAuthState()) {
429
494
  try {
430
495
  const raw = state?.[CLIENT_AUTH_STORAGE_KEY];
431
496
  const session = typeof raw === 'string' ? JSON.parse(raw) : raw;
432
- return session && typeof session === 'object' ? session : null;
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 };
433
510
  } catch {
434
511
  return null;
435
512
  }
@@ -472,28 +549,34 @@ function persistRuntimeSession(session) {
472
549
  ...(session?.user && typeof session.user === 'object' ? session.user : {})
473
550
  }
474
551
  }, { requireRefreshToken: true });
475
- if (!normalized.ok) return false;
476
- state[CLIENT_AUTH_STORAGE_KEY] = JSON.stringify(normalized.session);
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);
477
557
  writePrivateRuntimeAuthState(state);
478
558
  return true;
479
559
  }
480
560
 
481
- function clearPersistedRuntimeSession() {
561
+ function clearPersistedRuntimeSession() {
482
562
  if (!runtimeAuthStatePath) return false;
483
563
  const state = readRuntimeAuthState();
484
- delete state[CLIENT_AUTH_STORAGE_KEY];
564
+ delete state[CLIENT_AUTH_STORAGE_KEY];
565
+ runtimeRefreshSecretStore.clear();
485
566
  return writePrivateRuntimeAuthState(state);
486
567
  }
487
568
 
488
- function clearRuntimeSession() {
489
- runtimeAccessToken = '';
490
- runtimeRefreshToken = '';
569
+ function clearRuntimeSession() {
570
+ runtimeSessionGeneration += 1;
571
+ runtimeRefreshPromise = null;
572
+ runtimeAccessToken = '';
573
+ runtimeRefreshToken = '';
491
574
  runtimeAccessTokenExpiresAt = 0;
492
575
  runtimeManager.setAuthenticated(false);
493
576
  try { clearPersistedRuntimeSession(); } catch { /* runtime auth is already invalid */ }
494
577
  }
495
578
 
496
- async function getRuntimeAccessToken() {
579
+ async function getRuntimeAccessToken() {
497
580
  const accessToken = String(runtimeAccessToken || '').trim();
498
581
  const expiresSoon = runtimeAccessTokenExpiresAt > 0
499
582
  && runtimeAccessTokenExpiresAt <= Date.now() + HUB_ACCESS_TOKEN_REFRESH_SKEW_MS;
@@ -502,51 +585,112 @@ async function getRuntimeAccessToken() {
502
585
  }
503
586
 
504
587
  const refreshToken = String(runtimeRefreshToken || '').trim();
505
- if (!refreshToken) {
506
- return accessToken;
507
- }
508
-
509
- const response = await fetchAuthResponse(
510
- fetch,
511
- `${supabaseUrl}/auth/v1/token?grant_type=refresh_token`,
512
- {
513
- method: 'POST',
514
- headers: {
515
- apikey: supabasePublishableKey,
516
- 'Content-Type': 'application/json',
517
- Accept: 'application/json'
518
- },
519
- body: JSON.stringify({ refresh_token: refreshToken })
520
- },
521
- SUPABASE_AUTH_TIMEOUT_MS
522
- );
523
- if (!response.ok) {
524
- if (response.status === 400 || response.status === 401 || response.status === 403) {
525
- clearRuntimeSession();
526
- throw new Error(`hub-session-refresh-failed:${response.status}`);
527
- }
528
- throw new Error(`hub-session-refresh-provider-failed:${response.status}`);
529
- }
530
- const refreshed = await response.json().catch(() => null);
531
- const nextAccessToken = String(refreshed?.access_token || '').trim();
532
- if (!nextAccessToken) {
533
- throw new Error('hub-session-refresh-missing-access-token');
534
- }
535
- runtimeAccessToken = nextAccessToken;
536
- runtimeRefreshToken = String(refreshed?.refresh_token || refreshToken).trim();
537
- runtimeAccessTokenExpiresAt = normalizeSessionExpiryMs(refreshed?.expires_at)
538
- || (Number(refreshed?.expires_in) > 0 ? Date.now() + Number(refreshed.expires_in) * 1000 : 0);
539
- try {
540
- persistRuntimeSession({
541
- access_token: runtimeAccessToken,
542
- refresh_token: runtimeRefreshToken,
543
- expires_at: Math.floor(runtimeAccessTokenExpiresAt / 1000)
544
- });
545
- } catch (error) {
546
- console.warn(`[LiveDesk Hub] Refreshed session persistence failed: ${error?.message || error}`);
547
- }
548
- return runtimeAccessToken;
549
- }
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
+ }
550
694
 
551
695
  async function verifySupabaseUser(accessToken) {
552
696
  const token = String(accessToken || '').trim();
@@ -659,15 +803,81 @@ function requireHubFeatureAccess(_req, res, next) {
659
803
  res.status(402).json({ ok: false, error: 'livedesk-plan-device-limit', license: licenseSnapshot() });
660
804
  }
661
805
 
662
- const agentDataDir = process.env.LIVEDESK_DATA_DIR || undefined;
663
- const liveDeskSettingsStore = new LiveDeskSettingsStore({ dataDir: agentDataDir });
664
- const captureStore = new CaptureStore({ dataDir: agentDataDir });
665
- void captureStore.initialize().catch(error => {
666
- console.warn(`[LiveDesk Hub] capture store initialization failed: ${error instanceof Error ? error.message : String(error)}`);
667
- });
668
- void liveDeskSettingsStore.getRecord().catch(error => {
669
- console.warn(`[LiveDesk Hub] settings load failed: ${error instanceof Error ? error.message : String(error)}`);
670
- });
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?.();
671
881
 
672
882
  const udpTransport = createHubUdpTransport({
673
883
  env: process.env,
@@ -675,9 +885,10 @@ const udpTransport = createHubUdpTransport({
675
885
  logWarn: (_scope, message) => console.warn(`[LiveDesk Hub] ${message}`)
676
886
  });
677
887
 
678
- const remoteHub = createRemoteHub({
679
- managerPackage: '@livedesk/hub',
680
- managerVersion: packageInfo.version,
888
+ const remoteHub = createRemoteHub({
889
+ managerPackage: '@livedesk/hub',
890
+ managerVersion: packageInfo.version,
891
+ dataDir: agentDataDir,
681
892
  env: {
682
893
  ...process.env,
683
894
  MINDEXEC_MANAGER_PACKAGE: '@livedesk/hub',
@@ -688,7 +899,11 @@ const remoteHub = createRemoteHub({
688
899
  logWarn: (_scope, message) => console.warn(`[LiveDesk Hub] ${message}`),
689
900
  emitEvent: handleRemoteHubEvent,
690
901
  emitFrame: broadcastRemoteBinaryFrame,
691
- emitAudio: broadcastRemoteBinaryAudio,
902
+ emitAudio: broadcastRemoteBinaryAudio,
903
+ getSecurityIdentity: () => ({
904
+ accountId: runtimeManager.getSnapshot().userId || testSecurityAccountId,
905
+ hubId: remoteHub?.getSecurityStatus?.().hubId || ''
906
+ }),
692
907
  getEffectiveDevicePolicy: ({ deviceId, capabilities }) => buildEffectiveDevicePolicy(liveDeskSettingsStore.getCached(), { deviceId, capabilities }),
693
908
  getWelcomeDevicePolicy: ({ deviceId, capabilities }) => {
694
909
  const policy = buildEffectiveDevicePolicy(liveDeskSettingsStore.getCached(), { deviceId, capabilities });
@@ -791,9 +1006,11 @@ function getLiveDeskUpdateStatus() {
791
1006
  if (process.env.LIVEDESK_DESKTOP_HOST !== '1') {
792
1007
  liveDeskUpdateManager = createLiveDeskUpdateManager({
793
1008
  remoteHub,
794
- currentManagerVersion: process.env.LIVEDESK_MANAGER_VERSION || packageInfo.version,
795
- currentClientVersion: process.env.LIVEDESK_CLIENT_PACKAGE_VERSION || '',
796
- 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(),
797
1014
  requestHubRestart
798
1015
  });
799
1016
  }
@@ -1111,7 +1328,7 @@ function validateAgentMcpArguments(name, args) {
1111
1328
  return '';
1112
1329
  }
1113
1330
 
1114
- async function dispatchAgentMcpTool(session, name, args = {}) {
1331
+ async function dispatchAgentMcpTool(session, name, args = {}) {
1115
1332
  if (session.cancelled || session.signal?.aborted) return { ok: false, error: 'cancelled-by-user' };
1116
1333
  // This check runs synchronously before the first await, so concurrent Node
1117
1334
  // requests cannot pass the same remaining budget and queue extra Client work.
@@ -1119,8 +1336,23 @@ async function dispatchAgentMcpTool(session, name, args = {}) {
1119
1336
  session.toolLimitReached = true;
1120
1337
  return { ok: false, error: 'codex-tool-limit-reached' };
1121
1338
  }
1122
- session.toolCallCount += 1;
1123
- 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);
1124
1356
  if (!tool) return { ok: false, error: 'codex-tool-not-allowed' };
1125
1357
  const validationError = validateAgentMcpArguments(name, args);
1126
1358
  if (validationError) return { ok: false, error: validationError };
@@ -1311,13 +1543,20 @@ async function synchronizeAgentEnablement() {
1311
1543
  return enabled;
1312
1544
  }
1313
1545
 
1314
- const hubFilesystem = createHubFilesystem();
1315
- hubTransferJobs = createHubTransferJobs({
1316
- filesystem: hubFilesystem,
1317
- remoteHub,
1318
- maxConcurrent: Number(process.env.LIVEDESK_MAX_FILE_JOBS || 2),
1319
- commandResultTimeoutMs: readPositiveIntegerEnv('LIVEDESK_FILE_CHUNK_ACK_TIMEOUT_MS', 30_000)
1320
- });
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
+ });
1321
1560
  const hubSharedFolders = createHubSharedFolders({
1322
1561
  filesystem: hubFilesystem,
1323
1562
  transferJobs: hubTransferJobs,
@@ -1437,16 +1676,125 @@ app.use((req, res, next) => {
1437
1676
  res.setHeader('Access-Control-Allow-Origin', origin);
1438
1677
  res.setHeader('Access-Control-Allow-Private-Network', 'true');
1439
1678
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
1440
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
1679
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-LiveDesk-Admin-Session, X-LiveDesk-Admin-CSRF, X-LiveDesk-CSRF');
1441
1680
  res.setHeader('Vary', 'Origin, Access-Control-Request-Headers, Access-Control-Request-Private-Network');
1442
1681
  }
1443
1682
  if (req.method === 'OPTIONS') {
1444
1683
  res.status(204).end();
1445
1684
  return;
1446
1685
  }
1447
- next();
1448
- });
1449
- app.use(express.json({ limit: '32mb' }));
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' }));
1450
1798
  app.use((req, res, next) => {
1451
1799
  if (runtimeRole === 'client' && req.path.startsWith('/api/remote')) {
1452
1800
  res.status(403).json(roleGuardResponse(runtimeRole, 'hub'));
@@ -1597,11 +1945,12 @@ function normalizeTransferFiles(value) {
1597
1945
  if (totalBytes > MAX_FILE_TRANSFER_BYTES) {
1598
1946
  return { ok: false, error: 'file-transfer-too-large', files: [], totalBytes };
1599
1947
  }
1600
- files.push({
1601
- name,
1602
- relativePath,
1603
- size: byteLength,
1604
- mimeType: String(entry?.type || entry?.mimeType || '').slice(0, 160),
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),
1605
1954
  lastModified: Number(entry?.lastModified || 0) || 0,
1606
1955
  dataBase64
1607
1956
  });
@@ -1612,24 +1961,39 @@ function normalizeTransferFiles(value) {
1612
1961
  return { ok: true, files, totalBytes };
1613
1962
  }
1614
1963
 
1615
- function normalizeTransferChunk(body = {}) {
1964
+ function normalizeTransferChunk(body = {}) {
1616
1965
  const dataBase64 = String(body.dataBase64 || '').replace(/^data:[^,]*,/i, '').trim();
1617
1966
  const name = String(body.name || '').replace(/[\r\n\t\0]/g, ' ').trim().slice(0, 240);
1618
1967
  const relativePath = String(body.relativePath || name).replace(/[\r\n\t\0]/g, ' ').trim().slice(0, 600);
1619
1968
  const offset = Math.max(0, Math.floor(Number(body.offset) || 0));
1620
1969
  const totalBytes = Math.max(0, Math.floor(Number(body.totalBytes) || 0));
1621
- const byteLength = dataBase64 ? Buffer.byteLength(dataBase64, 'base64') : 0;
1622
- const final = body.final === true;
1623
-
1624
- if (!name || !relativePath || offset > totalBytes || offset + byteLength > totalBytes) {
1625
- return { ok: false, error: 'invalid-file-transfer-chunk' };
1626
- }
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
+ }
1627
1982
  if (byteLength > MAX_FILE_TRANSFER_CHUNK_BYTES) {
1628
1983
  return { ok: false, error: 'file-transfer-chunk-too-large', byteLength };
1629
1984
  }
1630
- if (byteLength === 0 && !(final && totalBytes === 0 && offset === 0)) {
1631
- return { ok: false, error: 'empty-file-transfer-chunk' };
1632
- }
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
+ }
1633
1997
 
1634
1998
  return {
1635
1999
  ok: true,
@@ -1639,8 +2003,9 @@ function normalizeTransferChunk(body = {}) {
1639
2003
  offset,
1640
2004
  totalBytes,
1641
2005
  byteLength,
1642
- dataBase64,
1643
- final,
2006
+ dataBase64,
2007
+ final,
2008
+ sha256,
1644
2009
  mimeType: String(body.type || body.mimeType || '').slice(0, 160),
1645
2010
  lastModified: Number(body.lastModified || 0) || 0
1646
2011
  }
@@ -2746,6 +3111,37 @@ function updateFrameSubscription(ws, payload = {}) {
2746
3111
  }
2747
3112
  }
2748
3113
 
3114
+ function refreshFrameSubscriptionLive(ws, payload = {}) {
3115
+ if (!ws || ws.readyState !== 1) {
3116
+ return;
3117
+ }
3118
+ const requestedIds = normalizeDeviceIds(payload.deviceIds ?? payload.devices ?? payload.deviceId);
3119
+ const subscribedIds = [...(ws.liveDeskDeviceIds || [])];
3120
+ const targetIds = requestedIds.length > 0
3121
+ ? requestedIds.filter(deviceId => ws.liveDeskDeviceIds?.has?.(deviceId))
3122
+ : subscribedIds;
3123
+ if (targetIds.length === 0) {
3124
+ return;
3125
+ }
3126
+ ws.liveDeskAutoStart = /^(1|true|yes|on|live)$/i.test(String(
3127
+ payload.autoStartLive ?? payload.startLive ?? ws.liveDeskAutoStart ?? ''
3128
+ ));
3129
+ ws.liveDeskLiveOptions = normalizeLiveOptions({
3130
+ ...(ws.liveDeskLiveOptions || {}),
3131
+ ...payload,
3132
+ monitorSelections: {
3133
+ ...(ws.liveDeskLiveOptions?.monitorSelections || {}),
3134
+ ...(payload.monitorSelections || {})
3135
+ }
3136
+ });
3137
+ for (const deviceId of targetIds.slice(0, 80)) {
3138
+ startFrameSubscriptionLive(ws, 'watchdog', deviceId, {
3139
+ ...ws.liveDeskLiveOptions,
3140
+ reuseExisting: true
3141
+ });
3142
+ }
3143
+ }
3144
+
2749
3145
  function startFrameSubscriptionLive(
2750
3146
  ws,
2751
3147
  reason = 'subscribe',
@@ -3677,10 +4073,26 @@ function buildHubHealthPayload({
3677
4073
  };
3678
4074
  }
3679
4075
 
3680
- app.get('/api/health', (_req, res) => {
3681
- noStore(res);
3682
- res.json(buildHubHealthPayload());
3683
- });
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
+ });
3684
4096
 
3685
4097
  if (persistentSessionGcEnabled) {
3686
4098
  app.post('/api/test/persistent-session/gc', (req, res) => {
@@ -3733,9 +4145,18 @@ app.patch('/api/settings', async (req, res) => {
3733
4145
  const record = await liveDeskSettingsStore.update(patch, revision);
3734
4146
  if (patch.agent && Object.prototype.hasOwnProperty.call(patch.agent, 'enabled')) {
3735
4147
  await agentSettingsStore.update({ enabled: record.settings.agent?.enabled === true });
3736
- }
3737
- remoteHub.refreshDevicePolicies();
3738
- res.json({ ok: true, revision: record.revision, updatedAt: record.updatedAt, settings: record.settings });
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 });
3739
4160
  } catch (error) {
3740
4161
  if (error instanceof SettingsConflictError) {
3741
4162
  res.status(409).json({ ok: false, error: error.code, revision: error.settings.revision, updatedAt: error.settings.updatedAt, settings: error.settings.settings });
@@ -3890,7 +4311,7 @@ app.get('/api/settings/capabilities', async (_req, res) => {
3890
4311
  });
3891
4312
  });
3892
4313
 
3893
- app.get('/api/security/trusted-devices', (_req, res) => {
4314
+ app.get('/api/security/trusted-devices', (_req, res) => {
3894
4315
  noStore(res);
3895
4316
  const devices = remoteHub.listDevices({ includeDataUrl: false }).map(device => ({
3896
4317
  deviceId: device.deviceId,
@@ -3902,24 +4323,141 @@ app.get('/api/security/trusted-devices', (_req, res) => {
3902
4323
  lastSeenAt: device.lastSeenAt || '',
3903
4324
  ipRange: String(device.remoteAddress || '').replace(/^::ffff:/, '').split(':')[0]
3904
4325
  }));
3905
- res.json({ ok: true, devices });
3906
- });
3907
-
3908
- app.delete('/api/security/trusted-devices/:deviceId', (req, res) => {
3909
- noStore(res);
3910
- const result = remoteHub.disconnectDevice(req.params.deviceId, 'trusted-device-revoked');
3911
- res.json({ ok: result === true, revoked: result === true, deviceId: req.params.deviceId });
3912
- });
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
+ });
3913
4448
 
3914
4449
  app.post('/api/security/revoke-all', (_req, res) => {
3915
4450
  noStore(res);
3916
- const devices = remoteHub.listDevices({ includeDataUrl: false });
3917
- let revoked = 0;
3918
- for (const device of devices) {
3919
- if (device.connected && remoteHub.disconnectDevice(device.deviceId, 'trusted-devices-revoked')) revoked += 1;
3920
- }
3921
- res.json({ ok: true, revoked });
3922
- });
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
+ });
3923
4461
 
3924
4462
  app.get('/api/settings/agent', async (_req, res) => {
3925
4463
  noStore(res);
@@ -4281,9 +4819,17 @@ app.post('/api/runtime/restart', (_req, res) => {
4281
4819
  }, 150);
4282
4820
  });
4283
4821
 
4284
- app.post('/api/runtime/shutdown', (_req, res) => {
4285
- noStore(res);
4286
- runtimeManager.emit('runtime.shutdown.requested', { role: runtimeRole });
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 });
4287
4833
  res.json({ ok: true, shuttingDown: true, role: runtimeRole });
4288
4834
  setTimeout(() => {
4289
4835
  void shutdownHub('API_SHUTDOWN')
@@ -4320,8 +4866,10 @@ app.post('/api/auth/session', async (req, res) => {
4320
4866
  }
4321
4867
  const { access_token: accessToken, refresh_token: refreshToken, expires_at: expiresAt } = normalized.session;
4322
4868
  try {
4323
- const user = await verifySupabaseUser(accessToken);
4324
- runtimeAccessToken = accessToken;
4869
+ const user = await verifySupabaseUser(accessToken);
4870
+ runtimeSessionGeneration += 1;
4871
+ runtimeRefreshPromise = null;
4872
+ runtimeAccessToken = accessToken;
4325
4873
  runtimeRefreshToken = refreshToken;
4326
4874
  runtimeAccessTokenExpiresAt = normalizeSessionExpiryMs(expiresAt);
4327
4875
  runtimeManager.setAuthenticated(true, user.id);
@@ -4334,12 +4882,29 @@ app.post('/api/auth/session', async (req, res) => {
4334
4882
  const hostTarget = runtimeRole === 'hub'
4335
4883
  ? await publishHubHostTargetWithPendingRoleTakeover('session-received')
4336
4884
  : { ok: true, active: false };
4337
- if (runtimeRole === 'hub') {
4338
- startHubHostTargetLeaseRenewal();
4339
- }
4340
- res.json({ ok: true, authenticated: true, persisted, userId: user.id, role: runtimeRole, hostTarget });
4341
- } catch (error) {
4342
- const message = error instanceof Error ? error.message : String(error);
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
+ });
4343
4908
  const status = authVerificationHttpStatus(message);
4344
4909
  res.status(status).json({ ok: false, error: message });
4345
4910
  }
@@ -4633,14 +5198,33 @@ function startHubHostTargetLeaseRenewal() {
4633
5198
  hubHostTargetRenewTimer.unref?.();
4634
5199
  }
4635
5200
 
4636
- app.delete('/api/auth/session', async (_req, res) => {
4637
- noStore(res);
4638
- const hostTarget = runtimeRole === 'hub'
4639
- ? await clearHubHostTarget('logout')
4640
- : { ok: true, active: false };
4641
- clearRuntimeSession();
4642
- res.json({ ok: true, authenticated: false, role: runtimeRole, hostTarget });
4643
- });
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
+ });
4644
5228
 
4645
5229
  app.get('/api/hub/status', (_req, res) => {
4646
5230
  noStore(res);
@@ -4987,11 +5571,37 @@ app.post('/api/remote/files/from-hub', requireHubFeatureAccess, (req, res) => {
4987
5571
  res.status(400).json({ ok: false, error: 'no-filesystem-items-selected' });
4988
5572
  return;
4989
5573
  }
4990
- const job = hubTransferJobs.create({
4991
- itemIds,
4992
- deviceIds,
4993
- remoteDirectory: req.body?.remoteDirectory
4994
- });
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
+ });
4995
5605
  res.status(202).json({ ok: true, jobId: job.jobId, state: job.state });
4996
5606
  } catch (error) {
4997
5607
  sendFilesystemError(res, error);
@@ -5072,56 +5682,80 @@ app.post('/api/remote/filesystem/shared-folders/:folderId/sync', requireHubFeatu
5072
5682
  }
5073
5683
  });
5074
5684
 
5075
- app.post('/api/remote/files/transfer', requireHubFeatureAccess, (req, res) => {
5685
+ app.post('/api/remote/files/transfer', requireHubFeatureAccess, async (req, res) => {
5076
5686
  noStore(res);
5077
5687
  const deviceIds = normalizeDeviceIds(req.body?.deviceIds);
5078
- if (deviceIds.length === 0) {
5079
- res.status(400).json({ ok: false, error: 'no-target-devices' });
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' });
5080
5691
  return;
5081
5692
  }
5082
5693
  const normalized = normalizeTransferFiles(req.body?.files);
5083
- if (!normalized.ok) {
5694
+ if (!normalized.ok) {
5695
+ recordSecurityAudit({ action: 'remote.file.transfer', phase: 'complete', result: 'rejected', reason: normalized.error, deviceIds, authMethod: 'local-admin-session' });
5084
5696
  res.status(400).json({ ok: false, error: normalized.error, totalBytes: normalized.totalBytes || 0 });
5085
5697
  return;
5086
5698
  }
5087
5699
  const transferId = String(req.body?.transferId || crypto.randomUUID()).replace(/[^a-zA-Z0-9_.:-]/g, '-').slice(0, 128);
5088
5700
  const remoteDirectory = String(req.body?.remoteDirectory || '').replace(/[\0\r\n\t]/g, ' ').trim().slice(0, 600);
5089
5701
  const queuedAt = new Date().toISOString();
5090
- const results = deviceIds.map(deviceId => ({
5091
- deviceId,
5092
- ...remoteHub.sendCommand(deviceId, {
5093
- command: 'file.transfer',
5094
- payload: {
5095
- transferId,
5096
- remoteDirectory,
5097
- files: normalized.files,
5098
- totalBytes: normalized.totalBytes,
5099
- requestedAt: queuedAt
5100
- }
5101
- })
5102
- }));
5103
- const queued = results.filter(result => result.ok).length;
5104
- res.json({
5105
- ok: queued > 0,
5106
- transferId,
5107
- queued,
5108
- total: deviceIds.length,
5109
- totalBytes: normalized.totalBytes,
5110
- files: normalized.files.length,
5111
- results,
5112
- error: queued > 0 ? undefined : 'no-transfer-queued'
5113
- });
5114
- });
5115
-
5116
- app.post('/api/remote/files/chunk', requireHubFeatureAccess, (req, res) => {
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) => {
5117
5749
  noStore(res);
5118
5750
  const deviceIds = normalizeDeviceIds(req.body?.deviceIds);
5119
- if (deviceIds.length === 0) {
5751
+ if (deviceIds.length === 0) {
5752
+ recordSecurityAudit({ action: 'remote.file.chunk', phase: 'complete', result: 'rejected', reason: 'no-target-devices', authMethod: 'local-admin-session' });
5120
5753
  res.status(400).json({ ok: false, error: 'no-target-devices' });
5121
5754
  return;
5122
5755
  }
5123
5756
  const normalized = normalizeTransferChunk(req.body || {});
5124
- if (!normalized.ok) {
5757
+ if (!normalized.ok) {
5758
+ recordSecurityAudit({ action: 'remote.file.chunk', phase: 'complete', result: 'rejected', reason: normalized.error, deviceIds, authMethod: 'local-admin-session' });
5125
5759
  res.status(400).json({ ok: false, error: normalized.error, byteLength: normalized.byteLength || 0 });
5126
5760
  return;
5127
5761
  }
@@ -5129,31 +5763,51 @@ app.post('/api/remote/files/chunk', requireHubFeatureAccess, (req, res) => {
5129
5763
  const transferId = String(req.body?.transferId || crypto.randomUUID()).replace(/[^a-zA-Z0-9_.:-]/g, '-').slice(0, 128);
5130
5764
  const remoteDirectory = String(req.body?.remoteDirectory || '').replace(/[\0\r\n\t]/g, ' ').trim().slice(0, 600);
5131
5765
  const queuedAt = new Date().toISOString();
5132
- const results = deviceIds.map(deviceId => ({
5133
- deviceId,
5134
- ...remoteHub.sendCommand(deviceId, {
5135
- command: 'file.transfer.chunk',
5136
- payload: {
5137
- transferId,
5138
- remoteDirectory,
5139
- ...normalized.chunk,
5140
- requestedAt: queuedAt
5141
- }
5142
- })
5143
- }));
5144
- const queued = results.filter(result => result.ok).length;
5145
- res.json({
5146
- ok: queued > 0,
5147
- transferId,
5148
- queued,
5149
- total: deviceIds.length,
5150
- byteLength: normalized.chunk.byteLength,
5151
- offset: normalized.chunk.offset,
5152
- final: normalized.chunk.final,
5153
- results,
5154
- error: queued > 0 ? undefined : 'no-transfer-queued'
5155
- });
5156
- });
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
+ });
5157
5811
 
5158
5812
  const MANUAL_POWER_ACTIONS = new Set(['lock', 'sleep', 'restart', 'shutdown']);
5159
5813
 
@@ -5426,7 +6080,7 @@ app.post('/api/remote/devices/:deviceId/audio/stop', async (req, res, next) => {
5426
6080
  }
5427
6081
  });
5428
6082
 
5429
- if (existsSync(webIndexPath)) {
6083
+ if (existsSync(webIndexPath)) {
5430
6084
  app.use(express.static(webDistPath, {
5431
6085
  etag: true,
5432
6086
  index: false,
@@ -5439,19 +6093,70 @@ if (existsSync(webIndexPath)) {
5439
6093
  }
5440
6094
  res.sendFile(webIndexPath);
5441
6095
  });
5442
- }
5443
-
5444
- httpServer.on('upgrade', (req, socket, head) => {
5445
- if (!isTrustedBrowserRequest(req)) {
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)) {
5446
6112
  socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
5447
6113
  socket.destroy();
5448
- return;
5449
- }
5450
- try {
5451
- const parsed = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
5452
- if (parsed.pathname === '/api/remote/frames/ws') {
5453
- frameWss.handleUpgrade(req, socket, head, ws => frameWss.emit('connection', ws, req));
5454
- return;
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;
5455
6160
  }
5456
6161
  if (parsed.pathname === '/api/remote/atlas/ws') {
5457
6162
  atlasWss.handleUpgrade(req, socket, head, ws => atlasWss.emit('connection', ws, req));
@@ -5462,16 +6167,14 @@ httpServer.on('upgrade', (req, socket, head) => {
5462
6167
  return;
5463
6168
  }
5464
6169
  if (parsed.pathname === '/api/remote/audio/ws') {
5465
- audioWss.handleUpgrade(req, socket, head, ws => audioWss.emit('connection', ws, req));
5466
- return;
5467
- }
5468
- socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
5469
- socket.destroy();
5470
- } catch {
5471
- socket.write('HTTP/1.1 400 Bad Request\r\n\r\n');
5472
- socket.destroy();
5473
- }
5474
- });
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
+ });
5475
6178
 
5476
6179
  frameWss.on('connection', (ws, req) => {
5477
6180
  frameClients.add(ws);
@@ -5496,6 +6199,8 @@ frameWss.on('connection', (ws, req) => {
5496
6199
  const payload = JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data || ''));
5497
6200
  if (payload?.type === 'subscribe') {
5498
6201
  updateFrameSubscription(ws, payload);
6202
+ } else if (payload?.type === 'refresh-live') {
6203
+ refreshFrameSubscriptionLive(ws, payload);
5499
6204
  } else if (payload?.type === 'restart-live') {
5500
6205
  restartFrameSubscriptionLive(ws, payload);
5501
6206
  }
@@ -5581,17 +6286,22 @@ audioWss.on('connection', (ws, req) => {
5581
6286
  });
5582
6287
  });
5583
6288
 
5584
- inputWss.on('connection', ws => {
5585
- inputClients.add(ws);
5586
- ws.liveDeskInputClientId = `riws-${++inputClientSeq}`;
5587
- ws.liveDeskInputDeviceIds = new Set();
6289
+ inputWss.on('connection', ws => {
6290
+ inputClients.add(ws);
6291
+ ws.liveDeskInputClientId = `riws-${++inputClientSeq}`;
6292
+ ws.liveDeskInputDeviceIds = new Set();
6293
+ ws.liveDeskInputAuditReady = false;
5588
6294
  try {
5589
6295
  ws._socket?.setNoDelay?.(true);
5590
6296
  } catch {
5591
6297
  // Best-effort latency hint for browser input sockets.
5592
- }
5593
- ws.on('message', data => {
5594
- let payload;
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;
5595
6305
  try {
5596
6306
  payload = JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data || ''));
5597
6307
  } catch {
@@ -5630,22 +6340,46 @@ inputWss.on('connection', ws => {
5630
6340
  timestamp: new Date().toISOString()
5631
6341
  });
5632
6342
  });
5633
- const releaseBrowserInputOwner = reason => {
5634
- inputClients.delete(ws);
5635
- for (const deviceId of ws.liveDeskInputDeviceIds || []) {
5636
- remoteHub.releaseInputOwner(deviceId, ws.liveDeskInputClientId, reason);
5637
- }
5638
- ws.liveDeskInputDeviceIds?.clear?.();
5639
- };
5640
- ws.on('close', () => releaseBrowserInputOwner('browser-input-websocket-closed'));
5641
- ws.on('error', () => releaseBrowserInputOwner('browser-input-websocket-error'));
5642
- sendJson(ws, {
5643
- type: 'RemoteInputSocketReady',
5644
- protocol: 'livedesk.remote.input.json.v1',
5645
- clientId: ws.liveDeskInputClientId,
5646
- timestamp: new Date().toISOString()
5647
- });
5648
- });
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
+ });
5649
6383
 
5650
6384
  await remoteHub.start();
5651
6385
  connectedDeviceCount = Number(remoteHub.getStatus({ includeSecrets: false }).connectedDeviceCount || 0);
@@ -5739,7 +6473,8 @@ function shutdownHub(signal) {
5739
6473
  hubShutdownPromise = (async () => {
5740
6474
  const startedAt = Date.now();
5741
6475
  console.log(`[LiveDesk Hub] Shutdown started signal=${signal} grace=${hubShutdownGraceMs}ms timeout=${hubShutdownTimeoutMs}ms.`);
5742
- if (roleWatchTimer) clearInterval(roleWatchTimer);
6476
+ if (roleWatchTimer) clearInterval(roleWatchTimer);
6477
+ clearInterval(captureRetentionTimer);
5743
6478
  clearInterval(browserWebSocketHeartbeatTimer);
5744
6479
  runSynchronousShutdownStep('update manager close', () => liveDeskUpdateManager?.close());
5745
6480
  atlasClients.clear();