@livedesk/hub 0.1.58 → 0.1.61
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 +1 -1
- package/src/auth/workspace-access.js +176 -0
- package/src/console-direct.js +329 -10
- package/src/console-direct.test.mjs +332 -3
- package/src/control-presentation-borrow-contract.test.mjs +999 -0
- package/src/http/hub-ui-session.js +155 -32
- package/src/live-stream-monitor-contract.js +195 -3
- package/src/remote-hub.js +108 -56
- package/src/server.js +1324 -396
- package/src/settings/settings-schema.js +25 -6
- package/src/settings/settings-store.js +9 -8
- package/src/wall-source-restart-contract.test.mjs +48 -0
- package/src/wall-source-restart-runtime.test.mjs +146 -0
package/src/server.js
CHANGED
|
@@ -30,7 +30,10 @@ import {
|
|
|
30
30
|
observeRemoteAudioStopConfirmation,
|
|
31
31
|
remoteAudioSubscriberOwns
|
|
32
32
|
} from './remote-audio-subscription-contract.mjs';
|
|
33
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
createReadOnlyControlPresentationReconcileCoordinator,
|
|
35
|
+
isReusedLiveStreamFrameReady
|
|
36
|
+
} from './live-stream-monitor-contract.js';
|
|
34
37
|
import { createLiveCaptureTransitionRetryCoordinator } from './live-capture-transition-retry.mjs';
|
|
35
38
|
import { buildMode4AtlasSessionKey, Mode4AtlasPool, planMode4AtlasInputTransitions } from './mode4-atlas-pool.js';
|
|
36
39
|
import { resolveMode4AtlasTileSize } from './mode4-atlas-sizing.js';
|
|
@@ -60,11 +63,18 @@ import { PRODUCTION_SUPABASE_PUBLISHABLE_KEY, PRODUCTION_SUPABASE_URL } from '..
|
|
|
60
63
|
import { createHubRuntime } from './runtime/hub-runtime.js';
|
|
61
64
|
import { hubRuntimeStatus } from './runtime/hub-runtime-status.js';
|
|
62
65
|
import { roleGuardResponse } from './http/hub-role-guard.js';
|
|
63
|
-
import {
|
|
64
|
-
clearHubUiSessionCookie,
|
|
65
|
-
createHubUiSessionAuthority,
|
|
66
|
-
serializeHubUiSessionCookie
|
|
67
|
-
} from './http/hub-ui-session.js';
|
|
66
|
+
import {
|
|
67
|
+
clearHubUiSessionCookie,
|
|
68
|
+
createHubUiSessionAuthority,
|
|
69
|
+
serializeHubUiSessionCookie
|
|
70
|
+
} from './http/hub-ui-session.js';
|
|
71
|
+
import {
|
|
72
|
+
resolveWorkspaceAccess,
|
|
73
|
+
WorkspaceAccessError,
|
|
74
|
+
workspaceEntitlementCanRequest,
|
|
75
|
+
workspaceRoleCanControl,
|
|
76
|
+
workspaceRoleCanRequest
|
|
77
|
+
} from './auth/workspace-access.js';
|
|
68
78
|
|
|
69
79
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
70
80
|
const webDistCandidates = [
|
|
@@ -176,7 +186,8 @@ const supabasePublishableKey = String(configuredSupabaseKey || PRODUCTION_SUPABA
|
|
|
176
186
|
const SUPABASE_AUTH_TIMEOUT_MS = process.env.LIVEDESK_AUTH_TEST_MODE === '1'
|
|
177
187
|
? readPositiveIntegerEnv('LIVEDESK_AUTH_TEST_TIMEOUT_MS', AUTH_REQUEST_TIMEOUT_MS)
|
|
178
188
|
: AUTH_REQUEST_TIMEOUT_MS;
|
|
179
|
-
const CLIENT_AUTH_STORAGE_KEY = 'livedesk.client.supabase.auth';
|
|
189
|
+
const CLIENT_AUTH_STORAGE_KEY = 'livedesk.client.supabase.auth';
|
|
190
|
+
const HUB_WORKSPACE_STORAGE_KEY = 'livedesk.hub.workspace';
|
|
180
191
|
const runtimeAuthStatePath = process.env.LIVEDESK_DESKTOP_HOST === '1'
|
|
181
192
|
? ''
|
|
182
193
|
: String(process.env.LIVEDESK_AUTH_STATE_PATH || '').trim();
|
|
@@ -194,10 +205,18 @@ const persistentSessionGcToken = persistentSessionGcEnabled
|
|
|
194
205
|
? String(process.env.LIVEDESK_PERSISTENT_SESSION_TEST_TOKEN || '').trim()
|
|
195
206
|
: '';
|
|
196
207
|
let connectedDeviceCount = 0;
|
|
197
|
-
let runtimeAccessToken = '';
|
|
198
|
-
let runtimeRefreshToken = '';
|
|
199
|
-
let runtimeAccessTokenExpiresAt = 0;
|
|
200
|
-
|
|
208
|
+
let runtimeAccessToken = '';
|
|
209
|
+
let runtimeRefreshToken = '';
|
|
210
|
+
let runtimeAccessTokenExpiresAt = 0;
|
|
211
|
+
let runtimeAuthEpoch = 0;
|
|
212
|
+
let runtimeAccessTokenRefreshOwner = null;
|
|
213
|
+
let runtimeWorkspaceAccess = null;
|
|
214
|
+
let workspaceAccessLastAttemptAt = 0;
|
|
215
|
+
let licenseRefreshLastAttemptAt = 0;
|
|
216
|
+
const hubUiSessionAuthority = createHubUiSessionAuthority({
|
|
217
|
+
onRevoke: (session, reason) => retireHubUiSessionSockets(session, reason)
|
|
218
|
+
});
|
|
219
|
+
const consoleProxyToken = crypto.randomBytes(32).toString('base64url');
|
|
201
220
|
let roleWatchInFlight = false;
|
|
202
221
|
let verifiedLicense = {
|
|
203
222
|
userId: '',
|
|
@@ -217,7 +236,16 @@ const HUB_HOST_TARGET_RENEW_MS = Math.max(1000, Math.min(
|
|
|
217
236
|
readPositiveIntegerEnv('LIVEDESK_HOST_TARGET_RENEW_MS', 20_000),
|
|
218
237
|
Math.max(1000, HUB_HOST_TARGET_LEASE_MS - 1000)
|
|
219
238
|
));
|
|
220
|
-
const HUB_ACCESS_TOKEN_REFRESH_SKEW_MS = 90_000;
|
|
239
|
+
const HUB_ACCESS_TOKEN_REFRESH_SKEW_MS = 90_000;
|
|
240
|
+
const WORKSPACE_ACCESS_REVERIFY_MS = Math.max(
|
|
241
|
+
30_000,
|
|
242
|
+
Math.min(5 * 60_000, readPositiveIntegerEnv('LIVEDESK_WORKSPACE_ACCESS_REVERIFY_MS', 60_000))
|
|
243
|
+
);
|
|
244
|
+
const LICENSE_REFRESH_INTERVAL_MS = Math.max(
|
|
245
|
+
30 * 60_000,
|
|
246
|
+
readPositiveIntegerEnv('LIVEDESK_LICENSE_REFRESH_INTERVAL_MS', 30 * 60_000)
|
|
247
|
+
);
|
|
248
|
+
const TEAM_UI_ACCESS_MAX_AGE_MS = 60_000;
|
|
221
249
|
const hubWakeBaseUrl = String(process.env.LIVEDESK_WAKE_URL || 'https://livedesk-wake.lovecrdm.workers.dev').trim();
|
|
222
250
|
const hubConsoleDirectSignalBaseUrl = String(
|
|
223
251
|
process.env.LIVEDESK_CONSOLE_SIGNAL_URL
|
|
@@ -284,6 +312,52 @@ function broadcastRemoteInputRouteState(deviceId, reason = '') {
|
|
|
284
312
|
}
|
|
285
313
|
}
|
|
286
314
|
|
|
315
|
+
function reconcileReadOnlyControlPresentationSubscribers(deviceId) {
|
|
316
|
+
const normalizedDeviceId = String(deviceId || '').trim();
|
|
317
|
+
if (!normalizedDeviceId) return;
|
|
318
|
+
const clients = new Set([
|
|
319
|
+
...(frameClientsByDeviceId.get(normalizedDeviceId) || []),
|
|
320
|
+
...frameWildcardClients
|
|
321
|
+
]);
|
|
322
|
+
for (const ws of clients) {
|
|
323
|
+
const liveOptions = ws.liveDeskLiveOptions;
|
|
324
|
+
if (ws.readyState !== 1
|
|
325
|
+
|| ws.liveDeskAutoStart !== true
|
|
326
|
+
|| !(ws.liveDeskDeviceIds instanceof Set)
|
|
327
|
+
|| !ws.liveDeskDeviceIds.has(normalizedDeviceId)
|
|
328
|
+
|| liveOptions?.allowReadOnlyControlBorrow !== true
|
|
329
|
+
|| String(liveOptions?.streamPurpose || '').trim().toLowerCase() !== 'wall') {
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
startFrameSubscriptionLive(ws, 'control-borrow-reconcile', normalizedDeviceId, {
|
|
333
|
+
...liveOptions,
|
|
334
|
+
forceRestart: false,
|
|
335
|
+
reuseExisting: true
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const readOnlyControlPresentationReconcileCoordinator =
|
|
341
|
+
createReadOnlyControlPresentationReconcileCoordinator({
|
|
342
|
+
onReconcile: reconcileReadOnlyControlPresentationSubscribers
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
function readRemoteLiveStreamEventPurpose(event) {
|
|
346
|
+
const explicitPurpose = String(event?.streamPurpose || '').trim().toLowerCase();
|
|
347
|
+
if (explicitPurpose) return explicitPurpose;
|
|
348
|
+
const commandId = String(event?.commandId || '').trim();
|
|
349
|
+
const activeStream = event?.device?.activeLiveStream;
|
|
350
|
+
if (!commandId || !activeStream) return '';
|
|
351
|
+
const pendingDescriptor = activeStream.pendingDescriptor;
|
|
352
|
+
if (String(pendingDescriptor?.commandId || '').trim() === commandId) {
|
|
353
|
+
return String(pendingDescriptor?.streamPurpose || '').trim().toLowerCase();
|
|
354
|
+
}
|
|
355
|
+
if (String(activeStream.commandId || '').trim() === commandId) {
|
|
356
|
+
return String(activeStream.streamPurpose || '').trim().toLowerCase();
|
|
357
|
+
}
|
|
358
|
+
return '';
|
|
359
|
+
}
|
|
360
|
+
|
|
287
361
|
function handleRemoteHubEvent(type, event) {
|
|
288
362
|
liveDeskUpdateManager?.handleRemoteEvent(type, event);
|
|
289
363
|
hubTransferJobs?.handleRemoteEvent(type, event);
|
|
@@ -338,9 +412,32 @@ function handleRemoteHubEvent(type, event) {
|
|
|
338
412
|
broadcastRemoteInputRouteState(deviceId, event?.reason || type);
|
|
339
413
|
return;
|
|
340
414
|
}
|
|
415
|
+
if (type === 'RemoteLiveStreamStarted'
|
|
416
|
+
|| type === 'RemoteLiveStreamOpened'
|
|
417
|
+
|| type === 'RemoteLiveStreamReady'
|
|
418
|
+
|| type === 'RemoteLiveStreamStopped') {
|
|
419
|
+
const liveStreamEventDeviceId = String(event?.deviceId || event?.device?.deviceId || '').trim();
|
|
420
|
+
const liveStreamEventPurpose = readRemoteLiveStreamEventPurpose(event);
|
|
421
|
+
if ((type === 'RemoteLiveStreamStarted' || type === 'RemoteLiveStreamOpened')
|
|
422
|
+
&& liveStreamEventPurpose === 'control') {
|
|
423
|
+
readOnlyControlPresentationReconcileCoordinator
|
|
424
|
+
.cancelForControlTransition(liveStreamEventDeviceId);
|
|
425
|
+
} else if (type === 'RemoteLiveStreamReady' && liveStreamEventPurpose === 'control') {
|
|
426
|
+
readOnlyControlPresentationReconcileCoordinator
|
|
427
|
+
.reconcileReadyControl(liveStreamEventDeviceId);
|
|
428
|
+
} else if (type === 'RemoteLiveStreamStopped'
|
|
429
|
+
&& liveStreamEventPurpose === 'control'
|
|
430
|
+
&& event?.captureStopConfirmed === true) {
|
|
431
|
+
readOnlyControlPresentationReconcileCoordinator
|
|
432
|
+
.scheduleAfterConfirmedStop(liveStreamEventDeviceId);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
341
435
|
if (type === 'RemoteDeviceConnected' || type === 'RemoteDeviceDisconnected') {
|
|
342
436
|
connectedDeviceCount = Number(remoteHub.getStatus({ includeSecrets: false }).connectedDeviceCount || 0);
|
|
343
437
|
const deviceId = String(event?.deviceId || event?.device?.deviceId || '').trim();
|
|
438
|
+
if (type === 'RemoteDeviceDisconnected') {
|
|
439
|
+
readOnlyControlPresentationReconcileCoordinator.cancelForControlTransition(deviceId);
|
|
440
|
+
}
|
|
344
441
|
broadcastRemoteInputRouteState(deviceId, event?.reason || type);
|
|
345
442
|
}
|
|
346
443
|
if (type !== 'RemoteDeviceConnected') {
|
|
@@ -368,7 +465,7 @@ function handleRemoteHubEvent(type, event) {
|
|
|
368
465
|
}
|
|
369
466
|
}
|
|
370
467
|
|
|
371
|
-
function activeLicensePlan() {
|
|
468
|
+
function activeLicensePlan() {
|
|
372
469
|
if (testLicensePlan) {
|
|
373
470
|
return testLicensePlan;
|
|
374
471
|
}
|
|
@@ -381,12 +478,22 @@ function activeLicensePlan() {
|
|
|
381
478
|
if (Number.isFinite(expiresAt) && expiresAt <= now) {
|
|
382
479
|
return 'free';
|
|
383
480
|
}
|
|
384
|
-
return verifiedLicense.plan === '
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
481
|
+
return verifiedLicense.plan === 'team'
|
|
482
|
+
? 'team'
|
|
483
|
+
: verifiedLicense.plan === 'pro'
|
|
484
|
+
? 'pro'
|
|
485
|
+
: verifiedLicense.plan === 'ltd'
|
|
486
|
+
? 'ltd'
|
|
487
|
+
: 'free';
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function activeDeviceLimit() {
|
|
491
|
+
const plan = activeLicensePlan();
|
|
492
|
+
return plan === 'team' || plan === 'pro'
|
|
493
|
+
? Number.POSITIVE_INFINITY
|
|
494
|
+
: plan === 'ltd'
|
|
495
|
+
? PLUS_DEVICE_LIMIT
|
|
496
|
+
: FREE_DEVICE_LIMIT;
|
|
390
497
|
}
|
|
391
498
|
|
|
392
499
|
function hasHubFeatureAccess() {
|
|
@@ -409,7 +516,7 @@ function hasHubFeatureAccessForRequest(req) {
|
|
|
409
516
|
return ids.size <= limit;
|
|
410
517
|
}
|
|
411
518
|
|
|
412
|
-
function licenseSnapshot() {
|
|
519
|
+
function licenseSnapshot() {
|
|
413
520
|
const plan = activeLicensePlan();
|
|
414
521
|
const limit = activeDeviceLimit();
|
|
415
522
|
return {
|
|
@@ -417,47 +524,80 @@ function licenseSnapshot() {
|
|
|
417
524
|
status: plan === 'free' ? 'free' : 'active',
|
|
418
525
|
deviceLimit: Number.isFinite(limit) ? limit : null,
|
|
419
526
|
connectedDeviceCount,
|
|
420
|
-
featureAccess: connectedDeviceCount <= limit,
|
|
421
|
-
|
|
527
|
+
featureAccess: connectedDeviceCount <= limit,
|
|
528
|
+
workspaceId: verifiedLicense.workspaceId || runtimeWorkspaceAccess?.workspaceId || '',
|
|
529
|
+
workspaceKind: verifiedLicense.workspaceKind || runtimeWorkspaceAccess?.workspaceKind || 'personal',
|
|
530
|
+
memberLimit: Number.isSafeInteger(verifiedLicense.memberLimit) ? verifiedLicense.memberLimit : 1,
|
|
531
|
+
commercialUse: verifiedLicense.commercialUse === true,
|
|
532
|
+
verifiedAt: verifiedLicense.verifiedAt ? new Date(verifiedLicense.verifiedAt).toISOString() : '',
|
|
422
533
|
expiresAt: verifiedLicense.expiresAt || ''
|
|
423
534
|
};
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function workspaceAccessSnapshot(access = runtimeWorkspaceAccess) {
|
|
538
|
+
if (!access?.workspaceId) return null;
|
|
539
|
+
return {
|
|
540
|
+
workspaceId: access.workspaceId,
|
|
541
|
+
name: access.workspaceName || '',
|
|
542
|
+
kind: access.workspaceKind || 'personal',
|
|
543
|
+
role: access.role || '',
|
|
544
|
+
membershipRevision: Math.max(0, Number(access.membershipRevision || 0)),
|
|
545
|
+
plan: access.plan || 'free',
|
|
546
|
+
status: access.entitlementStatus || 'inactive',
|
|
547
|
+
deviceLimit: access.deviceLimit,
|
|
548
|
+
memberLimit: access.memberLimit,
|
|
549
|
+
commercialUse: access.commercialUse === true,
|
|
550
|
+
verifiedAt: access.verifiedAt ? new Date(access.verifiedAt).toISOString() : ''
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function workspaceRemoteAccessActive(access = runtimeWorkspaceAccess) {
|
|
555
|
+
return Boolean(access?.workspaceId) && (access.workspaceKind !== 'team'
|
|
556
|
+
|| (access?.plan === 'team' && access?.entitlementStatus === 'active'));
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async function verifyWorkspaceAccess(accessToken, user, requestedWorkspaceId = '') {
|
|
560
|
+
return resolveWorkspaceAccess({
|
|
561
|
+
accessToken,
|
|
562
|
+
requestedWorkspaceId,
|
|
563
|
+
userId: user?.id || '',
|
|
564
|
+
supabaseUrl,
|
|
565
|
+
supabasePublishableKey,
|
|
566
|
+
fetchResponse: (url, options, timeoutMs) => fetchAuthResponse(fetch, url, options, timeoutMs),
|
|
567
|
+
timeoutMs: SUPABASE_AUTH_TIMEOUT_MS
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function applyVerifiedWorkspaceLicense(access) {
|
|
572
|
+
verifiedLicense = {
|
|
573
|
+
userId: access.userId || '',
|
|
574
|
+
workspaceId: access.workspaceId,
|
|
575
|
+
workspaceKind: access.workspaceKind,
|
|
576
|
+
plan: ['ltd', 'pro', 'team'].includes(access.plan) ? access.plan : 'free',
|
|
577
|
+
status: access.entitlementStatus === 'active' ? 'active' : 'inactive',
|
|
578
|
+
expiresAt: '',
|
|
579
|
+
memberLimit: access.memberLimit,
|
|
580
|
+
commercialUse: access.commercialUse === true,
|
|
581
|
+
verifiedAt: Date.now()
|
|
582
|
+
};
|
|
583
|
+
return licenseSnapshot();
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async function syncVerifiedLicense(accessToken, requestedWorkspaceId = '') {
|
|
587
|
+
const token = String(accessToken || '').trim();
|
|
428
588
|
if (!token) {
|
|
429
589
|
throw new Error('supabase-access-token-required');
|
|
430
|
-
}
|
|
431
|
-
const user = await verifySupabaseUser(token);
|
|
432
|
-
const
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
const
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
product_key: 'eq.livedesk',
|
|
442
|
-
limit: '1'
|
|
443
|
-
});
|
|
444
|
-
const entitlementResponse = await fetch(`${supabaseUrl}/rest/v1/livedesk_entitlements?${query}`, { headers });
|
|
445
|
-
if (!entitlementResponse.ok) {
|
|
446
|
-
throw new Error(`supabase-entitlement-query-failed:${entitlementResponse.status}`);
|
|
447
|
-
}
|
|
448
|
-
const rows = await entitlementResponse.json();
|
|
449
|
-
const entitlement = Array.isArray(rows) ? rows[0] : null;
|
|
450
|
-
const plan = entitlement?.plan === 'pro' ? 'pro' : entitlement?.plan === 'ltd' ? 'ltd' : 'free';
|
|
451
|
-
const status = entitlement?.status === 'active' ? 'active' : 'inactive';
|
|
452
|
-
verifiedLicense = {
|
|
453
|
-
userId,
|
|
454
|
-
plan,
|
|
455
|
-
status,
|
|
456
|
-
expiresAt: String(entitlement?.expires_at || ''),
|
|
457
|
-
verifiedAt: Date.now()
|
|
458
|
-
};
|
|
459
|
-
return licenseSnapshot();
|
|
460
|
-
}
|
|
590
|
+
}
|
|
591
|
+
const user = await verifySupabaseUser(token);
|
|
592
|
+
const selectedWorkspaceId = String(
|
|
593
|
+
requestedWorkspaceId
|
|
594
|
+
|| runtimeWorkspaceAccess?.workspaceId
|
|
595
|
+
|| readPersistedWorkspaceId()
|
|
596
|
+
|| ''
|
|
597
|
+
).trim();
|
|
598
|
+
const access = await verifyWorkspaceAccess(token, user, selectedWorkspaceId);
|
|
599
|
+
return applyVerifiedWorkspaceLicense(access);
|
|
600
|
+
}
|
|
461
601
|
|
|
462
602
|
function normalizeSessionExpiryMs(value) {
|
|
463
603
|
const numeric = Number(value || 0);
|
|
@@ -475,7 +615,7 @@ function readRuntimeAuthState() {
|
|
|
475
615
|
}
|
|
476
616
|
}
|
|
477
617
|
|
|
478
|
-
function readPersistedRuntimeSession(state = readRuntimeAuthState()) {
|
|
618
|
+
function readPersistedRuntimeSession(state = readRuntimeAuthState()) {
|
|
479
619
|
try {
|
|
480
620
|
const raw = state?.[CLIENT_AUTH_STORAGE_KEY];
|
|
481
621
|
const session = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
@@ -483,7 +623,12 @@ function readPersistedRuntimeSession(state = readRuntimeAuthState()) {
|
|
|
483
623
|
} catch {
|
|
484
624
|
return null;
|
|
485
625
|
}
|
|
486
|
-
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function readPersistedWorkspaceId(state = readRuntimeAuthState()) {
|
|
629
|
+
const workspaceId = String(state?.[HUB_WORKSPACE_STORAGE_KEY]?.workspaceId || '').trim();
|
|
630
|
+
return workspaceId.slice(0, 160);
|
|
631
|
+
}
|
|
487
632
|
|
|
488
633
|
function writePrivateRuntimeAuthState(state) {
|
|
489
634
|
if (!runtimeAuthStatePath) return false;
|
|
@@ -501,7 +646,7 @@ function writePrivateRuntimeAuthState(state) {
|
|
|
501
646
|
}
|
|
502
647
|
}
|
|
503
648
|
|
|
504
|
-
function persistRuntimeSession(session) {
|
|
649
|
+
function persistRuntimeSession(session) {
|
|
505
650
|
if (!runtimeAuthStatePath) return false;
|
|
506
651
|
const state = readRuntimeAuthState();
|
|
507
652
|
const previous = readPersistedRuntimeSession(state) || {};
|
|
@@ -526,25 +671,43 @@ function persistRuntimeSession(session) {
|
|
|
526
671
|
state[CLIENT_AUTH_STORAGE_KEY] = JSON.stringify(normalized.session);
|
|
527
672
|
writePrivateRuntimeAuthState(state);
|
|
528
673
|
return true;
|
|
529
|
-
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function persistRuntimeWorkspace(access) {
|
|
677
|
+
const workspaceId = String(access?.workspaceId || '').trim();
|
|
678
|
+
if (!runtimeAuthStatePath || !workspaceId) return false;
|
|
679
|
+
const state = readRuntimeAuthState();
|
|
680
|
+
state[HUB_WORKSPACE_STORAGE_KEY] = {
|
|
681
|
+
workspaceId,
|
|
682
|
+
workspaceKind: String(access?.workspaceKind || 'personal'),
|
|
683
|
+
selectedAt: new Date().toISOString()
|
|
684
|
+
};
|
|
685
|
+
writePrivateRuntimeAuthState(state);
|
|
686
|
+
return true;
|
|
687
|
+
}
|
|
530
688
|
|
|
531
|
-
function clearPersistedRuntimeSession() {
|
|
689
|
+
function clearPersistedRuntimeSession() {
|
|
532
690
|
if (!runtimeAuthStatePath) return false;
|
|
533
|
-
const state = readRuntimeAuthState();
|
|
534
|
-
delete state[CLIENT_AUTH_STORAGE_KEY];
|
|
535
|
-
|
|
536
|
-
|
|
691
|
+
const state = readRuntimeAuthState();
|
|
692
|
+
delete state[CLIENT_AUTH_STORAGE_KEY];
|
|
693
|
+
delete state[HUB_WORKSPACE_STORAGE_KEY];
|
|
694
|
+
return writePrivateRuntimeAuthState(state);
|
|
695
|
+
}
|
|
537
696
|
|
|
538
|
-
function clearRuntimeSession() {
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
697
|
+
function clearRuntimeSession() {
|
|
698
|
+
runtimeAuthEpoch += 1;
|
|
699
|
+
runtimeAccessToken = '';
|
|
700
|
+
runtimeRefreshToken = '';
|
|
701
|
+
runtimeAccessTokenExpiresAt = 0;
|
|
702
|
+
runtimeWorkspaceAccess = null;
|
|
703
|
+
workspaceAccessLastAttemptAt = 0;
|
|
704
|
+
licenseRefreshLastAttemptAt = 0;
|
|
542
705
|
hubUiSessionAuthority.revokeAll();
|
|
543
706
|
runtimeManager.setAuthenticated(false);
|
|
544
707
|
try { clearPersistedRuntimeSession(); } catch { /* runtime auth is already invalid */ }
|
|
545
708
|
}
|
|
546
709
|
|
|
547
|
-
async function getRuntimeAccessToken() {
|
|
710
|
+
async function getRuntimeAccessToken() {
|
|
548
711
|
const accessToken = String(runtimeAccessToken || '').trim();
|
|
549
712
|
const expiresSoon = runtimeAccessTokenExpiresAt > 0
|
|
550
713
|
&& runtimeAccessTokenExpiresAt <= Date.now() + HUB_ACCESS_TOKEN_REFRESH_SKEW_MS;
|
|
@@ -560,52 +723,74 @@ async function getRuntimeAccessToken() {
|
|
|
560
723
|
return accessToken;
|
|
561
724
|
}
|
|
562
725
|
|
|
563
|
-
const refreshToken = String(runtimeRefreshToken || '').trim();
|
|
564
|
-
if (!refreshToken) {
|
|
565
|
-
return accessToken;
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
726
|
+
const refreshToken = String(runtimeRefreshToken || '').trim();
|
|
727
|
+
if (!refreshToken) {
|
|
728
|
+
return accessToken;
|
|
729
|
+
}
|
|
730
|
+
if (runtimeAccessTokenRefreshOwner?.epoch === runtimeAuthEpoch
|
|
731
|
+
&& runtimeAccessTokenRefreshOwner.refreshToken === refreshToken) {
|
|
732
|
+
return runtimeAccessTokenRefreshOwner.promise;
|
|
733
|
+
}
|
|
734
|
+
const owner = {
|
|
735
|
+
epoch: runtimeAuthEpoch,
|
|
736
|
+
userId: currentBoundHubUserId(),
|
|
737
|
+
workspaceId: currentBoundHubWorkspaceId(),
|
|
738
|
+
refreshToken,
|
|
739
|
+
promise: null
|
|
740
|
+
};
|
|
741
|
+
owner.promise = (async () => {
|
|
742
|
+
const response = await fetchAuthResponse(
|
|
743
|
+
fetch,
|
|
744
|
+
`${supabaseUrl}/auth/v1/token?grant_type=refresh_token`,
|
|
745
|
+
{
|
|
746
|
+
method: 'POST',
|
|
747
|
+
headers: {
|
|
748
|
+
apikey: supabasePublishableKey,
|
|
749
|
+
'Content-Type': 'application/json',
|
|
750
|
+
Accept: 'application/json'
|
|
751
|
+
},
|
|
752
|
+
body: JSON.stringify({ refresh_token: refreshToken })
|
|
753
|
+
},
|
|
754
|
+
SUPABASE_AUTH_TIMEOUT_MS
|
|
755
|
+
);
|
|
756
|
+
if (!response.ok) {
|
|
757
|
+
if (response.status === 400 || response.status === 401 || response.status === 403) {
|
|
758
|
+
if (runtimeAuthEpoch === owner.epoch && runtimeRefreshToken === owner.refreshToken) {
|
|
759
|
+
clearRuntimeSession();
|
|
760
|
+
}
|
|
761
|
+
throw new Error(`hub-session-refresh-failed:${response.status}`);
|
|
762
|
+
}
|
|
763
|
+
throw new Error(`hub-session-refresh-provider-failed:${response.status}`);
|
|
764
|
+
}
|
|
765
|
+
const refreshed = await response.json().catch(() => null);
|
|
766
|
+
const nextAccessToken = String(refreshed?.access_token || '').trim();
|
|
767
|
+
if (!nextAccessToken) throw new Error('hub-session-refresh-missing-access-token');
|
|
768
|
+
if (runtimeAuthEpoch !== owner.epoch
|
|
769
|
+
|| runtimeRefreshToken !== owner.refreshToken
|
|
770
|
+
|| currentBoundHubUserId() !== owner.userId
|
|
771
|
+
|| currentBoundHubWorkspaceId() !== owner.workspaceId) {
|
|
772
|
+
throw new Error('hub-session-refresh-superseded');
|
|
773
|
+
}
|
|
774
|
+
runtimeAccessToken = nextAccessToken;
|
|
775
|
+
runtimeRefreshToken = String(refreshed?.refresh_token || refreshToken).trim();
|
|
776
|
+
runtimeAccessTokenExpiresAt = normalizeSessionExpiryMs(refreshed?.expires_at)
|
|
777
|
+
|| (Number(refreshed?.expires_in) > 0 ? Date.now() + Number(refreshed.expires_in) * 1000 : 0);
|
|
778
|
+
try {
|
|
779
|
+
persistRuntimeSession({
|
|
780
|
+
access_token: runtimeAccessToken,
|
|
781
|
+
refresh_token: runtimeRefreshToken,
|
|
782
|
+
expires_at: Math.floor(runtimeAccessTokenExpiresAt / 1000)
|
|
783
|
+
});
|
|
784
|
+
} catch (error) {
|
|
785
|
+
console.warn(`[VuvoDesk Hub] Refreshed session persistence failed: ${error?.message || error}`);
|
|
786
|
+
}
|
|
787
|
+
return runtimeAccessToken;
|
|
788
|
+
})().finally(() => {
|
|
789
|
+
if (runtimeAccessTokenRefreshOwner === owner) runtimeAccessTokenRefreshOwner = null;
|
|
790
|
+
});
|
|
791
|
+
runtimeAccessTokenRefreshOwner = owner;
|
|
792
|
+
return owner.promise;
|
|
793
|
+
}
|
|
609
794
|
|
|
610
795
|
async function verifySupabaseUser(accessToken) {
|
|
611
796
|
const token = String(accessToken || '').trim();
|
|
@@ -637,21 +822,51 @@ async function verifySupabaseUser(accessToken) {
|
|
|
637
822
|
};
|
|
638
823
|
}
|
|
639
824
|
|
|
640
|
-
function authVerificationHttpStatus(message) {
|
|
825
|
+
function authVerificationHttpStatus(message) {
|
|
641
826
|
const upstreamStatus = Number(String(message || '').match(/supabase-user-verification-failed:(\d+)/)?.[1] || 0);
|
|
642
827
|
return upstreamStatus === 401 || upstreamStatus === 403 ? 401 : 502;
|
|
643
828
|
}
|
|
644
829
|
|
|
645
|
-
async function
|
|
646
|
-
const
|
|
647
|
-
if (
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
830
|
+
async function queryRuntimeDeviceWorkspace(accessToken) {
|
|
831
|
+
const token = String(accessToken || '').trim();
|
|
832
|
+
if (!token || !runtimeDeviceId) return '';
|
|
833
|
+
const query = new URLSearchParams({
|
|
834
|
+
select: 'workspace_id,role,enabled',
|
|
835
|
+
device_id: `eq.${runtimeDeviceId}`,
|
|
836
|
+
enabled: 'eq.true',
|
|
837
|
+
limit: '1'
|
|
838
|
+
});
|
|
839
|
+
const response = await fetchAuthResponse(
|
|
840
|
+
fetch,
|
|
841
|
+
`${supabaseUrl}/rest/v1/livedesk_devices?${query}`,
|
|
842
|
+
{
|
|
843
|
+
headers: {
|
|
844
|
+
apikey: supabasePublishableKey,
|
|
845
|
+
Authorization: `Bearer ${token}`,
|
|
846
|
+
Accept: 'application/json'
|
|
847
|
+
}
|
|
848
|
+
},
|
|
849
|
+
SUPABASE_AUTH_TIMEOUT_MS
|
|
850
|
+
);
|
|
851
|
+
if (response.status === 401 || response.status === 403) {
|
|
852
|
+
throw new Error(`hub-device-workspace-query-not-authenticated:${response.status}`);
|
|
853
|
+
}
|
|
854
|
+
if (!response.ok) return '';
|
|
855
|
+
const rows = await response.json().catch(() => []);
|
|
856
|
+
const record = Array.isArray(rows) ? rows[0] : null;
|
|
857
|
+
return record?.enabled === false ? '' : String(record?.workspace_id || '').trim();
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
async function queryAuthoritativeRuntimeDevice() {
|
|
861
|
+
const accessToken = await getRuntimeAccessToken();
|
|
862
|
+
if (runtimeRole !== 'hub' || !accessToken || !runtimeDeviceId) {
|
|
863
|
+
return { role: '', workspaceId: '' };
|
|
864
|
+
}
|
|
865
|
+
const query = new URLSearchParams({
|
|
866
|
+
select: 'workspace_id,role,enabled,role_version,assigned_hub_id',
|
|
867
|
+
device_id: `eq.${runtimeDeviceId}`,
|
|
868
|
+
limit: '1'
|
|
869
|
+
});
|
|
655
870
|
const response = await fetchAuthResponse(
|
|
656
871
|
fetch,
|
|
657
872
|
`${supabaseUrl}/rest/v1/livedesk_devices?${query}`,
|
|
@@ -664,38 +879,149 @@ async function queryAuthoritativeRuntimeRole() {
|
|
|
664
879
|
},
|
|
665
880
|
SUPABASE_AUTH_TIMEOUT_MS
|
|
666
881
|
);
|
|
667
|
-
if (response.status === 401 || response.status === 403) {
|
|
668
|
-
|
|
669
|
-
throw new Error(`hub-role-query-not-authenticated:${response.status}`);
|
|
882
|
+
if (response.status === 401 || response.status === 403) {
|
|
883
|
+
throw new Error(`hub-role-query-not-authenticated:${response.status}`);
|
|
670
884
|
}
|
|
671
885
|
if (!response.ok) {
|
|
672
886
|
throw new Error(`hub-role-query-failed:${response.status}`);
|
|
673
887
|
}
|
|
674
888
|
const rows = await response.json().catch(() => []);
|
|
675
889
|
const record = Array.isArray(rows) ? rows[0] : null;
|
|
676
|
-
if (!record || record.enabled === false) {
|
|
677
|
-
return '';
|
|
678
|
-
}
|
|
679
|
-
const role = String(record.role || '').trim().toLowerCase();
|
|
680
|
-
return
|
|
681
|
-
|
|
890
|
+
if (!record || record.enabled === false) {
|
|
891
|
+
return { role: '', workspaceId: '' };
|
|
892
|
+
}
|
|
893
|
+
const role = String(record.role || '').trim().toLowerCase();
|
|
894
|
+
return {
|
|
895
|
+
role: role === 'hub' || role === 'client' ? role : '',
|
|
896
|
+
workspaceId: String(record.workspace_id || '').trim()
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
async function queryAuthoritativeRuntimeRole() {
|
|
901
|
+
return (await queryAuthoritativeRuntimeDevice()).role;
|
|
902
|
+
}
|
|
682
903
|
|
|
683
|
-
async function watchAuthoritativeRuntimeRole() {
|
|
904
|
+
async function watchAuthoritativeRuntimeRole() {
|
|
684
905
|
if (runtimeRole !== 'hub' || roleWatchInFlight || !runtimeAccessToken) {
|
|
685
906
|
return;
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
907
|
+
}
|
|
908
|
+
const watchAuthEpoch = runtimeAuthEpoch;
|
|
909
|
+
const watchUserId = currentBoundHubUserId();
|
|
910
|
+
const watchWorkspaceId = currentBoundHubWorkspaceId();
|
|
911
|
+
roleWatchInFlight = true;
|
|
912
|
+
try {
|
|
913
|
+
const authoritativeDevice = await queryAuthoritativeRuntimeDevice();
|
|
914
|
+
if (runtimeAuthEpoch !== watchAuthEpoch
|
|
915
|
+
|| currentBoundHubUserId() !== watchUserId
|
|
916
|
+
|| currentBoundHubWorkspaceId() !== watchWorkspaceId) return;
|
|
917
|
+
const role = authoritativeDevice.role;
|
|
690
918
|
if (role === 'client') {
|
|
691
919
|
console.warn('[VuvoDesk Hub] Supabase selected another Sync Server. Transitioning this runtime to Client.');
|
|
692
920
|
void clearHubHostTarget('role-demoted').finally(() => {
|
|
693
|
-
setTimeout(() => process.exit(ROLE_TRANSITION_EXIT_CODE), 150);
|
|
694
|
-
});
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
921
|
+
setTimeout(() => process.exit(ROLE_TRANSITION_EXIT_CODE), 150);
|
|
922
|
+
});
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
if (authoritativeDevice.workspaceId
|
|
926
|
+
&& authoritativeDevice.workspaceId !== currentBoundHubWorkspaceId()) {
|
|
927
|
+
const token = await getRuntimeAccessToken();
|
|
928
|
+
const userId = currentBoundHubUserId();
|
|
929
|
+
const movedAccess = await verifyWorkspaceAccess(
|
|
930
|
+
token,
|
|
931
|
+
{ id: userId },
|
|
932
|
+
authoritativeDevice.workspaceId
|
|
933
|
+
);
|
|
934
|
+
if (movedAccess.userId !== userId || movedAccess.role !== 'owner') {
|
|
935
|
+
throw new WorkspaceAccessError('hub-device-workspace-mismatch', { status: 403 });
|
|
936
|
+
}
|
|
937
|
+
if (runtimeAuthEpoch !== watchAuthEpoch
|
|
938
|
+
|| currentBoundHubUserId() !== watchUserId
|
|
939
|
+
|| currentBoundHubWorkspaceId() !== watchWorkspaceId) return;
|
|
940
|
+
await clearHubHostTarget('hub-workspace-moved');
|
|
941
|
+
if (runtimeAuthEpoch !== watchAuthEpoch
|
|
942
|
+
|| currentBoundHubUserId() !== watchUserId
|
|
943
|
+
|| currentBoundHubWorkspaceId() !== watchWorkspaceId) return;
|
|
944
|
+
hubUiSessionAuthority.revokeAll('hub-workspace-moved');
|
|
945
|
+
runtimeAuthEpoch += 1;
|
|
946
|
+
runtimeWorkspaceAccess = movedAccess;
|
|
947
|
+
try { persistRuntimeWorkspace(movedAccess); } catch {}
|
|
948
|
+
applyVerifiedWorkspaceLicense(movedAccess);
|
|
949
|
+
licenseRefreshLastAttemptAt = Date.now();
|
|
950
|
+
workspaceAccessLastAttemptAt = Date.now();
|
|
951
|
+
hubConsoleDirect?.invalidateWorkspaceAccess('hub-workspace-moved');
|
|
952
|
+
if (workspaceRemoteAccessActive(movedAccess)) {
|
|
953
|
+
scheduleAuthenticatedHubHostTargetPublication('hub-workspace-moved');
|
|
954
|
+
}
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
const now = Date.now();
|
|
958
|
+
if (now - workspaceAccessLastAttemptAt < WORKSPACE_ACCESS_REVERIFY_MS) {
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
workspaceAccessLastAttemptAt = now;
|
|
962
|
+
const token = await getRuntimeAccessToken();
|
|
963
|
+
const userId = currentBoundHubUserId();
|
|
964
|
+
const previousAccess = runtimeWorkspaceAccess;
|
|
965
|
+
const access = await verifyWorkspaceAccess(token, { id: userId }, currentBoundHubWorkspaceId());
|
|
966
|
+
if (access.role !== 'owner') {
|
|
967
|
+
throw new WorkspaceAccessError('workspace-owner-required', { status: 403 });
|
|
968
|
+
}
|
|
969
|
+
if (runtimeAuthEpoch !== watchAuthEpoch
|
|
970
|
+
|| currentBoundHubUserId() !== watchUserId
|
|
971
|
+
|| currentBoundHubWorkspaceId() !== watchWorkspaceId) return;
|
|
972
|
+
const licenseChanged = !previousAccess
|
|
973
|
+
|| previousAccess.plan !== access.plan
|
|
974
|
+
|| previousAccess.entitlementStatus !== access.entitlementStatus
|
|
975
|
+
|| previousAccess.deviceLimit !== access.deviceLimit
|
|
976
|
+
|| previousAccess.memberLimit !== access.memberLimit
|
|
977
|
+
|| previousAccess.commercialUse !== access.commercialUse;
|
|
978
|
+
const teamBecameInactive = access.workspaceKind === 'team'
|
|
979
|
+
&& !workspaceRemoteAccessActive(access)
|
|
980
|
+
&& workspaceRemoteAccessActive(previousAccess);
|
|
981
|
+
const teamBecameActive = access.workspaceKind === 'team'
|
|
982
|
+
&& workspaceRemoteAccessActive(access)
|
|
983
|
+
&& !workspaceRemoteAccessActive(previousAccess);
|
|
984
|
+
runtimeWorkspaceAccess = access;
|
|
985
|
+
try { persistRuntimeWorkspace(access); } catch { /* the verified in-memory owner remains authoritative */ }
|
|
986
|
+
if (licenseChanged || now - licenseRefreshLastAttemptAt >= LICENSE_REFRESH_INTERVAL_MS) {
|
|
987
|
+
applyVerifiedWorkspaceLicense(access);
|
|
988
|
+
licenseRefreshLastAttemptAt = now;
|
|
989
|
+
}
|
|
990
|
+
if (teamBecameInactive) {
|
|
991
|
+
hubConsoleDirect?.close();
|
|
992
|
+
await clearHubHostTarget('workspace-entitlement-inactive');
|
|
993
|
+
} else if (teamBecameActive) {
|
|
994
|
+
hubConsoleDirect?.refresh();
|
|
995
|
+
scheduleAuthenticatedHubHostTargetPublication('workspace-entitlement-activated');
|
|
996
|
+
}
|
|
997
|
+
// The revision also changes for invitations, billing and unrelated member
|
|
998
|
+
// updates. Closing every healthy peer here would turn a harmless Team
|
|
999
|
+
// metadata change into an input outage. Exact member removal is handled by
|
|
1000
|
+
// the owner revoke endpoint; every Team signaling socket is independently
|
|
1001
|
+
// revalidated by the Wake Worker within its bounded access window.
|
|
1002
|
+
} catch (error) {
|
|
1003
|
+
if (error instanceof WorkspaceAccessError
|
|
1004
|
+
&& error.status < 500
|
|
1005
|
+
&& runtimeAuthEpoch === watchAuthEpoch
|
|
1006
|
+
&& currentBoundHubUserId() === watchUserId
|
|
1007
|
+
&& currentBoundHubWorkspaceId() === watchWorkspaceId) {
|
|
1008
|
+
runtimeWorkspaceAccess = null;
|
|
1009
|
+
verifiedLicense = {
|
|
1010
|
+
userId: '',
|
|
1011
|
+
workspaceId: currentBoundHubWorkspaceId(),
|
|
1012
|
+
workspaceKind: 'personal',
|
|
1013
|
+
plan: 'free',
|
|
1014
|
+
status: 'inactive',
|
|
1015
|
+
expiresAt: '',
|
|
1016
|
+
memberLimit: 1,
|
|
1017
|
+
commercialUse: false,
|
|
1018
|
+
verifiedAt: Date.now()
|
|
1019
|
+
};
|
|
1020
|
+
hubUiSessionAuthority.revokeAll();
|
|
1021
|
+
hubConsoleDirect?.close();
|
|
1022
|
+
}
|
|
1023
|
+
// A temporary provider failure must not stop a working Hub. The next
|
|
1024
|
+
// interval retries the authoritative role check.
|
|
699
1025
|
} finally {
|
|
700
1026
|
roleWatchInFlight = false;
|
|
701
1027
|
}
|
|
@@ -1402,17 +1728,87 @@ hubConsoleDirect = createHubConsoleDirect({
|
|
|
1402
1728
|
deviceId: runtimeDeviceId,
|
|
1403
1729
|
httpBaseUrl: `http://127.0.0.1:${httpPort}`,
|
|
1404
1730
|
stunUrls: hubConsoleDirectStunUrls,
|
|
1405
|
-
getAccessToken: () => getRuntimeAccessToken()
|
|
1731
|
+
getAccessToken: () => getRuntimeAccessToken(),
|
|
1732
|
+
getWorkspaceAccess: () => runtimeWorkspaceAccess,
|
|
1733
|
+
consoleProxyToken
|
|
1406
1734
|
});
|
|
1407
1735
|
const httpConnections = new Set();
|
|
1408
1736
|
const frameWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
|
|
1409
1737
|
const atlasWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
|
|
1410
1738
|
const inputWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
|
|
1411
1739
|
const audioWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
|
|
1412
|
-
const browserWebSocketServers = [frameWss, atlasWss, inputWss, audioWss];
|
|
1413
|
-
const frameCaptureTransitionRetries = createLiveCaptureTransitionRetryCoordinator();
|
|
1414
|
-
|
|
1415
|
-
|
|
1740
|
+
const browserWebSocketServers = [frameWss, atlasWss, inputWss, audioWss];
|
|
1741
|
+
const frameCaptureTransitionRetries = createLiveCaptureTransitionRetryCoordinator();
|
|
1742
|
+
|
|
1743
|
+
function retireHubUiSessionSocket(ws, reason = 'hub-ui-session-revoked') {
|
|
1744
|
+
if (!ws || ws.liveDeskUiSessionRevoked === true) return false;
|
|
1745
|
+
ws.liveDeskUiSessionRevoked = true;
|
|
1746
|
+
if (ws.liveDeskUiSessionTimer) clearTimeout(ws.liveDeskUiSessionTimer);
|
|
1747
|
+
ws.liveDeskUiSessionTimer = null;
|
|
1748
|
+
if (ws.liveDeskInputClientId) {
|
|
1749
|
+
for (const deviceId of ws.liveDeskInputDeviceIds || []) {
|
|
1750
|
+
remoteHub.releaseInputOwner(
|
|
1751
|
+
deviceId,
|
|
1752
|
+
ws.liveDeskInputClientId,
|
|
1753
|
+
String(reason || 'hub-ui-session-revoked').slice(0, 120)
|
|
1754
|
+
);
|
|
1755
|
+
}
|
|
1756
|
+
ws.liveDeskInputDeviceIds?.clear?.();
|
|
1757
|
+
}
|
|
1758
|
+
try {
|
|
1759
|
+
ws.close(1008, String(reason || 'hub-ui-session-revoked').slice(0, 120));
|
|
1760
|
+
} catch {
|
|
1761
|
+
try { ws.terminate(); } catch {}
|
|
1762
|
+
}
|
|
1763
|
+
return true;
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
function retireHubUiSessionSockets(session, reason = 'hub-ui-session-revoked') {
|
|
1767
|
+
const sessionId = String(session?.sessionId || '');
|
|
1768
|
+
if (!sessionId) return 0;
|
|
1769
|
+
let retired = 0;
|
|
1770
|
+
for (const wss of browserWebSocketServers) {
|
|
1771
|
+
for (const ws of wss.clients) {
|
|
1772
|
+
if (ws.liveDeskUiSessionId !== sessionId) continue;
|
|
1773
|
+
if (retireHubUiSessionSocket(ws, reason)) retired += 1;
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
return retired;
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
function bindHubUiSessionSocket(ws, req) {
|
|
1780
|
+
const authorization = req?.liveDeskAuthorization;
|
|
1781
|
+
const sessionId = String(authorization?.sessionId || '');
|
|
1782
|
+
if (!sessionId) return;
|
|
1783
|
+
ws.liveDeskUiSessionId = sessionId;
|
|
1784
|
+
ws.liveDeskUiSessionUserId = String(authorization.userId || '');
|
|
1785
|
+
ws.liveDeskUiSessionWorkspaceId = String(authorization.workspaceId || '');
|
|
1786
|
+
ws.liveDeskUiSessionRevoked = false;
|
|
1787
|
+
const armDeadline = () => {
|
|
1788
|
+
if (ws.liveDeskUiSessionRevoked === true) return;
|
|
1789
|
+
const deadline = hubUiSessionAuthority.sessionDeadline(sessionId);
|
|
1790
|
+
if (!deadline) {
|
|
1791
|
+
retireHubUiSessionSocket(ws, 'hub-ui-session-expired');
|
|
1792
|
+
return;
|
|
1793
|
+
}
|
|
1794
|
+
const delay = deadline - Date.now();
|
|
1795
|
+
if (delay <= 0) {
|
|
1796
|
+
retireHubUiSessionSocket(ws, 'hub-ui-session-expired');
|
|
1797
|
+
return;
|
|
1798
|
+
}
|
|
1799
|
+
ws.liveDeskUiSessionTimer = setTimeout(armDeadline, Math.min(delay, 60_000));
|
|
1800
|
+
ws.liveDeskUiSessionTimer.unref?.();
|
|
1801
|
+
};
|
|
1802
|
+
armDeadline();
|
|
1803
|
+
const cleanup = () => {
|
|
1804
|
+
if (ws.liveDeskUiSessionTimer) clearTimeout(ws.liveDeskUiSessionTimer);
|
|
1805
|
+
ws.liveDeskUiSessionTimer = null;
|
|
1806
|
+
};
|
|
1807
|
+
ws.once('close', cleanup);
|
|
1808
|
+
ws.once('error', cleanup);
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
httpServer.on('connection', socket => {
|
|
1416
1812
|
httpConnections.add(socket);
|
|
1417
1813
|
socket.once('close', () => httpConnections.delete(socket));
|
|
1418
1814
|
});
|
|
@@ -1509,13 +1905,34 @@ function isSecureHubHttpRequest(req) {
|
|
|
1509
1905
|
|| String(req.headers['x-forwarded-proto'] || '').trim().toLowerCase() === 'https';
|
|
1510
1906
|
}
|
|
1511
1907
|
|
|
1512
|
-
function currentBoundHubUserId() {
|
|
1908
|
+
function currentBoundHubUserId() {
|
|
1513
1909
|
return String(
|
|
1514
1910
|
runtimeManager.getSnapshot().userId
|
|
1515
1911
|
|| readPersistedRuntimeSession()?.user?.id
|
|
1516
1912
|
|| ''
|
|
1517
1913
|
).trim();
|
|
1518
|
-
}
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
function workspaceAccessHttpStatus(error) {
|
|
1917
|
+
return error instanceof WorkspaceAccessError
|
|
1918
|
+
? Math.max(400, Math.min(599, Number(error.status || 403)))
|
|
1919
|
+
: authVerificationHttpStatus(error instanceof Error ? error.message : String(error));
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
function currentBoundHubWorkspaceId() {
|
|
1923
|
+
return String(
|
|
1924
|
+
runtimeWorkspaceAccess?.workspaceId
|
|
1925
|
+
|| readPersistedWorkspaceId()
|
|
1926
|
+
|| currentBoundHubUserId()
|
|
1927
|
+
|| ''
|
|
1928
|
+
).trim();
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
function runtimeAuthOwnerIsCurrent(epoch, userId, workspaceId) {
|
|
1932
|
+
return runtimeAuthEpoch === epoch
|
|
1933
|
+
&& currentBoundHubUserId() === String(userId || '')
|
|
1934
|
+
&& currentBoundHubWorkspaceId() === String(workspaceId || '');
|
|
1935
|
+
}
|
|
1519
1936
|
|
|
1520
1937
|
function isPublicHubApiRequest(req) {
|
|
1521
1938
|
const method = String(req.method || 'GET').toUpperCase();
|
|
@@ -1524,21 +1941,98 @@ function isPublicHubApiRequest(req) {
|
|
|
1524
1941
|
|| (method === 'POST' && path === '/api/auth/session');
|
|
1525
1942
|
}
|
|
1526
1943
|
|
|
1527
|
-
function isTrustedNativeLoopbackRequest(req) {
|
|
1944
|
+
function isTrustedNativeLoopbackRequest(req) {
|
|
1528
1945
|
return isLoopbackAddress(req.socket?.remoteAddress)
|
|
1529
1946
|
&& !String(req.headers.origin || '').trim()
|
|
1530
1947
|
&& !String(req.headers['sec-fetch-site'] || '').trim();
|
|
1531
|
-
}
|
|
1532
|
-
|
|
1533
|
-
function
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
return { ok:
|
|
1539
|
-
}
|
|
1540
|
-
|
|
1541
|
-
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
function authorizeConsoleProxyRequest(req) {
|
|
1951
|
+
const suppliedToken = String(req.headers['x-livedesk-console-proxy'] || '').trim();
|
|
1952
|
+
if (!suppliedToken) return null;
|
|
1953
|
+
if (!isTrustedNativeLoopbackRequest(req)
|
|
1954
|
+
|| !secureExactTestTokenMatches(suppliedToken, consoleProxyToken)) {
|
|
1955
|
+
return { ok: false, status: 403, error: 'console-proxy-not-authorized' };
|
|
1956
|
+
}
|
|
1957
|
+
const workspaceId = String(req.headers['x-livedesk-workspace-id'] || '').trim();
|
|
1958
|
+
const workspaceKind = String(req.headers['x-livedesk-workspace-kind'] || '').trim().toLowerCase();
|
|
1959
|
+
const userId = String(req.headers['x-livedesk-workspace-user-id'] || '').trim();
|
|
1960
|
+
const role = String(req.headers['x-livedesk-workspace-role'] || '').trim().toLowerCase();
|
|
1961
|
+
const membershipRevision = Number(req.headers['x-livedesk-membership-revision'] || 0);
|
|
1962
|
+
const accessExpiresAt = Number(req.headers['x-livedesk-workspace-access-expires-at'] || 0);
|
|
1963
|
+
if (!workspaceId
|
|
1964
|
+
|| workspaceKind !== String(runtimeWorkspaceAccess?.workspaceKind || '').trim().toLowerCase()
|
|
1965
|
+
|| !userId
|
|
1966
|
+
|| workspaceId !== currentBoundHubWorkspaceId()
|
|
1967
|
+
|| !workspaceRoleCanControl(role)
|
|
1968
|
+
|| !Number.isSafeInteger(membershipRevision)
|
|
1969
|
+
|| membershipRevision < 0
|
|
1970
|
+
|| !Number.isFinite(accessExpiresAt)
|
|
1971
|
+
|| (workspaceKind === 'team' && accessExpiresAt <= Date.now())
|
|
1972
|
+
|| (workspaceKind === 'personal' && accessExpiresAt !== 0)) {
|
|
1973
|
+
return { ok: false, status: 403, error: 'console-workspace-access-invalid' };
|
|
1974
|
+
}
|
|
1975
|
+
return {
|
|
1976
|
+
ok: true,
|
|
1977
|
+
consoleProxy: true,
|
|
1978
|
+
workspaceId,
|
|
1979
|
+
workspaceKind,
|
|
1980
|
+
userId,
|
|
1981
|
+
role,
|
|
1982
|
+
plan: String(runtimeWorkspaceAccess?.plan || 'free'),
|
|
1983
|
+
entitlementStatus: String(runtimeWorkspaceAccess?.entitlementStatus || 'inactive'),
|
|
1984
|
+
membershipRevision,
|
|
1985
|
+
accessExpiresAt
|
|
1986
|
+
};
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
function authorizationWorkspaceAccess(authorization) {
|
|
1990
|
+
return {
|
|
1991
|
+
workspaceKind: String(
|
|
1992
|
+
authorization?.workspaceKind
|
|
1993
|
+
|| runtimeWorkspaceAccess?.workspaceKind
|
|
1994
|
+
|| 'personal'
|
|
1995
|
+
).trim().toLowerCase(),
|
|
1996
|
+
plan: String(
|
|
1997
|
+
authorization?.plan
|
|
1998
|
+
|| runtimeWorkspaceAccess?.plan
|
|
1999
|
+
|| 'free'
|
|
2000
|
+
).trim().toLowerCase(),
|
|
2001
|
+
entitlementStatus: String(
|
|
2002
|
+
authorization?.entitlementStatus
|
|
2003
|
+
|| runtimeWorkspaceAccess?.entitlementStatus
|
|
2004
|
+
|| 'active'
|
|
2005
|
+
).trim().toLowerCase()
|
|
2006
|
+
};
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
function authorizeHubUiRequest(req) {
|
|
2010
|
+
if (process.env.LIVEDESK_TEST_MODE === '1') {
|
|
2011
|
+
return { ok: true, explicitTestMode: true };
|
|
2012
|
+
}
|
|
2013
|
+
const consoleAuthorization = authorizeConsoleProxyRequest(req);
|
|
2014
|
+
if (consoleAuthorization) return consoleAuthorization;
|
|
2015
|
+
if (isTrustedNativeLoopbackRequest(req)) {
|
|
2016
|
+
return { ok: true, nativeLoopback: true };
|
|
2017
|
+
}
|
|
2018
|
+
return hubUiSessionAuthority.authorize(req, {
|
|
2019
|
+
workspaceId: currentBoundHubWorkspaceId(),
|
|
2020
|
+
allowedRoles: ['owner', 'operator']
|
|
2021
|
+
});
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
function authorizedWorkspaceRole(req) {
|
|
2025
|
+
if (req.liveDeskAuthorization?.role) return req.liveDeskAuthorization.role;
|
|
2026
|
+
const consoleAuthorization = authorizeConsoleProxyRequest(req);
|
|
2027
|
+
if (consoleAuthorization?.ok) return consoleAuthorization.role;
|
|
2028
|
+
const uiAuthorization = hubUiSessionAuthority.authorize(req, {
|
|
2029
|
+
workspaceId: currentBoundHubWorkspaceId(),
|
|
2030
|
+
allowedRoles: ['owner', 'operator']
|
|
2031
|
+
}, { requireCsrf: false });
|
|
2032
|
+
return uiAuthorization?.ok
|
|
2033
|
+
? uiAuthorization.role
|
|
2034
|
+
: String(runtimeWorkspaceAccess?.role || '').trim().toLowerCase();
|
|
2035
|
+
}
|
|
1542
2036
|
|
|
1543
2037
|
app.use((req, res, next) => {
|
|
1544
2038
|
if (!isTrustedBrowserRequest(req)) {
|
|
@@ -1566,13 +2060,29 @@ app.use((req, res, next) => {
|
|
|
1566
2060
|
next();
|
|
1567
2061
|
return;
|
|
1568
2062
|
}
|
|
1569
|
-
const authorization = authorizeHubUiRequest(req);
|
|
1570
|
-
if (!authorization.ok) {
|
|
2063
|
+
const authorization = authorizeHubUiRequest(req);
|
|
2064
|
+
if (!authorization.ok) {
|
|
1571
2065
|
noStore(res);
|
|
1572
2066
|
res.status(authorization.status).json({ ok: false, error: authorization.error });
|
|
1573
|
-
return;
|
|
1574
|
-
}
|
|
1575
|
-
|
|
2067
|
+
return;
|
|
2068
|
+
}
|
|
2069
|
+
if (!workspaceEntitlementCanRequest(
|
|
2070
|
+
authorizationWorkspaceAccess(authorization),
|
|
2071
|
+
req.method,
|
|
2072
|
+
req.path
|
|
2073
|
+
)) {
|
|
2074
|
+
noStore(res);
|
|
2075
|
+
res.status(403).json({ ok: false, error: 'workspace-entitlement-inactive' });
|
|
2076
|
+
return;
|
|
2077
|
+
}
|
|
2078
|
+
if (authorization.role
|
|
2079
|
+
&& !workspaceRoleCanRequest(authorization.role, req.method, req.path)) {
|
|
2080
|
+
noStore(res);
|
|
2081
|
+
res.status(403).json({ ok: false, error: 'workspace-owner-required' });
|
|
2082
|
+
return;
|
|
2083
|
+
}
|
|
2084
|
+
req.liveDeskAuthorization = authorization;
|
|
2085
|
+
next();
|
|
1576
2086
|
});
|
|
1577
2087
|
app.use((req, res, next) => {
|
|
1578
2088
|
if (runtimeRole === 'client' && req.path.startsWith('/api/remote')) {
|
|
@@ -1774,7 +2284,7 @@ function normalizeTransferChunk(body = {}) {
|
|
|
1774
2284
|
};
|
|
1775
2285
|
}
|
|
1776
2286
|
|
|
1777
|
-
function normalizeLiveOptions(payload = {}) {
|
|
2287
|
+
function normalizeLiveOptions(payload = {}) {
|
|
1778
2288
|
const mode = String(payload.frameMode || payload.mode || 'mode3-h264-hw').trim() || 'mode3-h264-hw';
|
|
1779
2289
|
const streamPurpose = String(payload.streamPurpose || payload.purpose || 'wall').trim().slice(0, 24) || 'wall';
|
|
1780
2290
|
const maxFps = streamPurpose === 'control' ? 60 : 30;
|
|
@@ -1784,10 +2294,11 @@ function normalizeLiveOptions(payload = {}) {
|
|
|
1784
2294
|
maxHeight: clampNumber(payload.maxHeight, 180, 2160, 360),
|
|
1785
2295
|
quality: clampNumber(payload.quality, 20, 95, 45),
|
|
1786
2296
|
monitorIndex: normalizeMonitorIndex(payload.monitorIndex ?? payload.screenIndex ?? payload.displayIndex),
|
|
1787
|
-
monitorSelections: normalizeMonitorSelections(payload.monitorSelections),
|
|
1788
|
-
forceRestart: /^(1|true|yes|on)$/i.test(String(payload.forceRestart ?? '')),
|
|
1789
|
-
reuseExisting: /^(1|true|yes|on)$/i.test(String(payload.reuseExisting ?? '')),
|
|
1790
|
-
|
|
2297
|
+
monitorSelections: normalizeMonitorSelections(payload.monitorSelections),
|
|
2298
|
+
forceRestart: /^(1|true|yes|on)$/i.test(String(payload.forceRestart ?? '')),
|
|
2299
|
+
reuseExisting: /^(1|true|yes|on)$/i.test(String(payload.reuseExisting ?? '')),
|
|
2300
|
+
allowReadOnlyControlBorrow: /^(1|true|yes|on)$/i.test(String(payload.allowReadOnlyControlBorrow ?? '')),
|
|
2301
|
+
streamPurpose,
|
|
1791
2302
|
mode,
|
|
1792
2303
|
frameMode: mode
|
|
1793
2304
|
};
|
|
@@ -1888,15 +2399,23 @@ function unregisterFrameClient(ws) {
|
|
|
1888
2399
|
retireFrameClientSendLane(ws);
|
|
1889
2400
|
}
|
|
1890
2401
|
|
|
1891
|
-
function hasOtherFrameStreamOwner(ws, deviceId, streamId, streamPurpose = '') {
|
|
2402
|
+
function hasOtherFrameStreamOwner(ws, deviceId, streamId, streamPurpose = '') {
|
|
1892
2403
|
if (!streamId) {
|
|
1893
2404
|
return false;
|
|
1894
2405
|
}
|
|
1895
|
-
for (const candidate of frameClients) {
|
|
1896
|
-
if (candidate === ws || candidate.readyState !== candidate.OPEN) {
|
|
1897
|
-
continue;
|
|
1898
|
-
}
|
|
1899
|
-
|
|
2406
|
+
for (const candidate of frameClients) {
|
|
2407
|
+
if (candidate === ws || candidate.readyState !== candidate.OPEN) {
|
|
2408
|
+
continue;
|
|
2409
|
+
}
|
|
2410
|
+
const expectedBinding = candidate.liveDeskExpectedStreamBindingsByDeviceId instanceof Map
|
|
2411
|
+
? candidate.liveDeskExpectedStreamBindingsByDeviceId.get(deviceId)
|
|
2412
|
+
: null;
|
|
2413
|
+
if (expectedBinding?.readOnlyControlBorrow === true) {
|
|
2414
|
+
// A presentation observer can share immutable packets, but it must never
|
|
2415
|
+
// keep the native Control owner alive after the real controller leaves.
|
|
2416
|
+
continue;
|
|
2417
|
+
}
|
|
2418
|
+
if (candidate.liveDeskStreamIdsByDeviceId instanceof Map
|
|
1900
2419
|
&& candidate.liveDeskStreamIdsByDeviceId.get(deviceId) === streamId) {
|
|
1901
2420
|
return true;
|
|
1902
2421
|
}
|
|
@@ -1987,17 +2506,28 @@ function scheduleFrameStreamStop(deviceId, streamId, streamPurpose) {
|
|
|
1987
2506
|
pendingFrameStreamStops.set(key, { timer });
|
|
1988
2507
|
}
|
|
1989
2508
|
|
|
1990
|
-
function stopFrameClientStreams(ws) {
|
|
2509
|
+
function stopFrameClientStreams(ws) {
|
|
1991
2510
|
const streams = ws.liveDeskStreamIdsByDeviceId instanceof Map
|
|
1992
2511
|
? ws.liveDeskStreamIdsByDeviceId
|
|
1993
2512
|
: null;
|
|
1994
2513
|
if (!streams) {
|
|
1995
2514
|
return;
|
|
1996
2515
|
}
|
|
1997
|
-
const
|
|
1998
|
-
for (const [deviceId, streamId] of streams.entries()) {
|
|
1999
|
-
|
|
2000
|
-
|
|
2516
|
+
const requestedStreamPurpose = String(ws.liveDeskLiveOptions?.streamPurpose || 'wall');
|
|
2517
|
+
for (const [deviceId, streamId] of streams.entries()) {
|
|
2518
|
+
const expectedBinding = ws.liveDeskExpectedStreamBindingsByDeviceId instanceof Map
|
|
2519
|
+
? ws.liveDeskExpectedStreamBindingsByDeviceId.get(deviceId)
|
|
2520
|
+
: null;
|
|
2521
|
+
if (expectedBinding?.readOnlyControlBorrow === true) {
|
|
2522
|
+
// Borrowers own only browser presentation resources. The real Control
|
|
2523
|
+
// subscriber remains the sole native stop owner.
|
|
2524
|
+
continue;
|
|
2525
|
+
}
|
|
2526
|
+
const streamPurpose = expectedBinding?.streamId === streamId
|
|
2527
|
+
? String(expectedBinding.streamPurpose || requestedStreamPurpose)
|
|
2528
|
+
: requestedStreamPurpose;
|
|
2529
|
+
if (!streamId || hasOtherFrameStreamOwner(ws, deviceId, streamId, streamPurpose)) {
|
|
2530
|
+
continue;
|
|
2001
2531
|
}
|
|
2002
2532
|
scheduleFrameStreamStop(deviceId, streamId, streamPurpose);
|
|
2003
2533
|
}
|
|
@@ -3030,21 +3560,21 @@ function startFrameSubscriptionLive(
|
|
|
3030
3560
|
const monitorIndex = Object.prototype.hasOwnProperty.call(liveOptions.monitorSelections || {}, deviceId)
|
|
3031
3561
|
? liveOptions.monitorSelections[deviceId]
|
|
3032
3562
|
: liveOptions.monitorIndex;
|
|
3033
|
-
const result = remoteHub.startLiveStream(deviceId, {
|
|
3034
|
-
...liveOptions,
|
|
3035
|
-
monitorIndex,
|
|
3036
|
-
reuseExisting: liveOptions.forceRestart !== true
|
|
3037
|
-
&& (liveOptions.reuseExisting === true || reason === 'subscribe' || reason === 'watchdog'),
|
|
3038
|
-
// Wall capture is one shared native encoder per device. Two open browser
|
|
3039
|
-
// views may ask for different soft profiles (for example mobile 5 fps at
|
|
3040
|
-
// 1080p and desktop 20 fps at 540p). Once a healthy shared owner exists,
|
|
3041
|
-
// those preferences must bind to that owner instead of replacing it back
|
|
3042
|
-
// and forth every time either view refreshes its subscription.
|
|
3043
|
-
reuseSharedExisting: liveOptions.forceRestart !== true
|
|
3044
|
-
&& String(liveOptions.streamPurpose || '').trim().toLowerCase() === 'wall'
|
|
3045
|
-
&& hasOtherFrameStreamDemand(ws, deviceId, 'wall'),
|
|
3046
|
-
silentReuse: reason === 'watchdog' && liveOptions.forceRestart !== true
|
|
3047
|
-
});
|
|
3563
|
+
const result = remoteHub.startLiveStream(deviceId, {
|
|
3564
|
+
...liveOptions,
|
|
3565
|
+
monitorIndex,
|
|
3566
|
+
reuseExisting: liveOptions.forceRestart !== true
|
|
3567
|
+
&& (liveOptions.reuseExisting === true || reason === 'subscribe' || reason === 'watchdog'),
|
|
3568
|
+
// Wall capture is one shared native encoder per device. Two open browser
|
|
3569
|
+
// views may ask for different soft profiles (for example mobile 5 fps at
|
|
3570
|
+
// 1080p and desktop 20 fps at 540p). Once a healthy shared owner exists,
|
|
3571
|
+
// those preferences must bind to that owner instead of replacing it back
|
|
3572
|
+
// and forth every time either view refreshes its subscription.
|
|
3573
|
+
reuseSharedExisting: liveOptions.forceRestart !== true
|
|
3574
|
+
&& String(liveOptions.streamPurpose || '').trim().toLowerCase() === 'wall'
|
|
3575
|
+
&& hasOtherFrameStreamDemand(ws, deviceId, 'wall'),
|
|
3576
|
+
silentReuse: reason === 'watchdog' && liveOptions.forceRestart !== true
|
|
3577
|
+
});
|
|
3048
3578
|
if (result?.ok) {
|
|
3049
3579
|
if (result.sharedProfileReused === true) {
|
|
3050
3580
|
ws.liveDeskSharedProfileReuseCount = Math.max(
|
|
@@ -3053,18 +3583,25 @@ function startFrameSubscriptionLive(
|
|
|
3053
3583
|
) + 1;
|
|
3054
3584
|
}
|
|
3055
3585
|
frameCaptureTransitionRetries.complete(ws, deviceId, intentGeneration, 'capture-started');
|
|
3056
|
-
const expectedBinding = {
|
|
3586
|
+
const expectedBinding = {
|
|
3057
3587
|
deviceId,
|
|
3058
3588
|
sessionId: String(result.sessionId || device.sessionId || ''),
|
|
3059
3589
|
streamId: String(result.streamId || ''),
|
|
3060
3590
|
streamPurpose: String(result.streamPurpose || liveOptions.streamPurpose || 'wall'),
|
|
3061
3591
|
commandId: String(result.commandId || ''),
|
|
3062
|
-
captureGeneration: Number(result.captureGeneration || 0),
|
|
3063
|
-
monitorIndex: Number(result.monitorIndex || 0),
|
|
3064
|
-
|
|
3592
|
+
captureGeneration: Number(result.captureGeneration || 0),
|
|
3593
|
+
monitorIndex: Number(result.monitorIndex || 0),
|
|
3594
|
+
readOnlyControlBorrow: result.readOnlyControlBorrow === true,
|
|
3595
|
+
presentationPurpose: result.presentationPurpose === 'wall' ? 'wall' : '',
|
|
3596
|
+
effectiveProfile: result.effectiveProfile || null,
|
|
3597
|
+
readySent: false
|
|
3065
3598
|
};
|
|
3066
3599
|
const activeStream = device?.activeLiveStream;
|
|
3067
|
-
|
|
3600
|
+
// A new read-only browser presentation has no decoder history from the
|
|
3601
|
+
// already-running Control stream. It must enter through the next exact
|
|
3602
|
+
// key frame even when the native owner itself is already ready.
|
|
3603
|
+
const reusedFrameReady = expectedBinding.readOnlyControlBorrow !== true
|
|
3604
|
+
&& isReusedLiveStreamFrameReady(result, activeStream, expectedBinding);
|
|
3068
3605
|
expectedBinding.readySent = reusedFrameReady;
|
|
3069
3606
|
ws.liveDeskStreamIdsByDeviceId.set(deviceId, result.streamId);
|
|
3070
3607
|
const installedBindingIdentity = replaceExpectedFrameBindingForClient(
|
|
@@ -3076,19 +3613,24 @@ function startFrameSubscriptionLive(
|
|
|
3076
3613
|
skipped.push({ deviceId, reason: 'invalid-live-stream-binding' });
|
|
3077
3614
|
continue;
|
|
3078
3615
|
}
|
|
3079
|
-
|
|
3080
|
-
|
|
3616
|
+
if (expectedBinding.readOnlyControlBorrow !== true) {
|
|
3617
|
+
cancelPendingFrameStreamStop(deviceId, result.streamId, expectedBinding.streamPurpose);
|
|
3618
|
+
}
|
|
3619
|
+
started.push({
|
|
3081
3620
|
deviceId: expectedBinding.deviceId,
|
|
3082
3621
|
sessionId: expectedBinding.sessionId,
|
|
3083
3622
|
streamId: expectedBinding.streamId,
|
|
3084
3623
|
streamPurpose: expectedBinding.streamPurpose,
|
|
3085
3624
|
commandId: expectedBinding.commandId,
|
|
3086
3625
|
captureGeneration: expectedBinding.captureGeneration,
|
|
3087
|
-
monitorIndex: expectedBinding.monitorIndex,
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3626
|
+
monitorIndex: expectedBinding.monitorIndex,
|
|
3627
|
+
readOnlyControlBorrow: expectedBinding.readOnlyControlBorrow,
|
|
3628
|
+
presentationPurpose: expectedBinding.presentationPurpose,
|
|
3629
|
+
effectiveProfile: expectedBinding.effectiveProfile,
|
|
3630
|
+
ready: reusedFrameReady,
|
|
3631
|
+
fps: result.fps,
|
|
3632
|
+
reused: result.reused === true
|
|
3633
|
+
});
|
|
3092
3634
|
} else {
|
|
3093
3635
|
ws.liveDeskStreamIdsByDeviceId.delete(deviceId);
|
|
3094
3636
|
replaceExpectedFrameBindingForClient(ws, deviceId, null);
|
|
@@ -3160,9 +3702,10 @@ function startFrameSubscriptionLive(
|
|
|
3160
3702
|
}
|
|
3161
3703
|
}
|
|
3162
3704
|
}
|
|
3163
|
-
if (reason === 'watchdog' && !started.some(item => item.reused !== true))
|
|
3164
|
-
|
|
3165
|
-
|
|
3705
|
+
if ((reason === 'watchdog' && !started.some(item => item.reused !== true))
|
|
3706
|
+
|| (reason === 'control-borrow-reconcile' && started.length === 0)) {
|
|
3707
|
+
return;
|
|
3708
|
+
}
|
|
3166
3709
|
sendJson(ws, {
|
|
3167
3710
|
type: 'RemoteFrameLiveAutoStart',
|
|
3168
3711
|
timestamp: new Date().toISOString(),
|
|
@@ -3439,21 +3982,30 @@ function broadcastRemoteBinaryFrame(frameEvent) {
|
|
|
3439
3982
|
continue;
|
|
3440
3983
|
}
|
|
3441
3984
|
const requiresReadyKeyFrame = String(expectedBinding.streamPurpose || '').toLowerCase() === 'control' && isH264;
|
|
3442
|
-
if (!expectedBinding.readySent
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3985
|
+
if (!expectedBinding.readySent) {
|
|
3986
|
+
// A fresh H.264 decoder cannot consume dependent Control deltas. Keep
|
|
3987
|
+
// the browser lane closed until the first exact key frame; otherwise a
|
|
3988
|
+
// newly attached PWA can remain on "Preparing video" until a later IDR.
|
|
3989
|
+
if (requiresReadyKeyFrame && !isKeyFrame) {
|
|
3990
|
+
continue;
|
|
3991
|
+
}
|
|
3992
|
+
expectedBinding.readySent = sendJson(client, {
|
|
3993
|
+
type: 'RemoteFrameStreamReady',
|
|
3994
|
+
deviceId,
|
|
3995
|
+
sessionId: expectedBinding.sessionId,
|
|
3996
|
+
streamId: expectedBinding.streamId,
|
|
3997
|
+
streamPurpose: expectedBinding.streamPurpose,
|
|
3998
|
+
commandId: expectedBinding.commandId,
|
|
3999
|
+
captureGeneration: expectedBinding.captureGeneration,
|
|
4000
|
+
monitorIndex: expectedBinding.monitorIndex,
|
|
4001
|
+
readOnlyControlBorrow: expectedBinding.readOnlyControlBorrow === true,
|
|
4002
|
+
presentationPurpose: expectedBinding.presentationPurpose || '',
|
|
4003
|
+
effectiveProfile: expectedBinding.effectiveProfile || null
|
|
4004
|
+
});
|
|
4005
|
+
if (!expectedBinding.readySent) {
|
|
4006
|
+
continue;
|
|
4007
|
+
}
|
|
4008
|
+
}
|
|
3457
4009
|
const lane = ensureFrameClientSendLane(client);
|
|
3458
4010
|
if (client.liveDeskFrameBackpressured
|
|
3459
4011
|
&& lane.backpressuredDeviceIds.has(deviceId)
|
|
@@ -3954,7 +4506,7 @@ app.get('/api/settings', async (_req, res) => {
|
|
|
3954
4506
|
}
|
|
3955
4507
|
});
|
|
3956
4508
|
|
|
3957
|
-
app.patch('/api/settings', async (req, res) => {
|
|
4509
|
+
app.patch('/api/settings', async (req, res) => {
|
|
3958
4510
|
noStore(res);
|
|
3959
4511
|
try {
|
|
3960
4512
|
const body = req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : {};
|
|
@@ -3974,9 +4526,72 @@ app.patch('/api/settings', async (req, res) => {
|
|
|
3974
4526
|
}
|
|
3975
4527
|
res.status(error?.status || 400).json({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
3976
4528
|
}
|
|
3977
|
-
});
|
|
3978
|
-
|
|
3979
|
-
function
|
|
4529
|
+
});
|
|
4530
|
+
|
|
4531
|
+
function wallPreferencesResponse(record) {
|
|
4532
|
+
return {
|
|
4533
|
+
ok: true,
|
|
4534
|
+
revision: Number(record?.revision || 0),
|
|
4535
|
+
updatedAt: String(record?.updatedAt || ''),
|
|
4536
|
+
wallPreferences: {
|
|
4537
|
+
cadence: String(record?.cadence || record?.settings?.wall?.cadence || 'fast'),
|
|
4538
|
+
viewScale: Number(record?.viewScale ?? record?.settings?.wall?.viewScale ?? 100),
|
|
4539
|
+
showThisComputer: Boolean(
|
|
4540
|
+
record?.settings?.connection?.showThisComputer
|
|
4541
|
+
?? liveDeskSettingsStore.getCached()?.connection?.showThisComputer
|
|
4542
|
+
)
|
|
4543
|
+
}
|
|
4544
|
+
};
|
|
4545
|
+
}
|
|
4546
|
+
|
|
4547
|
+
app.get('/api/remote/wall-preferences', async (_req, res) => {
|
|
4548
|
+
noStore(res);
|
|
4549
|
+
try {
|
|
4550
|
+
res.json(wallPreferencesResponse(await liveDeskSettingsStore.getWallPreferencesRecord()));
|
|
4551
|
+
} catch (error) {
|
|
4552
|
+
res.status(500).json({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
4553
|
+
}
|
|
4554
|
+
});
|
|
4555
|
+
|
|
4556
|
+
app.patch('/api/remote/wall-preferences', async (req, res) => {
|
|
4557
|
+
noStore(res);
|
|
4558
|
+
try {
|
|
4559
|
+
const body = req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : null;
|
|
4560
|
+
const allowedKeys = new Set(['revision', 'cadence', 'viewScale']);
|
|
4561
|
+
if (!body || Object.keys(body).some(key => !allowedKeys.has(key))) {
|
|
4562
|
+
res.status(400).json({ ok: false, error: 'wall-preferences-invalid' });
|
|
4563
|
+
return;
|
|
4564
|
+
}
|
|
4565
|
+
const revision = Number(body.revision);
|
|
4566
|
+
const cadence = String(body.cadence || '').trim().toLowerCase();
|
|
4567
|
+
const viewScale = Number(body.viewScale);
|
|
4568
|
+
if (!Number.isSafeInteger(revision)
|
|
4569
|
+
|| revision < 0
|
|
4570
|
+
|| !['slow', 'fast'].includes(cadence)
|
|
4571
|
+
|| !Number.isFinite(viewScale)
|
|
4572
|
+
|| viewScale < 0
|
|
4573
|
+
|| viewScale > 100) {
|
|
4574
|
+
res.status(400).json({ ok: false, error: 'wall-preferences-invalid' });
|
|
4575
|
+
return;
|
|
4576
|
+
}
|
|
4577
|
+
const record = await liveDeskSettingsStore.update({ wall: { cadence, viewScale } }, revision);
|
|
4578
|
+
res.json(wallPreferencesResponse(record));
|
|
4579
|
+
} catch (error) {
|
|
4580
|
+
if (error instanceof SettingsConflictError) {
|
|
4581
|
+
const wall = error.settings?.settings?.wall || {};
|
|
4582
|
+
res.status(409).json(wallPreferencesResponse({
|
|
4583
|
+
revision: error.settings.revision,
|
|
4584
|
+
updatedAt: error.settings.updatedAt,
|
|
4585
|
+
cadence: wall.cadence,
|
|
4586
|
+
viewScale: wall.viewScale
|
|
4587
|
+
}));
|
|
4588
|
+
return;
|
|
4589
|
+
}
|
|
4590
|
+
res.status(error?.status || 400).json({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
4591
|
+
}
|
|
4592
|
+
});
|
|
4593
|
+
|
|
4594
|
+
function sendCaptureError(res, error) {
|
|
3980
4595
|
const rawCode = String(error?.message || error?.code || 'capture-request-failed');
|
|
3981
4596
|
const code = rawCode.replace(/[^a-z0-9-:]/gi, '-').toLowerCase().slice(0, 100);
|
|
3982
4597
|
const status = Number.isInteger(error?.status) && error.status >= 400 && error.status <= 599
|
|
@@ -4227,9 +4842,9 @@ app.post('/api/settings/agent/run', async (req, res) => {
|
|
|
4227
4842
|
return;
|
|
4228
4843
|
}
|
|
4229
4844
|
try {
|
|
4230
|
-
if (!(await synchronizeAgentEnablement())) {
|
|
4231
|
-
throw new AgentRuntimeError('agent-disabled', '
|
|
4232
|
-
}
|
|
4845
|
+
if (!(await synchronizeAgentEnablement())) {
|
|
4846
|
+
throw new AgentRuntimeError('agent-disabled', 'Codex Agent was explicitly turned off in Settings.', { status: 409 });
|
|
4847
|
+
}
|
|
4233
4848
|
const instruction = typeof req.body?.instruction === 'string' ? req.body.instruction.slice(0, 4000) : '';
|
|
4234
4849
|
const deviceIds = normalizeDeviceIds(req.body?.deviceIds).slice(0, 500);
|
|
4235
4850
|
const connectedDeviceIds = new Set(connectedAgentDeviceIds());
|
|
@@ -4345,20 +4960,23 @@ app.post('/api/settings/agent/summary', async (req, res) => {
|
|
|
4345
4960
|
}
|
|
4346
4961
|
});
|
|
4347
4962
|
|
|
4348
|
-
app.get('/api/remote/status', async (
|
|
4963
|
+
app.get('/api/remote/status', async (req, res) => {
|
|
4349
4964
|
noStore(res);
|
|
4350
4965
|
if (runtimeRole !== 'hub') {
|
|
4351
4966
|
res.status(403).json({ ok: false, error: 'role-not-allowed' });
|
|
4352
4967
|
return;
|
|
4353
|
-
}
|
|
4968
|
+
}
|
|
4354
4969
|
try {
|
|
4970
|
+
const includePairingPin = authorizedWorkspaceRole(req) === 'owner';
|
|
4355
4971
|
const [secretStatus, wallPreferences] = await Promise.all([
|
|
4356
|
-
Promise.resolve(remoteHub.getStatus({ includeSecrets: true })),
|
|
4972
|
+
Promise.resolve(includePairingPin ? remoteHub.getStatus({ includeSecrets: true }) : {}),
|
|
4357
4973
|
liveDeskSettingsStore.getWallPreferencesRecord()
|
|
4358
4974
|
]);
|
|
4975
|
+
const visibleStatus = { ...remoteHub.getStatus({ includeSecrets: false }) };
|
|
4976
|
+
if (!includePairingPin) delete visibleStatus.pairingPin;
|
|
4359
4977
|
res.json({
|
|
4360
|
-
...
|
|
4361
|
-
pairingPin: secretStatus.pairingPin,
|
|
4978
|
+
...visibleStatus,
|
|
4979
|
+
...(includePairingPin ? { pairingPin: secretStatus.pairingPin } : {}),
|
|
4362
4980
|
product: 'LiveDesk',
|
|
4363
4981
|
runtimeRole,
|
|
4364
4982
|
deviceId: runtimeDeviceId,
|
|
@@ -4366,6 +4984,7 @@ app.get('/api/remote/status', async (_req, res) => {
|
|
|
4366
4984
|
roleSource: runtimeRoleSource,
|
|
4367
4985
|
agentPackage: '@livedesk/client',
|
|
4368
4986
|
wallPreferences,
|
|
4987
|
+
workspace: workspaceAccessSnapshot(),
|
|
4369
4988
|
consoleDirect: hubConsoleDirect?.inspect() || { enabled: false, state: 'starting' },
|
|
4370
4989
|
frameLanes: snapshotFrameLaneResourceHealth(),
|
|
4371
4990
|
update: getLiveDeskUpdateStatus()
|
|
@@ -4546,72 +5165,242 @@ app.post('/api/runtime/switch-role', (req, res) => {
|
|
|
4546
5165
|
res.redirect(307, '/api/runtime/role');
|
|
4547
5166
|
});
|
|
4548
5167
|
|
|
4549
|
-
app.get('/api/auth/status', (_req, res) => {
|
|
4550
|
-
noStore(res);
|
|
4551
|
-
res.json({
|
|
4552
|
-
|
|
5168
|
+
app.get('/api/auth/status', (_req, res) => {
|
|
5169
|
+
noStore(res);
|
|
5170
|
+
res.json({
|
|
5171
|
+
ok: true,
|
|
5172
|
+
authenticated: runtimeManager.getSnapshot().authenticated,
|
|
5173
|
+
userId: runtimeManager.getSnapshot().userId || null,
|
|
5174
|
+
role: runtimeRole,
|
|
5175
|
+
workspace: workspaceAccessSnapshot()
|
|
5176
|
+
});
|
|
5177
|
+
});
|
|
4553
5178
|
|
|
4554
|
-
app.post('/api/auth/session', async (req, res) => {
|
|
4555
|
-
noStore(res);
|
|
4556
|
-
const
|
|
5179
|
+
app.post('/api/auth/session', async (req, res) => {
|
|
5180
|
+
noStore(res);
|
|
5181
|
+
const consoleProxyAuthorization = authorizeConsoleProxyRequest(req);
|
|
5182
|
+
if (consoleProxyAuthorization) {
|
|
5183
|
+
if (!consoleProxyAuthorization.ok) {
|
|
5184
|
+
res.status(consoleProxyAuthorization.status).json({ ok: false, error: consoleProxyAuthorization.error });
|
|
5185
|
+
return;
|
|
5186
|
+
}
|
|
5187
|
+
const validConsoleWorkspaceMember = consoleProxyAuthorization.workspaceKind === 'team'
|
|
5188
|
+
? ['owner', 'operator'].includes(consoleProxyAuthorization.role)
|
|
5189
|
+
: consoleProxyAuthorization.workspaceKind === 'personal'
|
|
5190
|
+
&& consoleProxyAuthorization.role === 'owner';
|
|
5191
|
+
if (!validConsoleWorkspaceMember
|
|
5192
|
+
|| !workspaceEntitlementCanRequest(consoleProxyAuthorization, 'POST', '/api/auth/session')) {
|
|
5193
|
+
res.status(403).json({ ok: false, error: 'workspace-member-session-required' });
|
|
5194
|
+
return;
|
|
5195
|
+
}
|
|
5196
|
+
const accessExpiresAt = consoleProxyAuthorization.workspaceKind === 'team'
|
|
5197
|
+
? Math.min(Number(consoleProxyAuthorization.accessExpiresAt || 0), Date.now() + TEAM_UI_ACCESS_MAX_AGE_MS)
|
|
5198
|
+
: 0;
|
|
5199
|
+
const uiSession = hubUiSessionAuthority.renew(req, consoleProxyAuthorization.userId, {
|
|
5200
|
+
workspaceId: consoleProxyAuthorization.workspaceId,
|
|
5201
|
+
workspaceKind: consoleProxyAuthorization.workspaceKind,
|
|
5202
|
+
role: consoleProxyAuthorization.role,
|
|
5203
|
+
plan: consoleProxyAuthorization.plan,
|
|
5204
|
+
entitlementStatus: consoleProxyAuthorization.entitlementStatus,
|
|
5205
|
+
membershipRevision: consoleProxyAuthorization.membershipRevision,
|
|
5206
|
+
accessExpiresAt
|
|
5207
|
+
});
|
|
5208
|
+
res.setHeader('Set-Cookie', serializeHubUiSessionCookie(uiSession.sessionId, uiSession.expiresAt, {
|
|
5209
|
+
secure: isSecureHubHttpRequest(req)
|
|
5210
|
+
}));
|
|
5211
|
+
res.json({
|
|
5212
|
+
ok: true,
|
|
5213
|
+
authenticated: true,
|
|
5214
|
+
persisted: false,
|
|
5215
|
+
runtimeOwner: false,
|
|
5216
|
+
userId: consoleProxyAuthorization.userId,
|
|
5217
|
+
role: runtimeRole,
|
|
5218
|
+
workspace: {
|
|
5219
|
+
workspaceId: consoleProxyAuthorization.workspaceId,
|
|
5220
|
+
kind: consoleProxyAuthorization.workspaceKind,
|
|
5221
|
+
role: consoleProxyAuthorization.role,
|
|
5222
|
+
membershipRevision: consoleProxyAuthorization.membershipRevision,
|
|
5223
|
+
plan: consoleProxyAuthorization.plan,
|
|
5224
|
+
status: consoleProxyAuthorization.entitlementStatus
|
|
5225
|
+
},
|
|
5226
|
+
license: licenseSnapshot(),
|
|
5227
|
+
uiSession: {
|
|
5228
|
+
csrfToken: uiSession.csrfToken,
|
|
5229
|
+
expiresAt: uiSession.expiresAt,
|
|
5230
|
+
accessExpiresAt
|
|
5231
|
+
}
|
|
5232
|
+
});
|
|
5233
|
+
return;
|
|
5234
|
+
}
|
|
5235
|
+
const requestAuthEpoch = runtimeAuthEpoch;
|
|
5236
|
+
const requestBoundUserId = currentBoundHubUserId();
|
|
5237
|
+
const requestBoundWorkspaceId = currentBoundHubWorkspaceId();
|
|
5238
|
+
const normalized = normalizeRuntimeAuthSession(req.body);
|
|
4557
5239
|
if (!normalized.ok) {
|
|
4558
5240
|
res.status(400).json({ ok: false, error: normalized.error });
|
|
4559
5241
|
return;
|
|
4560
|
-
}
|
|
4561
|
-
const { access_token: accessToken, refresh_token: refreshToken, expires_at: expiresAt } = normalized.session;
|
|
4562
|
-
try {
|
|
4563
|
-
const user = await verifySupabaseUser(accessToken);
|
|
4564
|
-
const
|
|
4565
|
-
|
|
4566
|
-
|
|
4567
|
-
|
|
4568
|
-
|
|
4569
|
-
|
|
4570
|
-
|
|
4571
|
-
|
|
4572
|
-
|
|
4573
|
-
|
|
4574
|
-
|
|
4575
|
-
|
|
4576
|
-
|
|
5242
|
+
}
|
|
5243
|
+
const { access_token: accessToken, refresh_token: refreshToken, expires_at: expiresAt } = normalized.session;
|
|
5244
|
+
try {
|
|
5245
|
+
const user = await verifySupabaseUser(accessToken);
|
|
5246
|
+
const explicitlyRequestedWorkspaceId = req.body?.workspaceId ?? req.body?.workspace_id ?? '';
|
|
5247
|
+
const authoritativeWorkspaceId = await queryRuntimeDeviceWorkspace(accessToken);
|
|
5248
|
+
const nativeRuntimeWorkspaceFallback = !String(explicitlyRequestedWorkspaceId || '').trim()
|
|
5249
|
+
&& isTrustedNativeLoopbackRequest(req)
|
|
5250
|
+
? currentBoundHubWorkspaceId() || authoritativeWorkspaceId
|
|
5251
|
+
: '';
|
|
5252
|
+
const access = await verifyWorkspaceAccess(
|
|
5253
|
+
accessToken,
|
|
5254
|
+
user,
|
|
5255
|
+
explicitlyRequestedWorkspaceId || nativeRuntimeWorkspaceFallback
|
|
5256
|
+
);
|
|
5257
|
+
const boundUserId = currentBoundHubUserId();
|
|
5258
|
+
const boundWorkspaceId = currentBoundHubWorkspaceId();
|
|
5259
|
+
if (runtimeAuthEpoch !== requestAuthEpoch
|
|
5260
|
+
|| boundUserId !== requestBoundUserId
|
|
5261
|
+
|| boundWorkspaceId !== requestBoundWorkspaceId) {
|
|
5262
|
+
res.status(409).json({ ok: false, error: 'hub-auth-session-superseded' });
|
|
5263
|
+
return;
|
|
5264
|
+
}
|
|
5265
|
+
if (authoritativeWorkspaceId && authoritativeWorkspaceId !== access.workspaceId) {
|
|
5266
|
+
res.status(403).json({ ok: false, error: 'hub-device-workspace-mismatch' });
|
|
5267
|
+
return;
|
|
5268
|
+
}
|
|
5269
|
+
const sameRuntimeOwner = Boolean(boundUserId) && boundUserId === user.id;
|
|
5270
|
+
const memberUiOnly = Boolean(boundUserId)
|
|
5271
|
+
&& boundWorkspaceId === access.workspaceId
|
|
5272
|
+
&& ['owner', 'operator'].includes(access.role)
|
|
5273
|
+
&& (!sameRuntimeOwner
|
|
5274
|
+
|| access.role !== 'owner'
|
|
5275
|
+
|| !isTrustedNativeLoopbackRequest(req));
|
|
5276
|
+
const ownerLocalWorkspaceRebind = sameRuntimeOwner
|
|
5277
|
+
&& boundWorkspaceId
|
|
5278
|
+
&& boundWorkspaceId !== access.workspaceId
|
|
5279
|
+
&& access.role === 'owner'
|
|
5280
|
+
&& isTrustedNativeLoopbackRequest(req)
|
|
5281
|
+
&& authoritativeWorkspaceId === access.workspaceId;
|
|
5282
|
+
if (boundWorkspaceId && boundWorkspaceId !== access.workspaceId && !ownerLocalWorkspaceRebind) {
|
|
5283
|
+
res.status(403).json({ ok: false, error: 'hub-workspace-mismatch' });
|
|
5284
|
+
return;
|
|
5285
|
+
}
|
|
5286
|
+
if (!boundUserId
|
|
5287
|
+
&& (access.role !== 'owner' || !isLoopbackAddress(req.socket?.remoteAddress))) {
|
|
5288
|
+
res.status(403).json({
|
|
5289
|
+
ok: false,
|
|
5290
|
+
error: access.role === 'owner'
|
|
5291
|
+
? 'hub-first-account-binding-must-be-local'
|
|
5292
|
+
: 'hub-owner-session-required'
|
|
5293
|
+
});
|
|
5294
|
+
return;
|
|
5295
|
+
}
|
|
5296
|
+
if (!memberUiOnly && !refreshToken) {
|
|
5297
|
+
res.status(400).json({ ok: false, error: 'refresh-token-required' });
|
|
5298
|
+
return;
|
|
5299
|
+
}
|
|
5300
|
+
if (ownerLocalWorkspaceRebind) {
|
|
5301
|
+
hubUiSessionAuthority.revokeAll('hub-workspace-rebound');
|
|
5302
|
+
}
|
|
5303
|
+
if (access.workspaceKind === 'team' && !workspaceRemoteAccessActive(access)) {
|
|
5304
|
+
hubUiSessionAuthority.revokeWorkspaceMember(access.workspaceId, user.id);
|
|
5305
|
+
}
|
|
5306
|
+
const uiAccessExpiresAt = access.workspaceKind === 'team'
|
|
5307
|
+
? Date.now() + TEAM_UI_ACCESS_MAX_AGE_MS
|
|
5308
|
+
: 0;
|
|
5309
|
+
const uiSession = hubUiSessionAuthority.renew(req, user.id, {
|
|
5310
|
+
workspaceId: access.workspaceId,
|
|
5311
|
+
workspaceKind: access.workspaceKind,
|
|
5312
|
+
role: access.role,
|
|
5313
|
+
plan: access.plan,
|
|
5314
|
+
entitlementStatus: access.entitlementStatus,
|
|
5315
|
+
membershipRevision: access.membershipRevision,
|
|
5316
|
+
accessExpiresAt: uiAccessExpiresAt
|
|
5317
|
+
});
|
|
5318
|
+
res.setHeader('Set-Cookie', serializeHubUiSessionCookie(uiSession.sessionId, uiSession.expiresAt, {
|
|
5319
|
+
secure: isSecureHubHttpRequest(req)
|
|
5320
|
+
}));
|
|
5321
|
+
if (memberUiOnly) {
|
|
5322
|
+
res.json({
|
|
5323
|
+
ok: true,
|
|
5324
|
+
authenticated: true,
|
|
5325
|
+
persisted: false,
|
|
5326
|
+
runtimeOwner: false,
|
|
5327
|
+
userId: user.id,
|
|
5328
|
+
role: runtimeRole,
|
|
5329
|
+
workspace: workspaceAccessSnapshot(access),
|
|
5330
|
+
license: licenseSnapshot(),
|
|
5331
|
+
hostTarget: {
|
|
5332
|
+
ok: true,
|
|
5333
|
+
pending: false,
|
|
5334
|
+
active: remoteHub.getStatus({ includeSecrets: false }).hostTargetActive === true
|
|
5335
|
+
},
|
|
5336
|
+
uiSession: {
|
|
5337
|
+
csrfToken: uiSession.csrfToken,
|
|
5338
|
+
expiresAt: uiSession.expiresAt,
|
|
5339
|
+
accessExpiresAt: uiAccessExpiresAt
|
|
5340
|
+
}
|
|
5341
|
+
});
|
|
5342
|
+
return;
|
|
5343
|
+
}
|
|
5344
|
+
if (boundUserId && !sameRuntimeOwner) {
|
|
5345
|
+
res.status(403).json({ ok: false, error: 'hub-account-mismatch' });
|
|
5346
|
+
return;
|
|
5347
|
+
}
|
|
5348
|
+
runtimeAuthEpoch += 1;
|
|
5349
|
+
runtimeAccessToken = accessToken;
|
|
5350
|
+
runtimeRefreshToken = refreshToken;
|
|
5351
|
+
runtimeAccessTokenExpiresAt = normalizeSessionExpiryMs(expiresAt);
|
|
5352
|
+
runtimeWorkspaceAccess = access;
|
|
5353
|
+
if (ownerLocalWorkspaceRebind) {
|
|
5354
|
+
hubConsoleDirect?.invalidateWorkspaceAccess('hub-workspace-rebound');
|
|
5355
|
+
}
|
|
5356
|
+
workspaceAccessLastAttemptAt = Date.now();
|
|
5357
|
+
licenseRefreshLastAttemptAt = Date.now();
|
|
5358
|
+
runtimeManager.setAuthenticated(true, user.id);
|
|
4577
5359
|
const persisted = persistRuntimeSession({
|
|
4578
5360
|
access_token: accessToken,
|
|
4579
5361
|
refresh_token: refreshToken,
|
|
4580
5362
|
expires_at: expiresAt,
|
|
4581
|
-
user
|
|
4582
|
-
});
|
|
4583
|
-
const
|
|
4584
|
-
|
|
4585
|
-
|
|
4586
|
-
|
|
4587
|
-
|
|
4588
|
-
|
|
4589
|
-
|
|
4590
|
-
|
|
4591
|
-
|
|
4592
|
-
|
|
4593
|
-
|
|
4594
|
-
|
|
5363
|
+
user
|
|
5364
|
+
});
|
|
5365
|
+
const workspacePersisted = persistRuntimeWorkspace(access);
|
|
5366
|
+
applyVerifiedWorkspaceLicense(access);
|
|
5367
|
+
const teamRemoteActive = workspaceRemoteAccessActive(access);
|
|
5368
|
+
let hostTarget = runtimeRole === 'hub'
|
|
5369
|
+
? {
|
|
5370
|
+
ok: true,
|
|
5371
|
+
pending: true,
|
|
5372
|
+
active: remoteHub.getStatus({ includeSecrets: false }).hostTargetActive === true
|
|
5373
|
+
}
|
|
5374
|
+
: { ok: true, pending: false, active: false };
|
|
5375
|
+
if (runtimeRole === 'hub' && !teamRemoteActive) {
|
|
5376
|
+
hubConsoleDirect?.close();
|
|
5377
|
+
const cleared = await clearHubHostTarget('workspace-entitlement-inactive');
|
|
5378
|
+
hostTarget = { ok: cleared.ok, pending: false, active: false, error: cleared.error || '' };
|
|
5379
|
+
}
|
|
5380
|
+
res.json({
|
|
4595
5381
|
ok: true,
|
|
4596
|
-
authenticated: true,
|
|
4597
|
-
persisted,
|
|
4598
|
-
|
|
4599
|
-
|
|
5382
|
+
authenticated: true,
|
|
5383
|
+
persisted: persisted && workspacePersisted,
|
|
5384
|
+
runtimeOwner: true,
|
|
5385
|
+
userId: user.id,
|
|
5386
|
+
role: runtimeRole,
|
|
5387
|
+
workspace: workspaceAccessSnapshot(access),
|
|
5388
|
+
license: licenseSnapshot(),
|
|
4600
5389
|
hostTarget,
|
|
4601
5390
|
uiSession: {
|
|
4602
5391
|
csrfToken: uiSession.csrfToken,
|
|
4603
5392
|
expiresAt: uiSession.expiresAt
|
|
4604
5393
|
}
|
|
4605
5394
|
});
|
|
4606
|
-
if (runtimeRole === 'hub') {
|
|
5395
|
+
if (runtimeRole === 'hub' && teamRemoteActive) {
|
|
4607
5396
|
hubConsoleDirect?.refresh();
|
|
4608
5397
|
scheduleAuthenticatedHubHostTargetPublication('session-received');
|
|
4609
5398
|
}
|
|
4610
|
-
} catch (error) {
|
|
4611
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
4612
|
-
const status =
|
|
4613
|
-
res.status(status).json({ ok: false, error: message });
|
|
4614
|
-
}
|
|
5399
|
+
} catch (error) {
|
|
5400
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
5401
|
+
const status = workspaceAccessHttpStatus(error);
|
|
5402
|
+
res.status(status).json({ ok: false, error: message });
|
|
5403
|
+
}
|
|
4615
5404
|
});
|
|
4616
5405
|
|
|
4617
5406
|
function updateHubHostTargetLeaseState(patch = {}) {
|
|
@@ -4643,19 +5432,20 @@ function hubWakeSignalUrl() {
|
|
|
4643
5432
|
return url.toString();
|
|
4644
5433
|
}
|
|
4645
5434
|
|
|
4646
|
-
function buildHubWakeEventId(target, remoteStatus, endpointCandidates) {
|
|
4647
|
-
return crypto.createHash('sha256').update(JSON.stringify({
|
|
4648
|
-
|
|
5435
|
+
function buildHubWakeEventId(target, remoteStatus, endpointCandidates, workspaceId = currentBoundHubWorkspaceId()) {
|
|
5436
|
+
return crypto.createHash('sha256').update(JSON.stringify({
|
|
5437
|
+
workspaceId,
|
|
5438
|
+
deviceId: runtimeDeviceId,
|
|
4649
5439
|
leaseId: target.leaseId || '',
|
|
4650
5440
|
hostInstanceId: target.hostInstanceId || remoteStatus.hostInstanceId || '',
|
|
4651
5441
|
endpointCandidates: [...endpointCandidates].sort()
|
|
4652
5442
|
})).digest('hex').slice(0, 40);
|
|
4653
5443
|
}
|
|
4654
5444
|
|
|
4655
|
-
async function notifyHubOnline({ accessToken, target, remoteStatus, endpointCandidates }) {
|
|
4656
|
-
const url = hubWakeSignalUrl();
|
|
4657
|
-
if (!url) return { ok: false, skipped: true, reason: 'wake-disabled' };
|
|
4658
|
-
const eventId = buildHubWakeEventId(target, remoteStatus, endpointCandidates);
|
|
5445
|
+
async function notifyHubOnline({ accessToken, target, remoteStatus, endpointCandidates, workspaceId }) {
|
|
5446
|
+
const url = hubWakeSignalUrl();
|
|
5447
|
+
if (!url) return { ok: false, skipped: true, reason: 'wake-disabled' };
|
|
5448
|
+
const eventId = buildHubWakeEventId(target, remoteStatus, endpointCandidates, workspaceId);
|
|
4659
5449
|
if (hubWakeNotificationState.lastEventId === eventId && hubWakeNotificationState.state === 'notified') {
|
|
4660
5450
|
return { ok: true, skipped: true, eventId };
|
|
4661
5451
|
}
|
|
@@ -4678,7 +5468,11 @@ async function notifyHubOnline({ accessToken, target, remoteStatus, endpointCand
|
|
|
4678
5468
|
'Content-Type': 'application/json',
|
|
4679
5469
|
Accept: 'application/json'
|
|
4680
5470
|
},
|
|
4681
|
-
body: JSON.stringify({
|
|
5471
|
+
body: JSON.stringify({
|
|
5472
|
+
workspaceId,
|
|
5473
|
+
deviceId: runtimeDeviceId,
|
|
5474
|
+
eventId
|
|
5475
|
+
})
|
|
4682
5476
|
}, SUPABASE_AUTH_TIMEOUT_MS);
|
|
4683
5477
|
const data = await response.json().catch(() => null);
|
|
4684
5478
|
if (!response.ok || data?.ok === false) {
|
|
@@ -4727,9 +5521,8 @@ async function callHubSupabaseRpc(name, payload, accessToken) {
|
|
|
4727
5521
|
);
|
|
4728
5522
|
const data = await response.json().catch(() => null);
|
|
4729
5523
|
const result = Array.isArray(data) ? data[0] : data;
|
|
4730
|
-
if (response.status === 401 || response.status === 403) {
|
|
4731
|
-
|
|
4732
|
-
throw new Error(`${name}-not-authenticated:${response.status}`);
|
|
5524
|
+
if (response.status === 401 || response.status === 403) {
|
|
5525
|
+
throw new Error(`${name}-not-authenticated:${response.status}`);
|
|
4733
5526
|
}
|
|
4734
5527
|
if (!response.ok) {
|
|
4735
5528
|
throw new Error(`${name}-failed:${response.status}`);
|
|
@@ -4740,16 +5533,21 @@ async function callHubSupabaseRpc(name, payload, accessToken) {
|
|
|
4740
5533
|
return result;
|
|
4741
5534
|
}
|
|
4742
5535
|
|
|
4743
|
-
async function promoteCurrentRuntimeToHub() {
|
|
4744
|
-
if (runtimeRole !== 'hub' || !runtimeDeviceId) {
|
|
4745
|
-
throw new Error('hub-runtime-identity-unavailable');
|
|
4746
|
-
}
|
|
5536
|
+
async function promoteCurrentRuntimeToHub() {
|
|
5537
|
+
if (runtimeRole !== 'hub' || !runtimeDeviceId) {
|
|
5538
|
+
throw new Error('hub-runtime-identity-unavailable');
|
|
5539
|
+
}
|
|
5540
|
+
if (runtimeWorkspaceAccess?.workspaceKind === 'team'
|
|
5541
|
+
&& !workspaceRemoteAccessActive(runtimeWorkspaceAccess)) {
|
|
5542
|
+
throw new Error('workspace-entitlement-inactive');
|
|
5543
|
+
}
|
|
4747
5544
|
const accessToken = await getRuntimeAccessToken();
|
|
4748
5545
|
if (!accessToken) {
|
|
4749
5546
|
throw new Error('hub-session-required');
|
|
4750
5547
|
}
|
|
4751
|
-
const result = await callHubSupabaseRpc('
|
|
4752
|
-
|
|
5548
|
+
const result = await callHubSupabaseRpc('set_livedesk_workspace_device_role', {
|
|
5549
|
+
p_workspace_id: currentBoundHubWorkspaceId(),
|
|
5550
|
+
p_device_id: runtimeDeviceId,
|
|
4753
5551
|
p_role: 'hub',
|
|
4754
5552
|
p_assigned_hub_id: null,
|
|
4755
5553
|
p_expected_role_version: null
|
|
@@ -4758,10 +5556,22 @@ async function promoteCurrentRuntimeToHub() {
|
|
|
4758
5556
|
return result;
|
|
4759
5557
|
}
|
|
4760
5558
|
|
|
4761
|
-
async function publishHubHostTarget({ takeover = false, reason = 'renewal' } = {}) {
|
|
4762
|
-
|
|
4763
|
-
|
|
4764
|
-
|
|
5559
|
+
async function publishHubHostTarget({ takeover = false, reason = 'renewal' } = {}) {
|
|
5560
|
+
const operationAuthEpoch = runtimeAuthEpoch;
|
|
5561
|
+
const operationUserId = currentBoundHubUserId();
|
|
5562
|
+
const operationWorkspaceId = currentBoundHubWorkspaceId();
|
|
5563
|
+
if (runtimeRole !== 'hub') {
|
|
5564
|
+
throw new Error('hub-role-not-allowed');
|
|
5565
|
+
}
|
|
5566
|
+
if (runtimeWorkspaceAccess?.workspaceKind === 'team'
|
|
5567
|
+
&& !workspaceRemoteAccessActive(runtimeWorkspaceAccess)) {
|
|
5568
|
+
updateHubHostTargetLeaseState({
|
|
5569
|
+
state: 'inactive',
|
|
5570
|
+
lastReason: String(reason || 'renewal'),
|
|
5571
|
+
lastError: 'workspace-entitlement-inactive'
|
|
5572
|
+
});
|
|
5573
|
+
return { ok: false, active: false, error: 'workspace-entitlement-inactive' };
|
|
5574
|
+
}
|
|
4765
5575
|
if (hubHostTargetRenewInFlight) {
|
|
4766
5576
|
return { ok: false, error: 'host-target-publish-already-running', hostTarget: remoteHub.getStatus({ includeSecrets: false }) };
|
|
4767
5577
|
}
|
|
@@ -4777,7 +5587,10 @@ async function publishHubHostTarget({ takeover = false, reason = 'renewal' } = {
|
|
|
4777
5587
|
if (!accessToken) {
|
|
4778
5588
|
throw new Error('hub-session-required');
|
|
4779
5589
|
}
|
|
4780
|
-
const authoritativeRole = await queryAuthoritativeRuntimeRole();
|
|
5590
|
+
const authoritativeRole = await queryAuthoritativeRuntimeRole();
|
|
5591
|
+
if (!runtimeAuthOwnerIsCurrent(operationAuthEpoch, operationUserId, operationWorkspaceId)) {
|
|
5592
|
+
return { ok: false, superseded: true, error: 'host-target-publish-superseded' };
|
|
5593
|
+
}
|
|
4781
5594
|
if (authoritativeRole !== 'hub') {
|
|
4782
5595
|
throw new Error(authoritativeRole === 'client' ? 'hub-role-no-longer-authoritative' : 'hub-role-not-authoritative');
|
|
4783
5596
|
}
|
|
@@ -4788,9 +5601,12 @@ async function publishHubHostTarget({ takeover = false, reason = 'renewal' } = {
|
|
|
4788
5601
|
leaseMs: HUB_HOST_TARGET_LEASE_MS,
|
|
4789
5602
|
takeover
|
|
4790
5603
|
});
|
|
4791
|
-
if (!local?.ok || !local.hostTarget) {
|
|
4792
|
-
throw new Error(local?.error || 'local-host-target-failed');
|
|
4793
|
-
}
|
|
5604
|
+
if (!local?.ok || !local.hostTarget) {
|
|
5605
|
+
throw new Error(local?.error || 'local-host-target-failed');
|
|
5606
|
+
}
|
|
5607
|
+
if (!runtimeAuthOwnerIsCurrent(operationAuthEpoch, operationUserId, operationWorkspaceId)) {
|
|
5608
|
+
return { ok: false, superseded: true, error: 'host-target-publish-superseded' };
|
|
5609
|
+
}
|
|
4794
5610
|
|
|
4795
5611
|
const remoteStatus = remoteHub.getStatus({ includeSecrets: true });
|
|
4796
5612
|
if (!remoteStatus.agentEndpointAccountRouteReady) {
|
|
@@ -4800,8 +5616,9 @@ async function publishHubHostTarget({ takeover = false, reason = 'renewal' } = {
|
|
|
4800
5616
|
const endpointCandidates = Array.isArray(target.endpointCandidates) && target.endpointCandidates.length > 0
|
|
4801
5617
|
? target.endpointCandidates
|
|
4802
5618
|
: [target.endpoint].filter(Boolean);
|
|
4803
|
-
await callHubSupabaseRpc('
|
|
4804
|
-
|
|
5619
|
+
await callHubSupabaseRpc('set_livedesk_workspace_remote_host_target', {
|
|
5620
|
+
p_workspace_id: operationWorkspaceId,
|
|
5621
|
+
p_node_id: runtimeDeviceId,
|
|
4805
5622
|
p_lease_id: target.leaseId,
|
|
4806
5623
|
p_host_instance_id: target.hostInstanceId || remoteStatus.hostInstanceId || runtimeDeviceId,
|
|
4807
5624
|
p_endpoint: target.endpoint || remoteStatus.agentEndpoint,
|
|
@@ -4815,7 +5632,19 @@ async function publishHubHostTarget({ takeover = false, reason = 'renewal' } = {
|
|
|
4815
5632
|
p_endpoint_candidates: endpointCandidates,
|
|
4816
5633
|
p_pairing_pin: remoteStatus.pairingPin || ''
|
|
4817
5634
|
}, accessToken);
|
|
4818
|
-
|
|
5635
|
+
if (!runtimeAuthOwnerIsCurrent(operationAuthEpoch, operationUserId, operationWorkspaceId)) {
|
|
5636
|
+
return { ok: false, superseded: true, error: 'host-target-publish-superseded' };
|
|
5637
|
+
}
|
|
5638
|
+
await notifyHubOnline({
|
|
5639
|
+
accessToken,
|
|
5640
|
+
target,
|
|
5641
|
+
remoteStatus,
|
|
5642
|
+
endpointCandidates,
|
|
5643
|
+
workspaceId: operationWorkspaceId
|
|
5644
|
+
});
|
|
5645
|
+
if (!runtimeAuthOwnerIsCurrent(operationAuthEpoch, operationUserId, operationWorkspaceId)) {
|
|
5646
|
+
return { ok: false, superseded: true, error: 'host-target-publish-superseded' };
|
|
5647
|
+
}
|
|
4819
5648
|
|
|
4820
5649
|
updateHubHostTargetLeaseState({
|
|
4821
5650
|
state: 'active',
|
|
@@ -4851,7 +5680,10 @@ async function publishHubHostTargetWithPendingRoleTakeover(reason = 'renewal') {
|
|
|
4851
5680
|
return result;
|
|
4852
5681
|
}
|
|
4853
5682
|
|
|
4854
|
-
async function clearHubHostTarget(reason = 'shutdown') {
|
|
5683
|
+
async function clearHubHostTarget(reason = 'shutdown') {
|
|
5684
|
+
const operationAuthEpoch = runtimeAuthEpoch;
|
|
5685
|
+
const operationUserId = currentBoundHubUserId();
|
|
5686
|
+
const operationWorkspaceId = currentBoundHubWorkspaceId();
|
|
4855
5687
|
if (hubHostTargetRenewTimer) {
|
|
4856
5688
|
clearInterval(hubHostTargetRenewTimer);
|
|
4857
5689
|
hubHostTargetRenewTimer = null;
|
|
@@ -4864,18 +5696,24 @@ async function clearHubHostTarget(reason = 'shutdown') {
|
|
|
4864
5696
|
try {
|
|
4865
5697
|
const accessToken = await getRuntimeAccessToken();
|
|
4866
5698
|
if (accessToken && targetNodeId && targetLeaseId && targetInstanceId) {
|
|
4867
|
-
await callHubSupabaseRpc('
|
|
4868
|
-
|
|
5699
|
+
await callHubSupabaseRpc('clear_livedesk_workspace_remote_host_target', {
|
|
5700
|
+
p_workspace_id: operationWorkspaceId,
|
|
5701
|
+
p_node_id: targetNodeId,
|
|
4869
5702
|
p_lease_id: targetLeaseId,
|
|
4870
5703
|
p_host_instance_id: targetInstanceId,
|
|
4871
5704
|
p_force: false
|
|
4872
5705
|
}, accessToken);
|
|
4873
5706
|
}
|
|
4874
|
-
} catch (cause) {
|
|
4875
|
-
error = cause instanceof Error ? cause.message : String(cause);
|
|
4876
|
-
console.error(`[VuvoDesk Hub] Host target ${reason} clear failed: ${error}`);
|
|
4877
|
-
}
|
|
4878
|
-
|
|
5707
|
+
} catch (cause) {
|
|
5708
|
+
error = cause instanceof Error ? cause.message : String(cause);
|
|
5709
|
+
console.error(`[VuvoDesk Hub] Host target ${reason} clear failed: ${error}`);
|
|
5710
|
+
}
|
|
5711
|
+
const currentRemoteStatus = remoteHub.getStatus({ includeSecrets: false });
|
|
5712
|
+
if (!runtimeAuthOwnerIsCurrent(operationAuthEpoch, operationUserId, operationWorkspaceId)
|
|
5713
|
+
|| (targetLeaseId && String(currentRemoteStatus.hostTargetLeaseId || '') !== targetLeaseId)) {
|
|
5714
|
+
return { ok: true, superseded: true, error, lease: getHubHostTargetLeaseStatus() };
|
|
5715
|
+
}
|
|
5716
|
+
try {
|
|
4879
5717
|
await remoteHub.setHostTarget({ enabled: false, nodeId: targetNodeId });
|
|
4880
5718
|
} catch (cause) {
|
|
4881
5719
|
error = error || (cause instanceof Error ? cause.message : String(cause));
|
|
@@ -4914,16 +5752,31 @@ function scheduleAuthenticatedHubHostTargetPublication(reason = 'session-receive
|
|
|
4914
5752
|
});
|
|
4915
5753
|
}
|
|
4916
5754
|
|
|
4917
|
-
app.delete('/api/auth/session', async (req, res) => {
|
|
4918
|
-
noStore(res);
|
|
5755
|
+
app.delete('/api/auth/session', async (req, res) => {
|
|
5756
|
+
noStore(res);
|
|
5757
|
+
const nativeOwnerLogout = isTrustedNativeLoopbackRequest(req)
|
|
5758
|
+
&& !String(req.headers['x-livedesk-console-proxy'] || '').trim();
|
|
5759
|
+
if (!nativeOwnerLogout) {
|
|
5760
|
+
const revoked = hubUiSessionAuthority.revoke(req);
|
|
5761
|
+
res.setHeader('Set-Cookie', clearHubUiSessionCookie({ secure: isSecureHubHttpRequest(req) }));
|
|
5762
|
+
res.json({
|
|
5763
|
+
ok: true,
|
|
5764
|
+
authenticated: runtimeManager.getSnapshot().authenticated,
|
|
5765
|
+
role: runtimeRole,
|
|
5766
|
+
runtimePreserved: true,
|
|
5767
|
+
signedOutUserId: revoked?.userId || null,
|
|
5768
|
+
workspaceId: revoked?.workspaceId || currentBoundHubWorkspaceId()
|
|
5769
|
+
});
|
|
5770
|
+
return;
|
|
5771
|
+
}
|
|
4919
5772
|
const hostTarget = runtimeRole === 'hub'
|
|
4920
5773
|
? await clearHubHostTarget('logout')
|
|
4921
5774
|
: { ok: true, active: false };
|
|
4922
5775
|
clearRuntimeSession();
|
|
4923
5776
|
hubConsoleDirect?.close();
|
|
4924
5777
|
res.setHeader('Set-Cookie', clearHubUiSessionCookie({ secure: isSecureHubHttpRequest(req) }));
|
|
4925
|
-
res.json({ ok: true, authenticated: false, role: runtimeRole, hostTarget });
|
|
4926
|
-
});
|
|
5778
|
+
res.json({ ok: true, authenticated: false, role: runtimeRole, runtimePreserved: false, hostTarget });
|
|
5779
|
+
});
|
|
4927
5780
|
|
|
4928
5781
|
app.get('/api/hub/status', (_req, res) => {
|
|
4929
5782
|
noStore(res);
|
|
@@ -5015,34 +5868,19 @@ app.post('/api/runtime/role', async (req, res) => {
|
|
|
5015
5868
|
res.status(400).json({ ok: false, error: 'hub-can-only-transition-to-client' });
|
|
5016
5869
|
return;
|
|
5017
5870
|
}
|
|
5018
|
-
const
|
|
5019
|
-
const accessToken = authorization.replace(/^Bearer\s+/i, '').trim();
|
|
5871
|
+
const accessToken = await getRuntimeAccessToken().catch(() => '');
|
|
5020
5872
|
if (!accessToken || !runtimeDeviceId) {
|
|
5021
5873
|
res.status(401).json({ ok: false, error: 'supabase-access-token-and-device-id-required' });
|
|
5022
5874
|
return;
|
|
5023
5875
|
}
|
|
5024
5876
|
try {
|
|
5025
|
-
const
|
|
5026
|
-
|
|
5027
|
-
|
|
5028
|
-
|
|
5029
|
-
|
|
5030
|
-
|
|
5031
|
-
|
|
5032
|
-
},
|
|
5033
|
-
body: JSON.stringify({
|
|
5034
|
-
p_device_id: runtimeDeviceId,
|
|
5035
|
-
p_role: 'client',
|
|
5036
|
-
p_assigned_hub_id: String(req.body?.assignedHubId || '').trim() || null,
|
|
5037
|
-
p_expected_role_version: Number.isInteger(Number(req.body?.expectedRoleVersion)) ? Number(req.body.expectedRoleVersion) : null
|
|
5038
|
-
})
|
|
5039
|
-
});
|
|
5040
|
-
const data = await response.json().catch(() => null);
|
|
5041
|
-
const result = Array.isArray(data) ? data[0] : data;
|
|
5042
|
-
if (!response.ok || result?.ok === false) {
|
|
5043
|
-
res.status(409).json({ ok: false, error: result?.reason || `role-change-rejected:${response.status}` });
|
|
5044
|
-
return;
|
|
5045
|
-
}
|
|
5877
|
+
const result = await callHubSupabaseRpc('set_livedesk_workspace_device_role', {
|
|
5878
|
+
p_workspace_id: currentBoundHubWorkspaceId(),
|
|
5879
|
+
p_device_id: runtimeDeviceId,
|
|
5880
|
+
p_role: 'client',
|
|
5881
|
+
p_assigned_hub_id: String(req.body?.assignedHubId || '').trim() || null,
|
|
5882
|
+
p_expected_role_version: Number.isInteger(Number(req.body?.expectedRoleVersion)) ? Number(req.body.expectedRoleVersion) : null
|
|
5883
|
+
}, accessToken);
|
|
5046
5884
|
await clearHubHostTarget('role-transition');
|
|
5047
5885
|
res.json({ ok: true, restarting: true, role: 'client', roleVersion: result?.role_version || 0 });
|
|
5048
5886
|
setTimeout(() => process.exit(ROLE_TRANSITION_EXIT_CODE), 150);
|
|
@@ -5090,38 +5928,103 @@ app.get('/api/remote/registry-credentials', (_req, res) => {
|
|
|
5090
5928
|
});
|
|
5091
5929
|
});
|
|
5092
5930
|
|
|
5093
|
-
app.get('/api/remote/license', (_req, res) => {
|
|
5931
|
+
app.get('/api/remote/license', (_req, res) => {
|
|
5094
5932
|
noStore(res);
|
|
5095
|
-
res.json(licenseSnapshot());
|
|
5096
|
-
});
|
|
5097
|
-
|
|
5098
|
-
app.post('/api/remote/
|
|
5933
|
+
res.json(licenseSnapshot());
|
|
5934
|
+
});
|
|
5935
|
+
|
|
5936
|
+
app.post('/api/remote/workspace/members/:userId/revoke', async (req, res) => {
|
|
5937
|
+
noStore(res);
|
|
5938
|
+
const workspaceId = String(req.body?.workspaceId || '').trim();
|
|
5939
|
+
const memberUserId = String(req.params.userId || '').trim().slice(0, 160);
|
|
5940
|
+
if (authorizedWorkspaceRole(req) !== 'owner') {
|
|
5941
|
+
res.status(403).json({ ok: false, error: 'workspace-owner-required' });
|
|
5942
|
+
return;
|
|
5943
|
+
}
|
|
5944
|
+
if (!workspaceId || workspaceId !== currentBoundHubWorkspaceId() || !memberUserId) {
|
|
5945
|
+
res.status(400).json({ ok: false, error: 'workspace-and-member-required' });
|
|
5946
|
+
return;
|
|
5947
|
+
}
|
|
5948
|
+
const retiredPeers = hubConsoleDirect?.revokeWorkspaceMember(
|
|
5949
|
+
workspaceId,
|
|
5950
|
+
memberUserId,
|
|
5951
|
+
'workspace-member-revoked'
|
|
5952
|
+
) || 0;
|
|
5953
|
+
const revokedUiSessions = hubUiSessionAuthority.revokeWorkspaceMember(workspaceId, memberUserId);
|
|
5954
|
+
let wakeRevoked = false;
|
|
5955
|
+
let wakeError = '';
|
|
5956
|
+
try {
|
|
5957
|
+
const accessToken = await getRuntimeAccessToken();
|
|
5958
|
+
if (!accessToken) throw new Error('hub-session-required');
|
|
5959
|
+
const revokeUrl = new URL('/v2/rtc/revoke-member', hubWakeBaseUrl);
|
|
5960
|
+
const response = await fetchAuthResponse(fetch, revokeUrl.href, {
|
|
5961
|
+
method: 'POST',
|
|
5962
|
+
headers: {
|
|
5963
|
+
Authorization: `Bearer ${accessToken}`,
|
|
5964
|
+
'Content-Type': 'application/json',
|
|
5965
|
+
Accept: 'application/json'
|
|
5966
|
+
},
|
|
5967
|
+
body: JSON.stringify({ workspaceId, memberUserId })
|
|
5968
|
+
}, SUPABASE_AUTH_TIMEOUT_MS);
|
|
5969
|
+
wakeRevoked = response.ok;
|
|
5970
|
+
if (!response.ok) wakeError = `wake-member-revoke-failed:${response.status}`;
|
|
5971
|
+
} catch (error) {
|
|
5972
|
+
wakeError = error instanceof Error ? error.message : String(error);
|
|
5973
|
+
}
|
|
5974
|
+
res.json({
|
|
5975
|
+
ok: true,
|
|
5976
|
+
workspaceId,
|
|
5977
|
+
memberUserId,
|
|
5978
|
+
retiredPeers,
|
|
5979
|
+
revokedUiSessions,
|
|
5980
|
+
wakeRevoked,
|
|
5981
|
+
...(wakeError ? { wakeError } : {})
|
|
5982
|
+
});
|
|
5983
|
+
});
|
|
5984
|
+
|
|
5985
|
+
app.post('/api/remote/license/sync', async (req, res) => {
|
|
5099
5986
|
noStore(res);
|
|
5100
5987
|
try {
|
|
5101
|
-
const authorization = String(req.headers.authorization || '');
|
|
5102
|
-
const accessToken = authorization.replace(/^Bearer\s+/i, '').trim();
|
|
5103
|
-
res.json(await syncVerifiedLicense(accessToken));
|
|
5104
|
-
} catch (err) {
|
|
5105
|
-
|
|
5106
|
-
|
|
5107
|
-
|
|
5108
|
-
|
|
5109
|
-
|
|
5110
|
-
|
|
5111
|
-
|
|
5112
|
-
|
|
5113
|
-
|
|
5114
|
-
|
|
5988
|
+
const authorization = String(req.headers.authorization || '');
|
|
5989
|
+
const accessToken = authorization.replace(/^Bearer\s+/i, '').trim();
|
|
5990
|
+
res.json(await syncVerifiedLicense(accessToken, req.body?.workspaceId ?? req.body?.workspace_id ?? ''));
|
|
5991
|
+
} catch (err) {
|
|
5992
|
+
const explicitAccessRejection = err instanceof WorkspaceAccessError && err.status < 500;
|
|
5993
|
+
if (explicitAccessRejection) {
|
|
5994
|
+
verifiedLicense = {
|
|
5995
|
+
userId: '',
|
|
5996
|
+
workspaceId: currentBoundHubWorkspaceId(),
|
|
5997
|
+
workspaceKind: runtimeWorkspaceAccess?.workspaceKind || 'personal',
|
|
5998
|
+
plan: 'free',
|
|
5999
|
+
status: 'inactive',
|
|
6000
|
+
expiresAt: '',
|
|
6001
|
+
memberLimit: 1,
|
|
6002
|
+
commercialUse: false,
|
|
6003
|
+
verifiedAt: Date.now()
|
|
6004
|
+
};
|
|
6005
|
+
}
|
|
6006
|
+
res.status(workspaceAccessHttpStatus(err)).json({
|
|
6007
|
+
ok: false,
|
|
6008
|
+
error: err instanceof Error ? err.message : String(err),
|
|
6009
|
+
preserved: !explicitAccessRejection && Date.now() - verifiedLicense.verifiedAt <= LICENSE_VERIFY_MAX_AGE_MS,
|
|
6010
|
+
license: licenseSnapshot()
|
|
6011
|
+
});
|
|
6012
|
+
}
|
|
6013
|
+
});
|
|
5115
6014
|
|
|
5116
6015
|
app.delete('/api/remote/license', (_req, res) => {
|
|
5117
6016
|
noStore(res);
|
|
5118
|
-
verifiedLicense = {
|
|
5119
|
-
userId: '',
|
|
5120
|
-
|
|
5121
|
-
|
|
5122
|
-
|
|
5123
|
-
|
|
5124
|
-
|
|
6017
|
+
verifiedLicense = {
|
|
6018
|
+
userId: '',
|
|
6019
|
+
workspaceId: currentBoundHubWorkspaceId(),
|
|
6020
|
+
workspaceKind: runtimeWorkspaceAccess?.workspaceKind || 'personal',
|
|
6021
|
+
plan: 'free',
|
|
6022
|
+
status: 'inactive',
|
|
6023
|
+
expiresAt: '',
|
|
6024
|
+
memberLimit: 1,
|
|
6025
|
+
commercialUse: false,
|
|
6026
|
+
verifiedAt: Date.now()
|
|
6027
|
+
};
|
|
5125
6028
|
res.json(licenseSnapshot());
|
|
5126
6029
|
});
|
|
5127
6030
|
|
|
@@ -5787,10 +6690,26 @@ httpServer.on('upgrade', (req, socket, head) => {
|
|
|
5787
6690
|
const label = authorization.status === 403 ? 'Forbidden' : 'Unauthorized';
|
|
5788
6691
|
socket.write(`HTTP/1.1 ${authorization.status} ${label}\r\nConnection: close\r\n\r\n`);
|
|
5789
6692
|
socket.destroy();
|
|
5790
|
-
return;
|
|
5791
|
-
}
|
|
5792
|
-
try {
|
|
5793
|
-
const parsed = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
|
|
6693
|
+
return;
|
|
6694
|
+
}
|
|
6695
|
+
try {
|
|
6696
|
+
const parsed = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
|
|
6697
|
+
if (!workspaceEntitlementCanRequest(
|
|
6698
|
+
authorizationWorkspaceAccess(authorization),
|
|
6699
|
+
'GET',
|
|
6700
|
+
parsed.pathname
|
|
6701
|
+
)) {
|
|
6702
|
+
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
|
|
6703
|
+
socket.destroy();
|
|
6704
|
+
return;
|
|
6705
|
+
}
|
|
6706
|
+
if (authorization.role
|
|
6707
|
+
&& !workspaceRoleCanRequest(authorization.role, 'GET', parsed.pathname)) {
|
|
6708
|
+
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
|
|
6709
|
+
socket.destroy();
|
|
6710
|
+
return;
|
|
6711
|
+
}
|
|
6712
|
+
req.liveDeskAuthorization = authorization;
|
|
5794
6713
|
if (parsed.pathname === '/api/remote/frames/ws') {
|
|
5795
6714
|
frameWss.handleUpgrade(req, socket, head, ws => frameWss.emit('connection', ws, req));
|
|
5796
6715
|
return;
|
|
@@ -5815,7 +6734,8 @@ httpServer.on('upgrade', (req, socket, head) => {
|
|
|
5815
6734
|
}
|
|
5816
6735
|
});
|
|
5817
6736
|
|
|
5818
|
-
frameWss.on('connection', (ws, req) => {
|
|
6737
|
+
frameWss.on('connection', (ws, req) => {
|
|
6738
|
+
bindHubUiSessionSocket(ws, req);
|
|
5819
6739
|
frameClients.add(ws);
|
|
5820
6740
|
ws.liveDeskFrameClientId = `rfws-${++frameClientSeq}`;
|
|
5821
6741
|
try {
|
|
@@ -5834,6 +6754,7 @@ frameWss.on('connection', (ws, req) => {
|
|
|
5834
6754
|
updateFrameSubscription(ws, {});
|
|
5835
6755
|
}
|
|
5836
6756
|
ws.on('message', data => {
|
|
6757
|
+
if (ws.liveDeskUiSessionRevoked === true) return;
|
|
5837
6758
|
try {
|
|
5838
6759
|
const payload = JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data || ''));
|
|
5839
6760
|
if (payload?.type === 'subscribe') {
|
|
@@ -5857,7 +6778,8 @@ frameWss.on('connection', (ws, req) => {
|
|
|
5857
6778
|
});
|
|
5858
6779
|
});
|
|
5859
6780
|
|
|
5860
|
-
atlasWss.on('connection', ws => {
|
|
6781
|
+
atlasWss.on('connection', (ws, req) => {
|
|
6782
|
+
bindHubUiSessionSocket(ws, req);
|
|
5861
6783
|
ws.liveDeskFrameClientId = `atlas-${++frameClientSeq}`;
|
|
5862
6784
|
try { ws._socket?.setNoDelay?.(true); } catch {}
|
|
5863
6785
|
const cleanup = () => {
|
|
@@ -5871,7 +6793,8 @@ atlasWss.on('connection', ws => {
|
|
|
5871
6793
|
ws.liveDeskAtlasSession = null;
|
|
5872
6794
|
retireFrameClientSendLane(ws);
|
|
5873
6795
|
};
|
|
5874
|
-
ws.on('message', data => {
|
|
6796
|
+
ws.on('message', data => {
|
|
6797
|
+
if (ws.liveDeskUiSessionRevoked === true) return;
|
|
5875
6798
|
try {
|
|
5876
6799
|
const payload = JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data || ''));
|
|
5877
6800
|
if (payload?.type === 'subscribe' || payload?.type === 'configure') {
|
|
@@ -5891,7 +6814,8 @@ atlasWss.on('connection', ws => {
|
|
|
5891
6814
|
});
|
|
5892
6815
|
});
|
|
5893
6816
|
|
|
5894
|
-
audioWss.on('connection', (ws, req) => {
|
|
6817
|
+
audioWss.on('connection', (ws, req) => {
|
|
6818
|
+
bindHubUiSessionSocket(ws, req);
|
|
5895
6819
|
audioClients.add(ws);
|
|
5896
6820
|
ws.liveDeskAudioClientId = `raws-${++audioClientSeq}`;
|
|
5897
6821
|
ws.liveDeskAudioCleanupComplete = false;
|
|
@@ -5904,7 +6828,8 @@ audioWss.on('connection', (ws, req) => {
|
|
|
5904
6828
|
} catch {
|
|
5905
6829
|
updateAudioSubscription(ws, {});
|
|
5906
6830
|
}
|
|
5907
|
-
ws.on('message', data => {
|
|
6831
|
+
ws.on('message', data => {
|
|
6832
|
+
if (ws.liveDeskUiSessionRevoked === true) return;
|
|
5908
6833
|
try {
|
|
5909
6834
|
const payload = JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data || ''));
|
|
5910
6835
|
if (payload?.type === 'subscribe') {
|
|
@@ -5925,7 +6850,8 @@ audioWss.on('connection', (ws, req) => {
|
|
|
5925
6850
|
});
|
|
5926
6851
|
});
|
|
5927
6852
|
|
|
5928
|
-
inputWss.on('connection', ws => {
|
|
6853
|
+
inputWss.on('connection', (ws, req) => {
|
|
6854
|
+
bindHubUiSessionSocket(ws, req);
|
|
5929
6855
|
inputClients.add(ws);
|
|
5930
6856
|
ws.liveDeskInputClientId = `riws-${++inputClientSeq}`;
|
|
5931
6857
|
ws.liveDeskInputDeviceIds = new Set();
|
|
@@ -5934,7 +6860,8 @@ inputWss.on('connection', ws => {
|
|
|
5934
6860
|
} catch {
|
|
5935
6861
|
// Best-effort latency hint for browser input sockets.
|
|
5936
6862
|
}
|
|
5937
|
-
ws.on('message', data => {
|
|
6863
|
+
ws.on('message', data => {
|
|
6864
|
+
if (ws.liveDeskUiSessionRevoked === true) return;
|
|
5938
6865
|
let payload;
|
|
5939
6866
|
try {
|
|
5940
6867
|
payload = JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data || ''));
|
|
@@ -6103,9 +7030,10 @@ function shutdownHub(signal) {
|
|
|
6103
7030
|
hubShutdownPromise = (async () => {
|
|
6104
7031
|
const startedAt = Date.now();
|
|
6105
7032
|
console.log(`[VuvoDesk Hub] Shutdown started signal=${signal} grace=${hubShutdownGraceMs}ms timeout=${hubShutdownTimeoutMs}ms.`);
|
|
6106
|
-
if (roleWatchTimer) clearInterval(roleWatchTimer);
|
|
6107
|
-
clearInterval(browserWebSocketHeartbeatTimer);
|
|
6108
|
-
runSynchronousShutdownStep('
|
|
7033
|
+
if (roleWatchTimer) clearInterval(roleWatchTimer);
|
|
7034
|
+
clearInterval(browserWebSocketHeartbeatTimer);
|
|
7035
|
+
runSynchronousShutdownStep('control presentation reconcile close', () => readOnlyControlPresentationReconcileCoordinator.close());
|
|
7036
|
+
runSynchronousShutdownStep('update manager close', () => liveDeskUpdateManager?.close());
|
|
6109
7037
|
runSynchronousShutdownStep('mobile console direct close', () => hubConsoleDirect?.close());
|
|
6110
7038
|
atlasClients.clear();
|
|
6111
7039
|
runSynchronousShutdownStep('shared folder close', () => hubSharedFolders.close());
|