@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/package.json +32 -32
- package/src/remote-hub.js +728 -722
- package/src/server.js +1006 -979
- package/src/settings/settings-schema.js +251 -239
- package/src/settings/settings-store.js +82 -77
- package/src/transport/secure-direct-acceptor.js +440 -433
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
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
const
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
if (
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
res.
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
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();
|