@livedesk/hub 0.1.58 → 0.1.59
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/http/hub-ui-session.js +155 -32
- package/src/server.js +1141 -328
package/src/server.js
CHANGED
|
@@ -60,11 +60,18 @@ import { PRODUCTION_SUPABASE_PUBLISHABLE_KEY, PRODUCTION_SUPABASE_URL } from '..
|
|
|
60
60
|
import { createHubRuntime } from './runtime/hub-runtime.js';
|
|
61
61
|
import { hubRuntimeStatus } from './runtime/hub-runtime-status.js';
|
|
62
62
|
import { roleGuardResponse } from './http/hub-role-guard.js';
|
|
63
|
-
import {
|
|
64
|
-
clearHubUiSessionCookie,
|
|
65
|
-
createHubUiSessionAuthority,
|
|
66
|
-
serializeHubUiSessionCookie
|
|
67
|
-
} from './http/hub-ui-session.js';
|
|
63
|
+
import {
|
|
64
|
+
clearHubUiSessionCookie,
|
|
65
|
+
createHubUiSessionAuthority,
|
|
66
|
+
serializeHubUiSessionCookie
|
|
67
|
+
} from './http/hub-ui-session.js';
|
|
68
|
+
import {
|
|
69
|
+
resolveWorkspaceAccess,
|
|
70
|
+
WorkspaceAccessError,
|
|
71
|
+
workspaceEntitlementCanRequest,
|
|
72
|
+
workspaceRoleCanControl,
|
|
73
|
+
workspaceRoleCanRequest
|
|
74
|
+
} from './auth/workspace-access.js';
|
|
68
75
|
|
|
69
76
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
70
77
|
const webDistCandidates = [
|
|
@@ -176,7 +183,8 @@ const supabasePublishableKey = String(configuredSupabaseKey || PRODUCTION_SUPABA
|
|
|
176
183
|
const SUPABASE_AUTH_TIMEOUT_MS = process.env.LIVEDESK_AUTH_TEST_MODE === '1'
|
|
177
184
|
? readPositiveIntegerEnv('LIVEDESK_AUTH_TEST_TIMEOUT_MS', AUTH_REQUEST_TIMEOUT_MS)
|
|
178
185
|
: AUTH_REQUEST_TIMEOUT_MS;
|
|
179
|
-
const CLIENT_AUTH_STORAGE_KEY = 'livedesk.client.supabase.auth';
|
|
186
|
+
const CLIENT_AUTH_STORAGE_KEY = 'livedesk.client.supabase.auth';
|
|
187
|
+
const HUB_WORKSPACE_STORAGE_KEY = 'livedesk.hub.workspace';
|
|
180
188
|
const runtimeAuthStatePath = process.env.LIVEDESK_DESKTOP_HOST === '1'
|
|
181
189
|
? ''
|
|
182
190
|
: String(process.env.LIVEDESK_AUTH_STATE_PATH || '').trim();
|
|
@@ -194,10 +202,18 @@ const persistentSessionGcToken = persistentSessionGcEnabled
|
|
|
194
202
|
? String(process.env.LIVEDESK_PERSISTENT_SESSION_TEST_TOKEN || '').trim()
|
|
195
203
|
: '';
|
|
196
204
|
let connectedDeviceCount = 0;
|
|
197
|
-
let runtimeAccessToken = '';
|
|
198
|
-
let runtimeRefreshToken = '';
|
|
199
|
-
let runtimeAccessTokenExpiresAt = 0;
|
|
200
|
-
|
|
205
|
+
let runtimeAccessToken = '';
|
|
206
|
+
let runtimeRefreshToken = '';
|
|
207
|
+
let runtimeAccessTokenExpiresAt = 0;
|
|
208
|
+
let runtimeAuthEpoch = 0;
|
|
209
|
+
let runtimeAccessTokenRefreshOwner = null;
|
|
210
|
+
let runtimeWorkspaceAccess = null;
|
|
211
|
+
let workspaceAccessLastAttemptAt = 0;
|
|
212
|
+
let licenseRefreshLastAttemptAt = 0;
|
|
213
|
+
const hubUiSessionAuthority = createHubUiSessionAuthority({
|
|
214
|
+
onRevoke: (session, reason) => retireHubUiSessionSockets(session, reason)
|
|
215
|
+
});
|
|
216
|
+
const consoleProxyToken = crypto.randomBytes(32).toString('base64url');
|
|
201
217
|
let roleWatchInFlight = false;
|
|
202
218
|
let verifiedLicense = {
|
|
203
219
|
userId: '',
|
|
@@ -217,7 +233,16 @@ const HUB_HOST_TARGET_RENEW_MS = Math.max(1000, Math.min(
|
|
|
217
233
|
readPositiveIntegerEnv('LIVEDESK_HOST_TARGET_RENEW_MS', 20_000),
|
|
218
234
|
Math.max(1000, HUB_HOST_TARGET_LEASE_MS - 1000)
|
|
219
235
|
));
|
|
220
|
-
const HUB_ACCESS_TOKEN_REFRESH_SKEW_MS = 90_000;
|
|
236
|
+
const HUB_ACCESS_TOKEN_REFRESH_SKEW_MS = 90_000;
|
|
237
|
+
const WORKSPACE_ACCESS_REVERIFY_MS = Math.max(
|
|
238
|
+
30_000,
|
|
239
|
+
Math.min(5 * 60_000, readPositiveIntegerEnv('LIVEDESK_WORKSPACE_ACCESS_REVERIFY_MS', 60_000))
|
|
240
|
+
);
|
|
241
|
+
const LICENSE_REFRESH_INTERVAL_MS = Math.max(
|
|
242
|
+
30 * 60_000,
|
|
243
|
+
readPositiveIntegerEnv('LIVEDESK_LICENSE_REFRESH_INTERVAL_MS', 30 * 60_000)
|
|
244
|
+
);
|
|
245
|
+
const TEAM_UI_ACCESS_MAX_AGE_MS = 60_000;
|
|
221
246
|
const hubWakeBaseUrl = String(process.env.LIVEDESK_WAKE_URL || 'https://livedesk-wake.lovecrdm.workers.dev').trim();
|
|
222
247
|
const hubConsoleDirectSignalBaseUrl = String(
|
|
223
248
|
process.env.LIVEDESK_CONSOLE_SIGNAL_URL
|
|
@@ -368,7 +393,7 @@ function handleRemoteHubEvent(type, event) {
|
|
|
368
393
|
}
|
|
369
394
|
}
|
|
370
395
|
|
|
371
|
-
function activeLicensePlan() {
|
|
396
|
+
function activeLicensePlan() {
|
|
372
397
|
if (testLicensePlan) {
|
|
373
398
|
return testLicensePlan;
|
|
374
399
|
}
|
|
@@ -381,12 +406,22 @@ function activeLicensePlan() {
|
|
|
381
406
|
if (Number.isFinite(expiresAt) && expiresAt <= now) {
|
|
382
407
|
return 'free';
|
|
383
408
|
}
|
|
384
|
-
return verifiedLicense.plan === '
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
409
|
+
return verifiedLicense.plan === 'team'
|
|
410
|
+
? 'team'
|
|
411
|
+
: verifiedLicense.plan === 'pro'
|
|
412
|
+
? 'pro'
|
|
413
|
+
: verifiedLicense.plan === 'ltd'
|
|
414
|
+
? 'ltd'
|
|
415
|
+
: 'free';
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function activeDeviceLimit() {
|
|
419
|
+
const plan = activeLicensePlan();
|
|
420
|
+
return plan === 'team' || plan === 'pro'
|
|
421
|
+
? Number.POSITIVE_INFINITY
|
|
422
|
+
: plan === 'ltd'
|
|
423
|
+
? PLUS_DEVICE_LIMIT
|
|
424
|
+
: FREE_DEVICE_LIMIT;
|
|
390
425
|
}
|
|
391
426
|
|
|
392
427
|
function hasHubFeatureAccess() {
|
|
@@ -409,7 +444,7 @@ function hasHubFeatureAccessForRequest(req) {
|
|
|
409
444
|
return ids.size <= limit;
|
|
410
445
|
}
|
|
411
446
|
|
|
412
|
-
function licenseSnapshot() {
|
|
447
|
+
function licenseSnapshot() {
|
|
413
448
|
const plan = activeLicensePlan();
|
|
414
449
|
const limit = activeDeviceLimit();
|
|
415
450
|
return {
|
|
@@ -417,47 +452,80 @@ function licenseSnapshot() {
|
|
|
417
452
|
status: plan === 'free' ? 'free' : 'active',
|
|
418
453
|
deviceLimit: Number.isFinite(limit) ? limit : null,
|
|
419
454
|
connectedDeviceCount,
|
|
420
|
-
featureAccess: connectedDeviceCount <= limit,
|
|
421
|
-
|
|
455
|
+
featureAccess: connectedDeviceCount <= limit,
|
|
456
|
+
workspaceId: verifiedLicense.workspaceId || runtimeWorkspaceAccess?.workspaceId || '',
|
|
457
|
+
workspaceKind: verifiedLicense.workspaceKind || runtimeWorkspaceAccess?.workspaceKind || 'personal',
|
|
458
|
+
memberLimit: Number.isSafeInteger(verifiedLicense.memberLimit) ? verifiedLicense.memberLimit : 1,
|
|
459
|
+
commercialUse: verifiedLicense.commercialUse === true,
|
|
460
|
+
verifiedAt: verifiedLicense.verifiedAt ? new Date(verifiedLicense.verifiedAt).toISOString() : '',
|
|
422
461
|
expiresAt: verifiedLicense.expiresAt || ''
|
|
423
462
|
};
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function workspaceAccessSnapshot(access = runtimeWorkspaceAccess) {
|
|
466
|
+
if (!access?.workspaceId) return null;
|
|
467
|
+
return {
|
|
468
|
+
workspaceId: access.workspaceId,
|
|
469
|
+
name: access.workspaceName || '',
|
|
470
|
+
kind: access.workspaceKind || 'personal',
|
|
471
|
+
role: access.role || '',
|
|
472
|
+
membershipRevision: Math.max(0, Number(access.membershipRevision || 0)),
|
|
473
|
+
plan: access.plan || 'free',
|
|
474
|
+
status: access.entitlementStatus || 'inactive',
|
|
475
|
+
deviceLimit: access.deviceLimit,
|
|
476
|
+
memberLimit: access.memberLimit,
|
|
477
|
+
commercialUse: access.commercialUse === true,
|
|
478
|
+
verifiedAt: access.verifiedAt ? new Date(access.verifiedAt).toISOString() : ''
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function workspaceRemoteAccessActive(access = runtimeWorkspaceAccess) {
|
|
483
|
+
return Boolean(access?.workspaceId) && (access.workspaceKind !== 'team'
|
|
484
|
+
|| (access?.plan === 'team' && access?.entitlementStatus === 'active'));
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function verifyWorkspaceAccess(accessToken, user, requestedWorkspaceId = '') {
|
|
488
|
+
return resolveWorkspaceAccess({
|
|
489
|
+
accessToken,
|
|
490
|
+
requestedWorkspaceId,
|
|
491
|
+
userId: user?.id || '',
|
|
492
|
+
supabaseUrl,
|
|
493
|
+
supabasePublishableKey,
|
|
494
|
+
fetchResponse: (url, options, timeoutMs) => fetchAuthResponse(fetch, url, options, timeoutMs),
|
|
495
|
+
timeoutMs: SUPABASE_AUTH_TIMEOUT_MS
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function applyVerifiedWorkspaceLicense(access) {
|
|
500
|
+
verifiedLicense = {
|
|
501
|
+
userId: access.userId || '',
|
|
502
|
+
workspaceId: access.workspaceId,
|
|
503
|
+
workspaceKind: access.workspaceKind,
|
|
504
|
+
plan: ['ltd', 'pro', 'team'].includes(access.plan) ? access.plan : 'free',
|
|
505
|
+
status: access.entitlementStatus === 'active' ? 'active' : 'inactive',
|
|
506
|
+
expiresAt: '',
|
|
507
|
+
memberLimit: access.memberLimit,
|
|
508
|
+
commercialUse: access.commercialUse === true,
|
|
509
|
+
verifiedAt: Date.now()
|
|
510
|
+
};
|
|
511
|
+
return licenseSnapshot();
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function syncVerifiedLicense(accessToken, requestedWorkspaceId = '') {
|
|
515
|
+
const token = String(accessToken || '').trim();
|
|
428
516
|
if (!token) {
|
|
429
517
|
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
|
-
}
|
|
518
|
+
}
|
|
519
|
+
const user = await verifySupabaseUser(token);
|
|
520
|
+
const selectedWorkspaceId = String(
|
|
521
|
+
requestedWorkspaceId
|
|
522
|
+
|| runtimeWorkspaceAccess?.workspaceId
|
|
523
|
+
|| readPersistedWorkspaceId()
|
|
524
|
+
|| ''
|
|
525
|
+
).trim();
|
|
526
|
+
const access = await verifyWorkspaceAccess(token, user, selectedWorkspaceId);
|
|
527
|
+
return applyVerifiedWorkspaceLicense(access);
|
|
528
|
+
}
|
|
461
529
|
|
|
462
530
|
function normalizeSessionExpiryMs(value) {
|
|
463
531
|
const numeric = Number(value || 0);
|
|
@@ -475,7 +543,7 @@ function readRuntimeAuthState() {
|
|
|
475
543
|
}
|
|
476
544
|
}
|
|
477
545
|
|
|
478
|
-
function readPersistedRuntimeSession(state = readRuntimeAuthState()) {
|
|
546
|
+
function readPersistedRuntimeSession(state = readRuntimeAuthState()) {
|
|
479
547
|
try {
|
|
480
548
|
const raw = state?.[CLIENT_AUTH_STORAGE_KEY];
|
|
481
549
|
const session = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
@@ -483,7 +551,12 @@ function readPersistedRuntimeSession(state = readRuntimeAuthState()) {
|
|
|
483
551
|
} catch {
|
|
484
552
|
return null;
|
|
485
553
|
}
|
|
486
|
-
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function readPersistedWorkspaceId(state = readRuntimeAuthState()) {
|
|
557
|
+
const workspaceId = String(state?.[HUB_WORKSPACE_STORAGE_KEY]?.workspaceId || '').trim();
|
|
558
|
+
return workspaceId.slice(0, 160);
|
|
559
|
+
}
|
|
487
560
|
|
|
488
561
|
function writePrivateRuntimeAuthState(state) {
|
|
489
562
|
if (!runtimeAuthStatePath) return false;
|
|
@@ -501,7 +574,7 @@ function writePrivateRuntimeAuthState(state) {
|
|
|
501
574
|
}
|
|
502
575
|
}
|
|
503
576
|
|
|
504
|
-
function persistRuntimeSession(session) {
|
|
577
|
+
function persistRuntimeSession(session) {
|
|
505
578
|
if (!runtimeAuthStatePath) return false;
|
|
506
579
|
const state = readRuntimeAuthState();
|
|
507
580
|
const previous = readPersistedRuntimeSession(state) || {};
|
|
@@ -526,25 +599,43 @@ function persistRuntimeSession(session) {
|
|
|
526
599
|
state[CLIENT_AUTH_STORAGE_KEY] = JSON.stringify(normalized.session);
|
|
527
600
|
writePrivateRuntimeAuthState(state);
|
|
528
601
|
return true;
|
|
529
|
-
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function persistRuntimeWorkspace(access) {
|
|
605
|
+
const workspaceId = String(access?.workspaceId || '').trim();
|
|
606
|
+
if (!runtimeAuthStatePath || !workspaceId) return false;
|
|
607
|
+
const state = readRuntimeAuthState();
|
|
608
|
+
state[HUB_WORKSPACE_STORAGE_KEY] = {
|
|
609
|
+
workspaceId,
|
|
610
|
+
workspaceKind: String(access?.workspaceKind || 'personal'),
|
|
611
|
+
selectedAt: new Date().toISOString()
|
|
612
|
+
};
|
|
613
|
+
writePrivateRuntimeAuthState(state);
|
|
614
|
+
return true;
|
|
615
|
+
}
|
|
530
616
|
|
|
531
|
-
function clearPersistedRuntimeSession() {
|
|
617
|
+
function clearPersistedRuntimeSession() {
|
|
532
618
|
if (!runtimeAuthStatePath) return false;
|
|
533
|
-
const state = readRuntimeAuthState();
|
|
534
|
-
delete state[CLIENT_AUTH_STORAGE_KEY];
|
|
535
|
-
|
|
536
|
-
|
|
619
|
+
const state = readRuntimeAuthState();
|
|
620
|
+
delete state[CLIENT_AUTH_STORAGE_KEY];
|
|
621
|
+
delete state[HUB_WORKSPACE_STORAGE_KEY];
|
|
622
|
+
return writePrivateRuntimeAuthState(state);
|
|
623
|
+
}
|
|
537
624
|
|
|
538
|
-
function clearRuntimeSession() {
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
625
|
+
function clearRuntimeSession() {
|
|
626
|
+
runtimeAuthEpoch += 1;
|
|
627
|
+
runtimeAccessToken = '';
|
|
628
|
+
runtimeRefreshToken = '';
|
|
629
|
+
runtimeAccessTokenExpiresAt = 0;
|
|
630
|
+
runtimeWorkspaceAccess = null;
|
|
631
|
+
workspaceAccessLastAttemptAt = 0;
|
|
632
|
+
licenseRefreshLastAttemptAt = 0;
|
|
542
633
|
hubUiSessionAuthority.revokeAll();
|
|
543
634
|
runtimeManager.setAuthenticated(false);
|
|
544
635
|
try { clearPersistedRuntimeSession(); } catch { /* runtime auth is already invalid */ }
|
|
545
636
|
}
|
|
546
637
|
|
|
547
|
-
async function getRuntimeAccessToken() {
|
|
638
|
+
async function getRuntimeAccessToken() {
|
|
548
639
|
const accessToken = String(runtimeAccessToken || '').trim();
|
|
549
640
|
const expiresSoon = runtimeAccessTokenExpiresAt > 0
|
|
550
641
|
&& runtimeAccessTokenExpiresAt <= Date.now() + HUB_ACCESS_TOKEN_REFRESH_SKEW_MS;
|
|
@@ -560,52 +651,74 @@ async function getRuntimeAccessToken() {
|
|
|
560
651
|
return accessToken;
|
|
561
652
|
}
|
|
562
653
|
|
|
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
|
-
|
|
654
|
+
const refreshToken = String(runtimeRefreshToken || '').trim();
|
|
655
|
+
if (!refreshToken) {
|
|
656
|
+
return accessToken;
|
|
657
|
+
}
|
|
658
|
+
if (runtimeAccessTokenRefreshOwner?.epoch === runtimeAuthEpoch
|
|
659
|
+
&& runtimeAccessTokenRefreshOwner.refreshToken === refreshToken) {
|
|
660
|
+
return runtimeAccessTokenRefreshOwner.promise;
|
|
661
|
+
}
|
|
662
|
+
const owner = {
|
|
663
|
+
epoch: runtimeAuthEpoch,
|
|
664
|
+
userId: currentBoundHubUserId(),
|
|
665
|
+
workspaceId: currentBoundHubWorkspaceId(),
|
|
666
|
+
refreshToken,
|
|
667
|
+
promise: null
|
|
668
|
+
};
|
|
669
|
+
owner.promise = (async () => {
|
|
670
|
+
const response = await fetchAuthResponse(
|
|
671
|
+
fetch,
|
|
672
|
+
`${supabaseUrl}/auth/v1/token?grant_type=refresh_token`,
|
|
673
|
+
{
|
|
674
|
+
method: 'POST',
|
|
675
|
+
headers: {
|
|
676
|
+
apikey: supabasePublishableKey,
|
|
677
|
+
'Content-Type': 'application/json',
|
|
678
|
+
Accept: 'application/json'
|
|
679
|
+
},
|
|
680
|
+
body: JSON.stringify({ refresh_token: refreshToken })
|
|
681
|
+
},
|
|
682
|
+
SUPABASE_AUTH_TIMEOUT_MS
|
|
683
|
+
);
|
|
684
|
+
if (!response.ok) {
|
|
685
|
+
if (response.status === 400 || response.status === 401 || response.status === 403) {
|
|
686
|
+
if (runtimeAuthEpoch === owner.epoch && runtimeRefreshToken === owner.refreshToken) {
|
|
687
|
+
clearRuntimeSession();
|
|
688
|
+
}
|
|
689
|
+
throw new Error(`hub-session-refresh-failed:${response.status}`);
|
|
690
|
+
}
|
|
691
|
+
throw new Error(`hub-session-refresh-provider-failed:${response.status}`);
|
|
692
|
+
}
|
|
693
|
+
const refreshed = await response.json().catch(() => null);
|
|
694
|
+
const nextAccessToken = String(refreshed?.access_token || '').trim();
|
|
695
|
+
if (!nextAccessToken) throw new Error('hub-session-refresh-missing-access-token');
|
|
696
|
+
if (runtimeAuthEpoch !== owner.epoch
|
|
697
|
+
|| runtimeRefreshToken !== owner.refreshToken
|
|
698
|
+
|| currentBoundHubUserId() !== owner.userId
|
|
699
|
+
|| currentBoundHubWorkspaceId() !== owner.workspaceId) {
|
|
700
|
+
throw new Error('hub-session-refresh-superseded');
|
|
701
|
+
}
|
|
702
|
+
runtimeAccessToken = nextAccessToken;
|
|
703
|
+
runtimeRefreshToken = String(refreshed?.refresh_token || refreshToken).trim();
|
|
704
|
+
runtimeAccessTokenExpiresAt = normalizeSessionExpiryMs(refreshed?.expires_at)
|
|
705
|
+
|| (Number(refreshed?.expires_in) > 0 ? Date.now() + Number(refreshed.expires_in) * 1000 : 0);
|
|
706
|
+
try {
|
|
707
|
+
persistRuntimeSession({
|
|
708
|
+
access_token: runtimeAccessToken,
|
|
709
|
+
refresh_token: runtimeRefreshToken,
|
|
710
|
+
expires_at: Math.floor(runtimeAccessTokenExpiresAt / 1000)
|
|
711
|
+
});
|
|
712
|
+
} catch (error) {
|
|
713
|
+
console.warn(`[VuvoDesk Hub] Refreshed session persistence failed: ${error?.message || error}`);
|
|
714
|
+
}
|
|
715
|
+
return runtimeAccessToken;
|
|
716
|
+
})().finally(() => {
|
|
717
|
+
if (runtimeAccessTokenRefreshOwner === owner) runtimeAccessTokenRefreshOwner = null;
|
|
718
|
+
});
|
|
719
|
+
runtimeAccessTokenRefreshOwner = owner;
|
|
720
|
+
return owner.promise;
|
|
721
|
+
}
|
|
609
722
|
|
|
610
723
|
async function verifySupabaseUser(accessToken) {
|
|
611
724
|
const token = String(accessToken || '').trim();
|
|
@@ -637,21 +750,51 @@ async function verifySupabaseUser(accessToken) {
|
|
|
637
750
|
};
|
|
638
751
|
}
|
|
639
752
|
|
|
640
|
-
function authVerificationHttpStatus(message) {
|
|
753
|
+
function authVerificationHttpStatus(message) {
|
|
641
754
|
const upstreamStatus = Number(String(message || '').match(/supabase-user-verification-failed:(\d+)/)?.[1] || 0);
|
|
642
755
|
return upstreamStatus === 401 || upstreamStatus === 403 ? 401 : 502;
|
|
643
756
|
}
|
|
644
757
|
|
|
645
|
-
async function
|
|
646
|
-
const
|
|
647
|
-
if (
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
758
|
+
async function queryRuntimeDeviceWorkspace(accessToken) {
|
|
759
|
+
const token = String(accessToken || '').trim();
|
|
760
|
+
if (!token || !runtimeDeviceId) return '';
|
|
761
|
+
const query = new URLSearchParams({
|
|
762
|
+
select: 'workspace_id,role,enabled',
|
|
763
|
+
device_id: `eq.${runtimeDeviceId}`,
|
|
764
|
+
enabled: 'eq.true',
|
|
765
|
+
limit: '1'
|
|
766
|
+
});
|
|
767
|
+
const response = await fetchAuthResponse(
|
|
768
|
+
fetch,
|
|
769
|
+
`${supabaseUrl}/rest/v1/livedesk_devices?${query}`,
|
|
770
|
+
{
|
|
771
|
+
headers: {
|
|
772
|
+
apikey: supabasePublishableKey,
|
|
773
|
+
Authorization: `Bearer ${token}`,
|
|
774
|
+
Accept: 'application/json'
|
|
775
|
+
}
|
|
776
|
+
},
|
|
777
|
+
SUPABASE_AUTH_TIMEOUT_MS
|
|
778
|
+
);
|
|
779
|
+
if (response.status === 401 || response.status === 403) {
|
|
780
|
+
throw new Error(`hub-device-workspace-query-not-authenticated:${response.status}`);
|
|
781
|
+
}
|
|
782
|
+
if (!response.ok) return '';
|
|
783
|
+
const rows = await response.json().catch(() => []);
|
|
784
|
+
const record = Array.isArray(rows) ? rows[0] : null;
|
|
785
|
+
return record?.enabled === false ? '' : String(record?.workspace_id || '').trim();
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
async function queryAuthoritativeRuntimeDevice() {
|
|
789
|
+
const accessToken = await getRuntimeAccessToken();
|
|
790
|
+
if (runtimeRole !== 'hub' || !accessToken || !runtimeDeviceId) {
|
|
791
|
+
return { role: '', workspaceId: '' };
|
|
792
|
+
}
|
|
793
|
+
const query = new URLSearchParams({
|
|
794
|
+
select: 'workspace_id,role,enabled,role_version,assigned_hub_id',
|
|
795
|
+
device_id: `eq.${runtimeDeviceId}`,
|
|
796
|
+
limit: '1'
|
|
797
|
+
});
|
|
655
798
|
const response = await fetchAuthResponse(
|
|
656
799
|
fetch,
|
|
657
800
|
`${supabaseUrl}/rest/v1/livedesk_devices?${query}`,
|
|
@@ -664,38 +807,149 @@ async function queryAuthoritativeRuntimeRole() {
|
|
|
664
807
|
},
|
|
665
808
|
SUPABASE_AUTH_TIMEOUT_MS
|
|
666
809
|
);
|
|
667
|
-
if (response.status === 401 || response.status === 403) {
|
|
668
|
-
|
|
669
|
-
throw new Error(`hub-role-query-not-authenticated:${response.status}`);
|
|
810
|
+
if (response.status === 401 || response.status === 403) {
|
|
811
|
+
throw new Error(`hub-role-query-not-authenticated:${response.status}`);
|
|
670
812
|
}
|
|
671
813
|
if (!response.ok) {
|
|
672
814
|
throw new Error(`hub-role-query-failed:${response.status}`);
|
|
673
815
|
}
|
|
674
816
|
const rows = await response.json().catch(() => []);
|
|
675
817
|
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
|
-
|
|
818
|
+
if (!record || record.enabled === false) {
|
|
819
|
+
return { role: '', workspaceId: '' };
|
|
820
|
+
}
|
|
821
|
+
const role = String(record.role || '').trim().toLowerCase();
|
|
822
|
+
return {
|
|
823
|
+
role: role === 'hub' || role === 'client' ? role : '',
|
|
824
|
+
workspaceId: String(record.workspace_id || '').trim()
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
async function queryAuthoritativeRuntimeRole() {
|
|
829
|
+
return (await queryAuthoritativeRuntimeDevice()).role;
|
|
830
|
+
}
|
|
682
831
|
|
|
683
|
-
async function watchAuthoritativeRuntimeRole() {
|
|
832
|
+
async function watchAuthoritativeRuntimeRole() {
|
|
684
833
|
if (runtimeRole !== 'hub' || roleWatchInFlight || !runtimeAccessToken) {
|
|
685
834
|
return;
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
835
|
+
}
|
|
836
|
+
const watchAuthEpoch = runtimeAuthEpoch;
|
|
837
|
+
const watchUserId = currentBoundHubUserId();
|
|
838
|
+
const watchWorkspaceId = currentBoundHubWorkspaceId();
|
|
839
|
+
roleWatchInFlight = true;
|
|
840
|
+
try {
|
|
841
|
+
const authoritativeDevice = await queryAuthoritativeRuntimeDevice();
|
|
842
|
+
if (runtimeAuthEpoch !== watchAuthEpoch
|
|
843
|
+
|| currentBoundHubUserId() !== watchUserId
|
|
844
|
+
|| currentBoundHubWorkspaceId() !== watchWorkspaceId) return;
|
|
845
|
+
const role = authoritativeDevice.role;
|
|
690
846
|
if (role === 'client') {
|
|
691
847
|
console.warn('[VuvoDesk Hub] Supabase selected another Sync Server. Transitioning this runtime to Client.');
|
|
692
848
|
void clearHubHostTarget('role-demoted').finally(() => {
|
|
693
|
-
setTimeout(() => process.exit(ROLE_TRANSITION_EXIT_CODE), 150);
|
|
694
|
-
});
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
849
|
+
setTimeout(() => process.exit(ROLE_TRANSITION_EXIT_CODE), 150);
|
|
850
|
+
});
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
if (authoritativeDevice.workspaceId
|
|
854
|
+
&& authoritativeDevice.workspaceId !== currentBoundHubWorkspaceId()) {
|
|
855
|
+
const token = await getRuntimeAccessToken();
|
|
856
|
+
const userId = currentBoundHubUserId();
|
|
857
|
+
const movedAccess = await verifyWorkspaceAccess(
|
|
858
|
+
token,
|
|
859
|
+
{ id: userId },
|
|
860
|
+
authoritativeDevice.workspaceId
|
|
861
|
+
);
|
|
862
|
+
if (movedAccess.userId !== userId || movedAccess.role !== 'owner') {
|
|
863
|
+
throw new WorkspaceAccessError('hub-device-workspace-mismatch', { status: 403 });
|
|
864
|
+
}
|
|
865
|
+
if (runtimeAuthEpoch !== watchAuthEpoch
|
|
866
|
+
|| currentBoundHubUserId() !== watchUserId
|
|
867
|
+
|| currentBoundHubWorkspaceId() !== watchWorkspaceId) return;
|
|
868
|
+
await clearHubHostTarget('hub-workspace-moved');
|
|
869
|
+
if (runtimeAuthEpoch !== watchAuthEpoch
|
|
870
|
+
|| currentBoundHubUserId() !== watchUserId
|
|
871
|
+
|| currentBoundHubWorkspaceId() !== watchWorkspaceId) return;
|
|
872
|
+
hubUiSessionAuthority.revokeAll('hub-workspace-moved');
|
|
873
|
+
runtimeAuthEpoch += 1;
|
|
874
|
+
runtimeWorkspaceAccess = movedAccess;
|
|
875
|
+
try { persistRuntimeWorkspace(movedAccess); } catch {}
|
|
876
|
+
applyVerifiedWorkspaceLicense(movedAccess);
|
|
877
|
+
licenseRefreshLastAttemptAt = Date.now();
|
|
878
|
+
workspaceAccessLastAttemptAt = Date.now();
|
|
879
|
+
hubConsoleDirect?.invalidateWorkspaceAccess('hub-workspace-moved');
|
|
880
|
+
if (workspaceRemoteAccessActive(movedAccess)) {
|
|
881
|
+
scheduleAuthenticatedHubHostTargetPublication('hub-workspace-moved');
|
|
882
|
+
}
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
const now = Date.now();
|
|
886
|
+
if (now - workspaceAccessLastAttemptAt < WORKSPACE_ACCESS_REVERIFY_MS) {
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
workspaceAccessLastAttemptAt = now;
|
|
890
|
+
const token = await getRuntimeAccessToken();
|
|
891
|
+
const userId = currentBoundHubUserId();
|
|
892
|
+
const previousAccess = runtimeWorkspaceAccess;
|
|
893
|
+
const access = await verifyWorkspaceAccess(token, { id: userId }, currentBoundHubWorkspaceId());
|
|
894
|
+
if (access.role !== 'owner') {
|
|
895
|
+
throw new WorkspaceAccessError('workspace-owner-required', { status: 403 });
|
|
896
|
+
}
|
|
897
|
+
if (runtimeAuthEpoch !== watchAuthEpoch
|
|
898
|
+
|| currentBoundHubUserId() !== watchUserId
|
|
899
|
+
|| currentBoundHubWorkspaceId() !== watchWorkspaceId) return;
|
|
900
|
+
const licenseChanged = !previousAccess
|
|
901
|
+
|| previousAccess.plan !== access.plan
|
|
902
|
+
|| previousAccess.entitlementStatus !== access.entitlementStatus
|
|
903
|
+
|| previousAccess.deviceLimit !== access.deviceLimit
|
|
904
|
+
|| previousAccess.memberLimit !== access.memberLimit
|
|
905
|
+
|| previousAccess.commercialUse !== access.commercialUse;
|
|
906
|
+
const teamBecameInactive = access.workspaceKind === 'team'
|
|
907
|
+
&& !workspaceRemoteAccessActive(access)
|
|
908
|
+
&& workspaceRemoteAccessActive(previousAccess);
|
|
909
|
+
const teamBecameActive = access.workspaceKind === 'team'
|
|
910
|
+
&& workspaceRemoteAccessActive(access)
|
|
911
|
+
&& !workspaceRemoteAccessActive(previousAccess);
|
|
912
|
+
runtimeWorkspaceAccess = access;
|
|
913
|
+
try { persistRuntimeWorkspace(access); } catch { /* the verified in-memory owner remains authoritative */ }
|
|
914
|
+
if (licenseChanged || now - licenseRefreshLastAttemptAt >= LICENSE_REFRESH_INTERVAL_MS) {
|
|
915
|
+
applyVerifiedWorkspaceLicense(access);
|
|
916
|
+
licenseRefreshLastAttemptAt = now;
|
|
917
|
+
}
|
|
918
|
+
if (teamBecameInactive) {
|
|
919
|
+
hubConsoleDirect?.close();
|
|
920
|
+
await clearHubHostTarget('workspace-entitlement-inactive');
|
|
921
|
+
} else if (teamBecameActive) {
|
|
922
|
+
hubConsoleDirect?.refresh();
|
|
923
|
+
scheduleAuthenticatedHubHostTargetPublication('workspace-entitlement-activated');
|
|
924
|
+
}
|
|
925
|
+
// The revision also changes for invitations, billing and unrelated member
|
|
926
|
+
// updates. Closing every healthy peer here would turn a harmless Team
|
|
927
|
+
// metadata change into an input outage. Exact member removal is handled by
|
|
928
|
+
// the owner revoke endpoint; every Team signaling socket is independently
|
|
929
|
+
// revalidated by the Wake Worker within its bounded access window.
|
|
930
|
+
} catch (error) {
|
|
931
|
+
if (error instanceof WorkspaceAccessError
|
|
932
|
+
&& error.status < 500
|
|
933
|
+
&& runtimeAuthEpoch === watchAuthEpoch
|
|
934
|
+
&& currentBoundHubUserId() === watchUserId
|
|
935
|
+
&& currentBoundHubWorkspaceId() === watchWorkspaceId) {
|
|
936
|
+
runtimeWorkspaceAccess = null;
|
|
937
|
+
verifiedLicense = {
|
|
938
|
+
userId: '',
|
|
939
|
+
workspaceId: currentBoundHubWorkspaceId(),
|
|
940
|
+
workspaceKind: 'personal',
|
|
941
|
+
plan: 'free',
|
|
942
|
+
status: 'inactive',
|
|
943
|
+
expiresAt: '',
|
|
944
|
+
memberLimit: 1,
|
|
945
|
+
commercialUse: false,
|
|
946
|
+
verifiedAt: Date.now()
|
|
947
|
+
};
|
|
948
|
+
hubUiSessionAuthority.revokeAll();
|
|
949
|
+
hubConsoleDirect?.close();
|
|
950
|
+
}
|
|
951
|
+
// A temporary provider failure must not stop a working Hub. The next
|
|
952
|
+
// interval retries the authoritative role check.
|
|
699
953
|
} finally {
|
|
700
954
|
roleWatchInFlight = false;
|
|
701
955
|
}
|
|
@@ -1402,17 +1656,87 @@ hubConsoleDirect = createHubConsoleDirect({
|
|
|
1402
1656
|
deviceId: runtimeDeviceId,
|
|
1403
1657
|
httpBaseUrl: `http://127.0.0.1:${httpPort}`,
|
|
1404
1658
|
stunUrls: hubConsoleDirectStunUrls,
|
|
1405
|
-
getAccessToken: () => getRuntimeAccessToken()
|
|
1659
|
+
getAccessToken: () => getRuntimeAccessToken(),
|
|
1660
|
+
getWorkspaceAccess: () => runtimeWorkspaceAccess,
|
|
1661
|
+
consoleProxyToken
|
|
1406
1662
|
});
|
|
1407
1663
|
const httpConnections = new Set();
|
|
1408
1664
|
const frameWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
|
|
1409
1665
|
const atlasWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
|
|
1410
1666
|
const inputWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
|
|
1411
1667
|
const audioWss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
|
|
1412
|
-
const browserWebSocketServers = [frameWss, atlasWss, inputWss, audioWss];
|
|
1413
|
-
const frameCaptureTransitionRetries = createLiveCaptureTransitionRetryCoordinator();
|
|
1414
|
-
|
|
1415
|
-
|
|
1668
|
+
const browserWebSocketServers = [frameWss, atlasWss, inputWss, audioWss];
|
|
1669
|
+
const frameCaptureTransitionRetries = createLiveCaptureTransitionRetryCoordinator();
|
|
1670
|
+
|
|
1671
|
+
function retireHubUiSessionSocket(ws, reason = 'hub-ui-session-revoked') {
|
|
1672
|
+
if (!ws || ws.liveDeskUiSessionRevoked === true) return false;
|
|
1673
|
+
ws.liveDeskUiSessionRevoked = true;
|
|
1674
|
+
if (ws.liveDeskUiSessionTimer) clearTimeout(ws.liveDeskUiSessionTimer);
|
|
1675
|
+
ws.liveDeskUiSessionTimer = null;
|
|
1676
|
+
if (ws.liveDeskInputClientId) {
|
|
1677
|
+
for (const deviceId of ws.liveDeskInputDeviceIds || []) {
|
|
1678
|
+
remoteHub.releaseInputOwner(
|
|
1679
|
+
deviceId,
|
|
1680
|
+
ws.liveDeskInputClientId,
|
|
1681
|
+
String(reason || 'hub-ui-session-revoked').slice(0, 120)
|
|
1682
|
+
);
|
|
1683
|
+
}
|
|
1684
|
+
ws.liveDeskInputDeviceIds?.clear?.();
|
|
1685
|
+
}
|
|
1686
|
+
try {
|
|
1687
|
+
ws.close(1008, String(reason || 'hub-ui-session-revoked').slice(0, 120));
|
|
1688
|
+
} catch {
|
|
1689
|
+
try { ws.terminate(); } catch {}
|
|
1690
|
+
}
|
|
1691
|
+
return true;
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
function retireHubUiSessionSockets(session, reason = 'hub-ui-session-revoked') {
|
|
1695
|
+
const sessionId = String(session?.sessionId || '');
|
|
1696
|
+
if (!sessionId) return 0;
|
|
1697
|
+
let retired = 0;
|
|
1698
|
+
for (const wss of browserWebSocketServers) {
|
|
1699
|
+
for (const ws of wss.clients) {
|
|
1700
|
+
if (ws.liveDeskUiSessionId !== sessionId) continue;
|
|
1701
|
+
if (retireHubUiSessionSocket(ws, reason)) retired += 1;
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
return retired;
|
|
1705
|
+
}
|
|
1706
|
+
|
|
1707
|
+
function bindHubUiSessionSocket(ws, req) {
|
|
1708
|
+
const authorization = req?.liveDeskAuthorization;
|
|
1709
|
+
const sessionId = String(authorization?.sessionId || '');
|
|
1710
|
+
if (!sessionId) return;
|
|
1711
|
+
ws.liveDeskUiSessionId = sessionId;
|
|
1712
|
+
ws.liveDeskUiSessionUserId = String(authorization.userId || '');
|
|
1713
|
+
ws.liveDeskUiSessionWorkspaceId = String(authorization.workspaceId || '');
|
|
1714
|
+
ws.liveDeskUiSessionRevoked = false;
|
|
1715
|
+
const armDeadline = () => {
|
|
1716
|
+
if (ws.liveDeskUiSessionRevoked === true) return;
|
|
1717
|
+
const deadline = hubUiSessionAuthority.sessionDeadline(sessionId);
|
|
1718
|
+
if (!deadline) {
|
|
1719
|
+
retireHubUiSessionSocket(ws, 'hub-ui-session-expired');
|
|
1720
|
+
return;
|
|
1721
|
+
}
|
|
1722
|
+
const delay = deadline - Date.now();
|
|
1723
|
+
if (delay <= 0) {
|
|
1724
|
+
retireHubUiSessionSocket(ws, 'hub-ui-session-expired');
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
ws.liveDeskUiSessionTimer = setTimeout(armDeadline, Math.min(delay, 60_000));
|
|
1728
|
+
ws.liveDeskUiSessionTimer.unref?.();
|
|
1729
|
+
};
|
|
1730
|
+
armDeadline();
|
|
1731
|
+
const cleanup = () => {
|
|
1732
|
+
if (ws.liveDeskUiSessionTimer) clearTimeout(ws.liveDeskUiSessionTimer);
|
|
1733
|
+
ws.liveDeskUiSessionTimer = null;
|
|
1734
|
+
};
|
|
1735
|
+
ws.once('close', cleanup);
|
|
1736
|
+
ws.once('error', cleanup);
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
httpServer.on('connection', socket => {
|
|
1416
1740
|
httpConnections.add(socket);
|
|
1417
1741
|
socket.once('close', () => httpConnections.delete(socket));
|
|
1418
1742
|
});
|
|
@@ -1509,13 +1833,34 @@ function isSecureHubHttpRequest(req) {
|
|
|
1509
1833
|
|| String(req.headers['x-forwarded-proto'] || '').trim().toLowerCase() === 'https';
|
|
1510
1834
|
}
|
|
1511
1835
|
|
|
1512
|
-
function currentBoundHubUserId() {
|
|
1836
|
+
function currentBoundHubUserId() {
|
|
1513
1837
|
return String(
|
|
1514
1838
|
runtimeManager.getSnapshot().userId
|
|
1515
1839
|
|| readPersistedRuntimeSession()?.user?.id
|
|
1516
1840
|
|| ''
|
|
1517
1841
|
).trim();
|
|
1518
|
-
}
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
function workspaceAccessHttpStatus(error) {
|
|
1845
|
+
return error instanceof WorkspaceAccessError
|
|
1846
|
+
? Math.max(400, Math.min(599, Number(error.status || 403)))
|
|
1847
|
+
: authVerificationHttpStatus(error instanceof Error ? error.message : String(error));
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
function currentBoundHubWorkspaceId() {
|
|
1851
|
+
return String(
|
|
1852
|
+
runtimeWorkspaceAccess?.workspaceId
|
|
1853
|
+
|| readPersistedWorkspaceId()
|
|
1854
|
+
|| currentBoundHubUserId()
|
|
1855
|
+
|| ''
|
|
1856
|
+
).trim();
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
function runtimeAuthOwnerIsCurrent(epoch, userId, workspaceId) {
|
|
1860
|
+
return runtimeAuthEpoch === epoch
|
|
1861
|
+
&& currentBoundHubUserId() === String(userId || '')
|
|
1862
|
+
&& currentBoundHubWorkspaceId() === String(workspaceId || '');
|
|
1863
|
+
}
|
|
1519
1864
|
|
|
1520
1865
|
function isPublicHubApiRequest(req) {
|
|
1521
1866
|
const method = String(req.method || 'GET').toUpperCase();
|
|
@@ -1524,21 +1869,98 @@ function isPublicHubApiRequest(req) {
|
|
|
1524
1869
|
|| (method === 'POST' && path === '/api/auth/session');
|
|
1525
1870
|
}
|
|
1526
1871
|
|
|
1527
|
-
function isTrustedNativeLoopbackRequest(req) {
|
|
1872
|
+
function isTrustedNativeLoopbackRequest(req) {
|
|
1528
1873
|
return isLoopbackAddress(req.socket?.remoteAddress)
|
|
1529
1874
|
&& !String(req.headers.origin || '').trim()
|
|
1530
1875
|
&& !String(req.headers['sec-fetch-site'] || '').trim();
|
|
1531
|
-
}
|
|
1532
|
-
|
|
1533
|
-
function
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
return { ok:
|
|
1539
|
-
}
|
|
1540
|
-
|
|
1541
|
-
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
function authorizeConsoleProxyRequest(req) {
|
|
1879
|
+
const suppliedToken = String(req.headers['x-livedesk-console-proxy'] || '').trim();
|
|
1880
|
+
if (!suppliedToken) return null;
|
|
1881
|
+
if (!isTrustedNativeLoopbackRequest(req)
|
|
1882
|
+
|| !secureExactTestTokenMatches(suppliedToken, consoleProxyToken)) {
|
|
1883
|
+
return { ok: false, status: 403, error: 'console-proxy-not-authorized' };
|
|
1884
|
+
}
|
|
1885
|
+
const workspaceId = String(req.headers['x-livedesk-workspace-id'] || '').trim();
|
|
1886
|
+
const workspaceKind = String(req.headers['x-livedesk-workspace-kind'] || '').trim().toLowerCase();
|
|
1887
|
+
const userId = String(req.headers['x-livedesk-workspace-user-id'] || '').trim();
|
|
1888
|
+
const role = String(req.headers['x-livedesk-workspace-role'] || '').trim().toLowerCase();
|
|
1889
|
+
const membershipRevision = Number(req.headers['x-livedesk-membership-revision'] || 0);
|
|
1890
|
+
const accessExpiresAt = Number(req.headers['x-livedesk-workspace-access-expires-at'] || 0);
|
|
1891
|
+
if (!workspaceId
|
|
1892
|
+
|| workspaceKind !== String(runtimeWorkspaceAccess?.workspaceKind || '').trim().toLowerCase()
|
|
1893
|
+
|| !userId
|
|
1894
|
+
|| workspaceId !== currentBoundHubWorkspaceId()
|
|
1895
|
+
|| !workspaceRoleCanControl(role)
|
|
1896
|
+
|| !Number.isSafeInteger(membershipRevision)
|
|
1897
|
+
|| membershipRevision < 0
|
|
1898
|
+
|| !Number.isFinite(accessExpiresAt)
|
|
1899
|
+
|| (workspaceKind === 'team' && accessExpiresAt <= Date.now())
|
|
1900
|
+
|| (workspaceKind === 'personal' && accessExpiresAt !== 0)) {
|
|
1901
|
+
return { ok: false, status: 403, error: 'console-workspace-access-invalid' };
|
|
1902
|
+
}
|
|
1903
|
+
return {
|
|
1904
|
+
ok: true,
|
|
1905
|
+
consoleProxy: true,
|
|
1906
|
+
workspaceId,
|
|
1907
|
+
workspaceKind,
|
|
1908
|
+
userId,
|
|
1909
|
+
role,
|
|
1910
|
+
plan: String(runtimeWorkspaceAccess?.plan || 'free'),
|
|
1911
|
+
entitlementStatus: String(runtimeWorkspaceAccess?.entitlementStatus || 'inactive'),
|
|
1912
|
+
membershipRevision,
|
|
1913
|
+
accessExpiresAt
|
|
1914
|
+
};
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
function authorizationWorkspaceAccess(authorization) {
|
|
1918
|
+
return {
|
|
1919
|
+
workspaceKind: String(
|
|
1920
|
+
authorization?.workspaceKind
|
|
1921
|
+
|| runtimeWorkspaceAccess?.workspaceKind
|
|
1922
|
+
|| 'personal'
|
|
1923
|
+
).trim().toLowerCase(),
|
|
1924
|
+
plan: String(
|
|
1925
|
+
authorization?.plan
|
|
1926
|
+
|| runtimeWorkspaceAccess?.plan
|
|
1927
|
+
|| 'free'
|
|
1928
|
+
).trim().toLowerCase(),
|
|
1929
|
+
entitlementStatus: String(
|
|
1930
|
+
authorization?.entitlementStatus
|
|
1931
|
+
|| runtimeWorkspaceAccess?.entitlementStatus
|
|
1932
|
+
|| 'active'
|
|
1933
|
+
).trim().toLowerCase()
|
|
1934
|
+
};
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
function authorizeHubUiRequest(req) {
|
|
1938
|
+
if (process.env.LIVEDESK_TEST_MODE === '1') {
|
|
1939
|
+
return { ok: true, explicitTestMode: true };
|
|
1940
|
+
}
|
|
1941
|
+
const consoleAuthorization = authorizeConsoleProxyRequest(req);
|
|
1942
|
+
if (consoleAuthorization) return consoleAuthorization;
|
|
1943
|
+
if (isTrustedNativeLoopbackRequest(req)) {
|
|
1944
|
+
return { ok: true, nativeLoopback: true };
|
|
1945
|
+
}
|
|
1946
|
+
return hubUiSessionAuthority.authorize(req, {
|
|
1947
|
+
workspaceId: currentBoundHubWorkspaceId(),
|
|
1948
|
+
allowedRoles: ['owner', 'operator']
|
|
1949
|
+
});
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
function authorizedWorkspaceRole(req) {
|
|
1953
|
+
if (req.liveDeskAuthorization?.role) return req.liveDeskAuthorization.role;
|
|
1954
|
+
const consoleAuthorization = authorizeConsoleProxyRequest(req);
|
|
1955
|
+
if (consoleAuthorization?.ok) return consoleAuthorization.role;
|
|
1956
|
+
const uiAuthorization = hubUiSessionAuthority.authorize(req, {
|
|
1957
|
+
workspaceId: currentBoundHubWorkspaceId(),
|
|
1958
|
+
allowedRoles: ['owner', 'operator']
|
|
1959
|
+
}, { requireCsrf: false });
|
|
1960
|
+
return uiAuthorization?.ok
|
|
1961
|
+
? uiAuthorization.role
|
|
1962
|
+
: String(runtimeWorkspaceAccess?.role || '').trim().toLowerCase();
|
|
1963
|
+
}
|
|
1542
1964
|
|
|
1543
1965
|
app.use((req, res, next) => {
|
|
1544
1966
|
if (!isTrustedBrowserRequest(req)) {
|
|
@@ -1566,13 +1988,29 @@ app.use((req, res, next) => {
|
|
|
1566
1988
|
next();
|
|
1567
1989
|
return;
|
|
1568
1990
|
}
|
|
1569
|
-
const authorization = authorizeHubUiRequest(req);
|
|
1570
|
-
if (!authorization.ok) {
|
|
1991
|
+
const authorization = authorizeHubUiRequest(req);
|
|
1992
|
+
if (!authorization.ok) {
|
|
1571
1993
|
noStore(res);
|
|
1572
1994
|
res.status(authorization.status).json({ ok: false, error: authorization.error });
|
|
1573
|
-
return;
|
|
1574
|
-
}
|
|
1575
|
-
|
|
1995
|
+
return;
|
|
1996
|
+
}
|
|
1997
|
+
if (!workspaceEntitlementCanRequest(
|
|
1998
|
+
authorizationWorkspaceAccess(authorization),
|
|
1999
|
+
req.method,
|
|
2000
|
+
req.path
|
|
2001
|
+
)) {
|
|
2002
|
+
noStore(res);
|
|
2003
|
+
res.status(403).json({ ok: false, error: 'workspace-entitlement-inactive' });
|
|
2004
|
+
return;
|
|
2005
|
+
}
|
|
2006
|
+
if (authorization.role
|
|
2007
|
+
&& !workspaceRoleCanRequest(authorization.role, req.method, req.path)) {
|
|
2008
|
+
noStore(res);
|
|
2009
|
+
res.status(403).json({ ok: false, error: 'workspace-owner-required' });
|
|
2010
|
+
return;
|
|
2011
|
+
}
|
|
2012
|
+
req.liveDeskAuthorization = authorization;
|
|
2013
|
+
next();
|
|
1576
2014
|
});
|
|
1577
2015
|
app.use((req, res, next) => {
|
|
1578
2016
|
if (runtimeRole === 'client' && req.path.startsWith('/api/remote')) {
|
|
@@ -3954,7 +4392,7 @@ app.get('/api/settings', async (_req, res) => {
|
|
|
3954
4392
|
}
|
|
3955
4393
|
});
|
|
3956
4394
|
|
|
3957
|
-
app.patch('/api/settings', async (req, res) => {
|
|
4395
|
+
app.patch('/api/settings', async (req, res) => {
|
|
3958
4396
|
noStore(res);
|
|
3959
4397
|
try {
|
|
3960
4398
|
const body = req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : {};
|
|
@@ -3974,9 +4412,72 @@ app.patch('/api/settings', async (req, res) => {
|
|
|
3974
4412
|
}
|
|
3975
4413
|
res.status(error?.status || 400).json({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
3976
4414
|
}
|
|
3977
|
-
});
|
|
3978
|
-
|
|
3979
|
-
function
|
|
4415
|
+
});
|
|
4416
|
+
|
|
4417
|
+
function wallPreferencesResponse(record) {
|
|
4418
|
+
return {
|
|
4419
|
+
ok: true,
|
|
4420
|
+
revision: Number(record?.revision || 0),
|
|
4421
|
+
updatedAt: String(record?.updatedAt || ''),
|
|
4422
|
+
wallPreferences: {
|
|
4423
|
+
cadence: String(record?.cadence || record?.settings?.wall?.cadence || 'fast'),
|
|
4424
|
+
viewScale: Number(record?.viewScale ?? record?.settings?.wall?.viewScale ?? 100),
|
|
4425
|
+
showThisComputer: Boolean(
|
|
4426
|
+
record?.settings?.connection?.showThisComputer
|
|
4427
|
+
?? liveDeskSettingsStore.getCached()?.connection?.showThisComputer
|
|
4428
|
+
)
|
|
4429
|
+
}
|
|
4430
|
+
};
|
|
4431
|
+
}
|
|
4432
|
+
|
|
4433
|
+
app.get('/api/remote/wall-preferences', async (_req, res) => {
|
|
4434
|
+
noStore(res);
|
|
4435
|
+
try {
|
|
4436
|
+
res.json(wallPreferencesResponse(await liveDeskSettingsStore.getWallPreferencesRecord()));
|
|
4437
|
+
} catch (error) {
|
|
4438
|
+
res.status(500).json({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
4439
|
+
}
|
|
4440
|
+
});
|
|
4441
|
+
|
|
4442
|
+
app.patch('/api/remote/wall-preferences', async (req, res) => {
|
|
4443
|
+
noStore(res);
|
|
4444
|
+
try {
|
|
4445
|
+
const body = req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : null;
|
|
4446
|
+
const allowedKeys = new Set(['revision', 'cadence', 'viewScale']);
|
|
4447
|
+
if (!body || Object.keys(body).some(key => !allowedKeys.has(key))) {
|
|
4448
|
+
res.status(400).json({ ok: false, error: 'wall-preferences-invalid' });
|
|
4449
|
+
return;
|
|
4450
|
+
}
|
|
4451
|
+
const revision = Number(body.revision);
|
|
4452
|
+
const cadence = String(body.cadence || '').trim().toLowerCase();
|
|
4453
|
+
const viewScale = Number(body.viewScale);
|
|
4454
|
+
if (!Number.isSafeInteger(revision)
|
|
4455
|
+
|| revision < 0
|
|
4456
|
+
|| !['slow', 'fast'].includes(cadence)
|
|
4457
|
+
|| !Number.isFinite(viewScale)
|
|
4458
|
+
|| viewScale < 0
|
|
4459
|
+
|| viewScale > 100) {
|
|
4460
|
+
res.status(400).json({ ok: false, error: 'wall-preferences-invalid' });
|
|
4461
|
+
return;
|
|
4462
|
+
}
|
|
4463
|
+
const record = await liveDeskSettingsStore.update({ wall: { cadence, viewScale } }, revision);
|
|
4464
|
+
res.json(wallPreferencesResponse(record));
|
|
4465
|
+
} catch (error) {
|
|
4466
|
+
if (error instanceof SettingsConflictError) {
|
|
4467
|
+
const wall = error.settings?.settings?.wall || {};
|
|
4468
|
+
res.status(409).json(wallPreferencesResponse({
|
|
4469
|
+
revision: error.settings.revision,
|
|
4470
|
+
updatedAt: error.settings.updatedAt,
|
|
4471
|
+
cadence: wall.cadence,
|
|
4472
|
+
viewScale: wall.viewScale
|
|
4473
|
+
}));
|
|
4474
|
+
return;
|
|
4475
|
+
}
|
|
4476
|
+
res.status(error?.status || 400).json({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
4477
|
+
}
|
|
4478
|
+
});
|
|
4479
|
+
|
|
4480
|
+
function sendCaptureError(res, error) {
|
|
3980
4481
|
const rawCode = String(error?.message || error?.code || 'capture-request-failed');
|
|
3981
4482
|
const code = rawCode.replace(/[^a-z0-9-:]/gi, '-').toLowerCase().slice(0, 100);
|
|
3982
4483
|
const status = Number.isInteger(error?.status) && error.status >= 400 && error.status <= 599
|
|
@@ -4345,20 +4846,23 @@ app.post('/api/settings/agent/summary', async (req, res) => {
|
|
|
4345
4846
|
}
|
|
4346
4847
|
});
|
|
4347
4848
|
|
|
4348
|
-
app.get('/api/remote/status', async (
|
|
4849
|
+
app.get('/api/remote/status', async (req, res) => {
|
|
4349
4850
|
noStore(res);
|
|
4350
4851
|
if (runtimeRole !== 'hub') {
|
|
4351
4852
|
res.status(403).json({ ok: false, error: 'role-not-allowed' });
|
|
4352
4853
|
return;
|
|
4353
|
-
}
|
|
4854
|
+
}
|
|
4354
4855
|
try {
|
|
4856
|
+
const includePairingPin = authorizedWorkspaceRole(req) === 'owner';
|
|
4355
4857
|
const [secretStatus, wallPreferences] = await Promise.all([
|
|
4356
|
-
Promise.resolve(remoteHub.getStatus({ includeSecrets: true })),
|
|
4858
|
+
Promise.resolve(includePairingPin ? remoteHub.getStatus({ includeSecrets: true }) : {}),
|
|
4357
4859
|
liveDeskSettingsStore.getWallPreferencesRecord()
|
|
4358
4860
|
]);
|
|
4861
|
+
const visibleStatus = { ...remoteHub.getStatus({ includeSecrets: false }) };
|
|
4862
|
+
if (!includePairingPin) delete visibleStatus.pairingPin;
|
|
4359
4863
|
res.json({
|
|
4360
|
-
...
|
|
4361
|
-
pairingPin: secretStatus.pairingPin,
|
|
4864
|
+
...visibleStatus,
|
|
4865
|
+
...(includePairingPin ? { pairingPin: secretStatus.pairingPin } : {}),
|
|
4362
4866
|
product: 'LiveDesk',
|
|
4363
4867
|
runtimeRole,
|
|
4364
4868
|
deviceId: runtimeDeviceId,
|
|
@@ -4366,6 +4870,7 @@ app.get('/api/remote/status', async (_req, res) => {
|
|
|
4366
4870
|
roleSource: runtimeRoleSource,
|
|
4367
4871
|
agentPackage: '@livedesk/client',
|
|
4368
4872
|
wallPreferences,
|
|
4873
|
+
workspace: workspaceAccessSnapshot(),
|
|
4369
4874
|
consoleDirect: hubConsoleDirect?.inspect() || { enabled: false, state: 'starting' },
|
|
4370
4875
|
frameLanes: snapshotFrameLaneResourceHealth(),
|
|
4371
4876
|
update: getLiveDeskUpdateStatus()
|
|
@@ -4546,72 +5051,242 @@ app.post('/api/runtime/switch-role', (req, res) => {
|
|
|
4546
5051
|
res.redirect(307, '/api/runtime/role');
|
|
4547
5052
|
});
|
|
4548
5053
|
|
|
4549
|
-
app.get('/api/auth/status', (_req, res) => {
|
|
4550
|
-
noStore(res);
|
|
4551
|
-
res.json({
|
|
4552
|
-
|
|
5054
|
+
app.get('/api/auth/status', (_req, res) => {
|
|
5055
|
+
noStore(res);
|
|
5056
|
+
res.json({
|
|
5057
|
+
ok: true,
|
|
5058
|
+
authenticated: runtimeManager.getSnapshot().authenticated,
|
|
5059
|
+
userId: runtimeManager.getSnapshot().userId || null,
|
|
5060
|
+
role: runtimeRole,
|
|
5061
|
+
workspace: workspaceAccessSnapshot()
|
|
5062
|
+
});
|
|
5063
|
+
});
|
|
4553
5064
|
|
|
4554
|
-
app.post('/api/auth/session', async (req, res) => {
|
|
4555
|
-
noStore(res);
|
|
4556
|
-
const
|
|
5065
|
+
app.post('/api/auth/session', async (req, res) => {
|
|
5066
|
+
noStore(res);
|
|
5067
|
+
const consoleProxyAuthorization = authorizeConsoleProxyRequest(req);
|
|
5068
|
+
if (consoleProxyAuthorization) {
|
|
5069
|
+
if (!consoleProxyAuthorization.ok) {
|
|
5070
|
+
res.status(consoleProxyAuthorization.status).json({ ok: false, error: consoleProxyAuthorization.error });
|
|
5071
|
+
return;
|
|
5072
|
+
}
|
|
5073
|
+
const validConsoleWorkspaceMember = consoleProxyAuthorization.workspaceKind === 'team'
|
|
5074
|
+
? ['owner', 'operator'].includes(consoleProxyAuthorization.role)
|
|
5075
|
+
: consoleProxyAuthorization.workspaceKind === 'personal'
|
|
5076
|
+
&& consoleProxyAuthorization.role === 'owner';
|
|
5077
|
+
if (!validConsoleWorkspaceMember
|
|
5078
|
+
|| !workspaceEntitlementCanRequest(consoleProxyAuthorization, 'POST', '/api/auth/session')) {
|
|
5079
|
+
res.status(403).json({ ok: false, error: 'workspace-member-session-required' });
|
|
5080
|
+
return;
|
|
5081
|
+
}
|
|
5082
|
+
const accessExpiresAt = consoleProxyAuthorization.workspaceKind === 'team'
|
|
5083
|
+
? Math.min(Number(consoleProxyAuthorization.accessExpiresAt || 0), Date.now() + TEAM_UI_ACCESS_MAX_AGE_MS)
|
|
5084
|
+
: 0;
|
|
5085
|
+
const uiSession = hubUiSessionAuthority.renew(req, consoleProxyAuthorization.userId, {
|
|
5086
|
+
workspaceId: consoleProxyAuthorization.workspaceId,
|
|
5087
|
+
workspaceKind: consoleProxyAuthorization.workspaceKind,
|
|
5088
|
+
role: consoleProxyAuthorization.role,
|
|
5089
|
+
plan: consoleProxyAuthorization.plan,
|
|
5090
|
+
entitlementStatus: consoleProxyAuthorization.entitlementStatus,
|
|
5091
|
+
membershipRevision: consoleProxyAuthorization.membershipRevision,
|
|
5092
|
+
accessExpiresAt
|
|
5093
|
+
});
|
|
5094
|
+
res.setHeader('Set-Cookie', serializeHubUiSessionCookie(uiSession.sessionId, uiSession.expiresAt, {
|
|
5095
|
+
secure: isSecureHubHttpRequest(req)
|
|
5096
|
+
}));
|
|
5097
|
+
res.json({
|
|
5098
|
+
ok: true,
|
|
5099
|
+
authenticated: true,
|
|
5100
|
+
persisted: false,
|
|
5101
|
+
runtimeOwner: false,
|
|
5102
|
+
userId: consoleProxyAuthorization.userId,
|
|
5103
|
+
role: runtimeRole,
|
|
5104
|
+
workspace: {
|
|
5105
|
+
workspaceId: consoleProxyAuthorization.workspaceId,
|
|
5106
|
+
kind: consoleProxyAuthorization.workspaceKind,
|
|
5107
|
+
role: consoleProxyAuthorization.role,
|
|
5108
|
+
membershipRevision: consoleProxyAuthorization.membershipRevision,
|
|
5109
|
+
plan: consoleProxyAuthorization.plan,
|
|
5110
|
+
status: consoleProxyAuthorization.entitlementStatus
|
|
5111
|
+
},
|
|
5112
|
+
license: licenseSnapshot(),
|
|
5113
|
+
uiSession: {
|
|
5114
|
+
csrfToken: uiSession.csrfToken,
|
|
5115
|
+
expiresAt: uiSession.expiresAt,
|
|
5116
|
+
accessExpiresAt
|
|
5117
|
+
}
|
|
5118
|
+
});
|
|
5119
|
+
return;
|
|
5120
|
+
}
|
|
5121
|
+
const requestAuthEpoch = runtimeAuthEpoch;
|
|
5122
|
+
const requestBoundUserId = currentBoundHubUserId();
|
|
5123
|
+
const requestBoundWorkspaceId = currentBoundHubWorkspaceId();
|
|
5124
|
+
const normalized = normalizeRuntimeAuthSession(req.body);
|
|
4557
5125
|
if (!normalized.ok) {
|
|
4558
5126
|
res.status(400).json({ ok: false, error: normalized.error });
|
|
4559
5127
|
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
|
-
|
|
5128
|
+
}
|
|
5129
|
+
const { access_token: accessToken, refresh_token: refreshToken, expires_at: expiresAt } = normalized.session;
|
|
5130
|
+
try {
|
|
5131
|
+
const user = await verifySupabaseUser(accessToken);
|
|
5132
|
+
const explicitlyRequestedWorkspaceId = req.body?.workspaceId ?? req.body?.workspace_id ?? '';
|
|
5133
|
+
const authoritativeWorkspaceId = await queryRuntimeDeviceWorkspace(accessToken);
|
|
5134
|
+
const nativeRuntimeWorkspaceFallback = !String(explicitlyRequestedWorkspaceId || '').trim()
|
|
5135
|
+
&& isTrustedNativeLoopbackRequest(req)
|
|
5136
|
+
? currentBoundHubWorkspaceId() || authoritativeWorkspaceId
|
|
5137
|
+
: '';
|
|
5138
|
+
const access = await verifyWorkspaceAccess(
|
|
5139
|
+
accessToken,
|
|
5140
|
+
user,
|
|
5141
|
+
explicitlyRequestedWorkspaceId || nativeRuntimeWorkspaceFallback
|
|
5142
|
+
);
|
|
5143
|
+
const boundUserId = currentBoundHubUserId();
|
|
5144
|
+
const boundWorkspaceId = currentBoundHubWorkspaceId();
|
|
5145
|
+
if (runtimeAuthEpoch !== requestAuthEpoch
|
|
5146
|
+
|| boundUserId !== requestBoundUserId
|
|
5147
|
+
|| boundWorkspaceId !== requestBoundWorkspaceId) {
|
|
5148
|
+
res.status(409).json({ ok: false, error: 'hub-auth-session-superseded' });
|
|
5149
|
+
return;
|
|
5150
|
+
}
|
|
5151
|
+
if (authoritativeWorkspaceId && authoritativeWorkspaceId !== access.workspaceId) {
|
|
5152
|
+
res.status(403).json({ ok: false, error: 'hub-device-workspace-mismatch' });
|
|
5153
|
+
return;
|
|
5154
|
+
}
|
|
5155
|
+
const sameRuntimeOwner = Boolean(boundUserId) && boundUserId === user.id;
|
|
5156
|
+
const memberUiOnly = Boolean(boundUserId)
|
|
5157
|
+
&& boundWorkspaceId === access.workspaceId
|
|
5158
|
+
&& ['owner', 'operator'].includes(access.role)
|
|
5159
|
+
&& (!sameRuntimeOwner
|
|
5160
|
+
|| access.role !== 'owner'
|
|
5161
|
+
|| !isTrustedNativeLoopbackRequest(req));
|
|
5162
|
+
const ownerLocalWorkspaceRebind = sameRuntimeOwner
|
|
5163
|
+
&& boundWorkspaceId
|
|
5164
|
+
&& boundWorkspaceId !== access.workspaceId
|
|
5165
|
+
&& access.role === 'owner'
|
|
5166
|
+
&& isTrustedNativeLoopbackRequest(req)
|
|
5167
|
+
&& authoritativeWorkspaceId === access.workspaceId;
|
|
5168
|
+
if (boundWorkspaceId && boundWorkspaceId !== access.workspaceId && !ownerLocalWorkspaceRebind) {
|
|
5169
|
+
res.status(403).json({ ok: false, error: 'hub-workspace-mismatch' });
|
|
5170
|
+
return;
|
|
5171
|
+
}
|
|
5172
|
+
if (!boundUserId
|
|
5173
|
+
&& (access.role !== 'owner' || !isLoopbackAddress(req.socket?.remoteAddress))) {
|
|
5174
|
+
res.status(403).json({
|
|
5175
|
+
ok: false,
|
|
5176
|
+
error: access.role === 'owner'
|
|
5177
|
+
? 'hub-first-account-binding-must-be-local'
|
|
5178
|
+
: 'hub-owner-session-required'
|
|
5179
|
+
});
|
|
5180
|
+
return;
|
|
5181
|
+
}
|
|
5182
|
+
if (!memberUiOnly && !refreshToken) {
|
|
5183
|
+
res.status(400).json({ ok: false, error: 'refresh-token-required' });
|
|
5184
|
+
return;
|
|
5185
|
+
}
|
|
5186
|
+
if (ownerLocalWorkspaceRebind) {
|
|
5187
|
+
hubUiSessionAuthority.revokeAll('hub-workspace-rebound');
|
|
5188
|
+
}
|
|
5189
|
+
if (access.workspaceKind === 'team' && !workspaceRemoteAccessActive(access)) {
|
|
5190
|
+
hubUiSessionAuthority.revokeWorkspaceMember(access.workspaceId, user.id);
|
|
5191
|
+
}
|
|
5192
|
+
const uiAccessExpiresAt = access.workspaceKind === 'team'
|
|
5193
|
+
? Date.now() + TEAM_UI_ACCESS_MAX_AGE_MS
|
|
5194
|
+
: 0;
|
|
5195
|
+
const uiSession = hubUiSessionAuthority.renew(req, user.id, {
|
|
5196
|
+
workspaceId: access.workspaceId,
|
|
5197
|
+
workspaceKind: access.workspaceKind,
|
|
5198
|
+
role: access.role,
|
|
5199
|
+
plan: access.plan,
|
|
5200
|
+
entitlementStatus: access.entitlementStatus,
|
|
5201
|
+
membershipRevision: access.membershipRevision,
|
|
5202
|
+
accessExpiresAt: uiAccessExpiresAt
|
|
5203
|
+
});
|
|
5204
|
+
res.setHeader('Set-Cookie', serializeHubUiSessionCookie(uiSession.sessionId, uiSession.expiresAt, {
|
|
5205
|
+
secure: isSecureHubHttpRequest(req)
|
|
5206
|
+
}));
|
|
5207
|
+
if (memberUiOnly) {
|
|
5208
|
+
res.json({
|
|
5209
|
+
ok: true,
|
|
5210
|
+
authenticated: true,
|
|
5211
|
+
persisted: false,
|
|
5212
|
+
runtimeOwner: false,
|
|
5213
|
+
userId: user.id,
|
|
5214
|
+
role: runtimeRole,
|
|
5215
|
+
workspace: workspaceAccessSnapshot(access),
|
|
5216
|
+
license: licenseSnapshot(),
|
|
5217
|
+
hostTarget: {
|
|
5218
|
+
ok: true,
|
|
5219
|
+
pending: false,
|
|
5220
|
+
active: remoteHub.getStatus({ includeSecrets: false }).hostTargetActive === true
|
|
5221
|
+
},
|
|
5222
|
+
uiSession: {
|
|
5223
|
+
csrfToken: uiSession.csrfToken,
|
|
5224
|
+
expiresAt: uiSession.expiresAt,
|
|
5225
|
+
accessExpiresAt: uiAccessExpiresAt
|
|
5226
|
+
}
|
|
5227
|
+
});
|
|
5228
|
+
return;
|
|
5229
|
+
}
|
|
5230
|
+
if (boundUserId && !sameRuntimeOwner) {
|
|
5231
|
+
res.status(403).json({ ok: false, error: 'hub-account-mismatch' });
|
|
5232
|
+
return;
|
|
5233
|
+
}
|
|
5234
|
+
runtimeAuthEpoch += 1;
|
|
5235
|
+
runtimeAccessToken = accessToken;
|
|
5236
|
+
runtimeRefreshToken = refreshToken;
|
|
5237
|
+
runtimeAccessTokenExpiresAt = normalizeSessionExpiryMs(expiresAt);
|
|
5238
|
+
runtimeWorkspaceAccess = access;
|
|
5239
|
+
if (ownerLocalWorkspaceRebind) {
|
|
5240
|
+
hubConsoleDirect?.invalidateWorkspaceAccess('hub-workspace-rebound');
|
|
5241
|
+
}
|
|
5242
|
+
workspaceAccessLastAttemptAt = Date.now();
|
|
5243
|
+
licenseRefreshLastAttemptAt = Date.now();
|
|
5244
|
+
runtimeManager.setAuthenticated(true, user.id);
|
|
4577
5245
|
const persisted = persistRuntimeSession({
|
|
4578
5246
|
access_token: accessToken,
|
|
4579
5247
|
refresh_token: refreshToken,
|
|
4580
5248
|
expires_at: expiresAt,
|
|
4581
|
-
user
|
|
4582
|
-
});
|
|
4583
|
-
const
|
|
4584
|
-
|
|
4585
|
-
|
|
4586
|
-
|
|
4587
|
-
|
|
4588
|
-
|
|
4589
|
-
|
|
4590
|
-
|
|
4591
|
-
|
|
4592
|
-
|
|
4593
|
-
|
|
4594
|
-
|
|
5249
|
+
user
|
|
5250
|
+
});
|
|
5251
|
+
const workspacePersisted = persistRuntimeWorkspace(access);
|
|
5252
|
+
applyVerifiedWorkspaceLicense(access);
|
|
5253
|
+
const teamRemoteActive = workspaceRemoteAccessActive(access);
|
|
5254
|
+
let hostTarget = runtimeRole === 'hub'
|
|
5255
|
+
? {
|
|
5256
|
+
ok: true,
|
|
5257
|
+
pending: true,
|
|
5258
|
+
active: remoteHub.getStatus({ includeSecrets: false }).hostTargetActive === true
|
|
5259
|
+
}
|
|
5260
|
+
: { ok: true, pending: false, active: false };
|
|
5261
|
+
if (runtimeRole === 'hub' && !teamRemoteActive) {
|
|
5262
|
+
hubConsoleDirect?.close();
|
|
5263
|
+
const cleared = await clearHubHostTarget('workspace-entitlement-inactive');
|
|
5264
|
+
hostTarget = { ok: cleared.ok, pending: false, active: false, error: cleared.error || '' };
|
|
5265
|
+
}
|
|
5266
|
+
res.json({
|
|
4595
5267
|
ok: true,
|
|
4596
|
-
authenticated: true,
|
|
4597
|
-
persisted,
|
|
4598
|
-
|
|
4599
|
-
|
|
5268
|
+
authenticated: true,
|
|
5269
|
+
persisted: persisted && workspacePersisted,
|
|
5270
|
+
runtimeOwner: true,
|
|
5271
|
+
userId: user.id,
|
|
5272
|
+
role: runtimeRole,
|
|
5273
|
+
workspace: workspaceAccessSnapshot(access),
|
|
5274
|
+
license: licenseSnapshot(),
|
|
4600
5275
|
hostTarget,
|
|
4601
5276
|
uiSession: {
|
|
4602
5277
|
csrfToken: uiSession.csrfToken,
|
|
4603
5278
|
expiresAt: uiSession.expiresAt
|
|
4604
5279
|
}
|
|
4605
5280
|
});
|
|
4606
|
-
if (runtimeRole === 'hub') {
|
|
5281
|
+
if (runtimeRole === 'hub' && teamRemoteActive) {
|
|
4607
5282
|
hubConsoleDirect?.refresh();
|
|
4608
5283
|
scheduleAuthenticatedHubHostTargetPublication('session-received');
|
|
4609
5284
|
}
|
|
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
|
-
}
|
|
5285
|
+
} catch (error) {
|
|
5286
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
5287
|
+
const status = workspaceAccessHttpStatus(error);
|
|
5288
|
+
res.status(status).json({ ok: false, error: message });
|
|
5289
|
+
}
|
|
4615
5290
|
});
|
|
4616
5291
|
|
|
4617
5292
|
function updateHubHostTargetLeaseState(patch = {}) {
|
|
@@ -4643,19 +5318,20 @@ function hubWakeSignalUrl() {
|
|
|
4643
5318
|
return url.toString();
|
|
4644
5319
|
}
|
|
4645
5320
|
|
|
4646
|
-
function buildHubWakeEventId(target, remoteStatus, endpointCandidates) {
|
|
4647
|
-
return crypto.createHash('sha256').update(JSON.stringify({
|
|
4648
|
-
|
|
5321
|
+
function buildHubWakeEventId(target, remoteStatus, endpointCandidates, workspaceId = currentBoundHubWorkspaceId()) {
|
|
5322
|
+
return crypto.createHash('sha256').update(JSON.stringify({
|
|
5323
|
+
workspaceId,
|
|
5324
|
+
deviceId: runtimeDeviceId,
|
|
4649
5325
|
leaseId: target.leaseId || '',
|
|
4650
5326
|
hostInstanceId: target.hostInstanceId || remoteStatus.hostInstanceId || '',
|
|
4651
5327
|
endpointCandidates: [...endpointCandidates].sort()
|
|
4652
5328
|
})).digest('hex').slice(0, 40);
|
|
4653
5329
|
}
|
|
4654
5330
|
|
|
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);
|
|
5331
|
+
async function notifyHubOnline({ accessToken, target, remoteStatus, endpointCandidates, workspaceId }) {
|
|
5332
|
+
const url = hubWakeSignalUrl();
|
|
5333
|
+
if (!url) return { ok: false, skipped: true, reason: 'wake-disabled' };
|
|
5334
|
+
const eventId = buildHubWakeEventId(target, remoteStatus, endpointCandidates, workspaceId);
|
|
4659
5335
|
if (hubWakeNotificationState.lastEventId === eventId && hubWakeNotificationState.state === 'notified') {
|
|
4660
5336
|
return { ok: true, skipped: true, eventId };
|
|
4661
5337
|
}
|
|
@@ -4678,7 +5354,11 @@ async function notifyHubOnline({ accessToken, target, remoteStatus, endpointCand
|
|
|
4678
5354
|
'Content-Type': 'application/json',
|
|
4679
5355
|
Accept: 'application/json'
|
|
4680
5356
|
},
|
|
4681
|
-
body: JSON.stringify({
|
|
5357
|
+
body: JSON.stringify({
|
|
5358
|
+
workspaceId,
|
|
5359
|
+
deviceId: runtimeDeviceId,
|
|
5360
|
+
eventId
|
|
5361
|
+
})
|
|
4682
5362
|
}, SUPABASE_AUTH_TIMEOUT_MS);
|
|
4683
5363
|
const data = await response.json().catch(() => null);
|
|
4684
5364
|
if (!response.ok || data?.ok === false) {
|
|
@@ -4727,9 +5407,8 @@ async function callHubSupabaseRpc(name, payload, accessToken) {
|
|
|
4727
5407
|
);
|
|
4728
5408
|
const data = await response.json().catch(() => null);
|
|
4729
5409
|
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}`);
|
|
5410
|
+
if (response.status === 401 || response.status === 403) {
|
|
5411
|
+
throw new Error(`${name}-not-authenticated:${response.status}`);
|
|
4733
5412
|
}
|
|
4734
5413
|
if (!response.ok) {
|
|
4735
5414
|
throw new Error(`${name}-failed:${response.status}`);
|
|
@@ -4740,16 +5419,21 @@ async function callHubSupabaseRpc(name, payload, accessToken) {
|
|
|
4740
5419
|
return result;
|
|
4741
5420
|
}
|
|
4742
5421
|
|
|
4743
|
-
async function promoteCurrentRuntimeToHub() {
|
|
4744
|
-
if (runtimeRole !== 'hub' || !runtimeDeviceId) {
|
|
4745
|
-
throw new Error('hub-runtime-identity-unavailable');
|
|
4746
|
-
}
|
|
5422
|
+
async function promoteCurrentRuntimeToHub() {
|
|
5423
|
+
if (runtimeRole !== 'hub' || !runtimeDeviceId) {
|
|
5424
|
+
throw new Error('hub-runtime-identity-unavailable');
|
|
5425
|
+
}
|
|
5426
|
+
if (runtimeWorkspaceAccess?.workspaceKind === 'team'
|
|
5427
|
+
&& !workspaceRemoteAccessActive(runtimeWorkspaceAccess)) {
|
|
5428
|
+
throw new Error('workspace-entitlement-inactive');
|
|
5429
|
+
}
|
|
4747
5430
|
const accessToken = await getRuntimeAccessToken();
|
|
4748
5431
|
if (!accessToken) {
|
|
4749
5432
|
throw new Error('hub-session-required');
|
|
4750
5433
|
}
|
|
4751
|
-
const result = await callHubSupabaseRpc('
|
|
4752
|
-
|
|
5434
|
+
const result = await callHubSupabaseRpc('set_livedesk_workspace_device_role', {
|
|
5435
|
+
p_workspace_id: currentBoundHubWorkspaceId(),
|
|
5436
|
+
p_device_id: runtimeDeviceId,
|
|
4753
5437
|
p_role: 'hub',
|
|
4754
5438
|
p_assigned_hub_id: null,
|
|
4755
5439
|
p_expected_role_version: null
|
|
@@ -4758,10 +5442,22 @@ async function promoteCurrentRuntimeToHub() {
|
|
|
4758
5442
|
return result;
|
|
4759
5443
|
}
|
|
4760
5444
|
|
|
4761
|
-
async function publishHubHostTarget({ takeover = false, reason = 'renewal' } = {}) {
|
|
4762
|
-
|
|
4763
|
-
|
|
4764
|
-
|
|
5445
|
+
async function publishHubHostTarget({ takeover = false, reason = 'renewal' } = {}) {
|
|
5446
|
+
const operationAuthEpoch = runtimeAuthEpoch;
|
|
5447
|
+
const operationUserId = currentBoundHubUserId();
|
|
5448
|
+
const operationWorkspaceId = currentBoundHubWorkspaceId();
|
|
5449
|
+
if (runtimeRole !== 'hub') {
|
|
5450
|
+
throw new Error('hub-role-not-allowed');
|
|
5451
|
+
}
|
|
5452
|
+
if (runtimeWorkspaceAccess?.workspaceKind === 'team'
|
|
5453
|
+
&& !workspaceRemoteAccessActive(runtimeWorkspaceAccess)) {
|
|
5454
|
+
updateHubHostTargetLeaseState({
|
|
5455
|
+
state: 'inactive',
|
|
5456
|
+
lastReason: String(reason || 'renewal'),
|
|
5457
|
+
lastError: 'workspace-entitlement-inactive'
|
|
5458
|
+
});
|
|
5459
|
+
return { ok: false, active: false, error: 'workspace-entitlement-inactive' };
|
|
5460
|
+
}
|
|
4765
5461
|
if (hubHostTargetRenewInFlight) {
|
|
4766
5462
|
return { ok: false, error: 'host-target-publish-already-running', hostTarget: remoteHub.getStatus({ includeSecrets: false }) };
|
|
4767
5463
|
}
|
|
@@ -4777,7 +5473,10 @@ async function publishHubHostTarget({ takeover = false, reason = 'renewal' } = {
|
|
|
4777
5473
|
if (!accessToken) {
|
|
4778
5474
|
throw new Error('hub-session-required');
|
|
4779
5475
|
}
|
|
4780
|
-
const authoritativeRole = await queryAuthoritativeRuntimeRole();
|
|
5476
|
+
const authoritativeRole = await queryAuthoritativeRuntimeRole();
|
|
5477
|
+
if (!runtimeAuthOwnerIsCurrent(operationAuthEpoch, operationUserId, operationWorkspaceId)) {
|
|
5478
|
+
return { ok: false, superseded: true, error: 'host-target-publish-superseded' };
|
|
5479
|
+
}
|
|
4781
5480
|
if (authoritativeRole !== 'hub') {
|
|
4782
5481
|
throw new Error(authoritativeRole === 'client' ? 'hub-role-no-longer-authoritative' : 'hub-role-not-authoritative');
|
|
4783
5482
|
}
|
|
@@ -4788,9 +5487,12 @@ async function publishHubHostTarget({ takeover = false, reason = 'renewal' } = {
|
|
|
4788
5487
|
leaseMs: HUB_HOST_TARGET_LEASE_MS,
|
|
4789
5488
|
takeover
|
|
4790
5489
|
});
|
|
4791
|
-
if (!local?.ok || !local.hostTarget) {
|
|
4792
|
-
throw new Error(local?.error || 'local-host-target-failed');
|
|
4793
|
-
}
|
|
5490
|
+
if (!local?.ok || !local.hostTarget) {
|
|
5491
|
+
throw new Error(local?.error || 'local-host-target-failed');
|
|
5492
|
+
}
|
|
5493
|
+
if (!runtimeAuthOwnerIsCurrent(operationAuthEpoch, operationUserId, operationWorkspaceId)) {
|
|
5494
|
+
return { ok: false, superseded: true, error: 'host-target-publish-superseded' };
|
|
5495
|
+
}
|
|
4794
5496
|
|
|
4795
5497
|
const remoteStatus = remoteHub.getStatus({ includeSecrets: true });
|
|
4796
5498
|
if (!remoteStatus.agentEndpointAccountRouteReady) {
|
|
@@ -4800,8 +5502,9 @@ async function publishHubHostTarget({ takeover = false, reason = 'renewal' } = {
|
|
|
4800
5502
|
const endpointCandidates = Array.isArray(target.endpointCandidates) && target.endpointCandidates.length > 0
|
|
4801
5503
|
? target.endpointCandidates
|
|
4802
5504
|
: [target.endpoint].filter(Boolean);
|
|
4803
|
-
await callHubSupabaseRpc('
|
|
4804
|
-
|
|
5505
|
+
await callHubSupabaseRpc('set_livedesk_workspace_remote_host_target', {
|
|
5506
|
+
p_workspace_id: operationWorkspaceId,
|
|
5507
|
+
p_node_id: runtimeDeviceId,
|
|
4805
5508
|
p_lease_id: target.leaseId,
|
|
4806
5509
|
p_host_instance_id: target.hostInstanceId || remoteStatus.hostInstanceId || runtimeDeviceId,
|
|
4807
5510
|
p_endpoint: target.endpoint || remoteStatus.agentEndpoint,
|
|
@@ -4815,7 +5518,19 @@ async function publishHubHostTarget({ takeover = false, reason = 'renewal' } = {
|
|
|
4815
5518
|
p_endpoint_candidates: endpointCandidates,
|
|
4816
5519
|
p_pairing_pin: remoteStatus.pairingPin || ''
|
|
4817
5520
|
}, accessToken);
|
|
4818
|
-
|
|
5521
|
+
if (!runtimeAuthOwnerIsCurrent(operationAuthEpoch, operationUserId, operationWorkspaceId)) {
|
|
5522
|
+
return { ok: false, superseded: true, error: 'host-target-publish-superseded' };
|
|
5523
|
+
}
|
|
5524
|
+
await notifyHubOnline({
|
|
5525
|
+
accessToken,
|
|
5526
|
+
target,
|
|
5527
|
+
remoteStatus,
|
|
5528
|
+
endpointCandidates,
|
|
5529
|
+
workspaceId: operationWorkspaceId
|
|
5530
|
+
});
|
|
5531
|
+
if (!runtimeAuthOwnerIsCurrent(operationAuthEpoch, operationUserId, operationWorkspaceId)) {
|
|
5532
|
+
return { ok: false, superseded: true, error: 'host-target-publish-superseded' };
|
|
5533
|
+
}
|
|
4819
5534
|
|
|
4820
5535
|
updateHubHostTargetLeaseState({
|
|
4821
5536
|
state: 'active',
|
|
@@ -4851,7 +5566,10 @@ async function publishHubHostTargetWithPendingRoleTakeover(reason = 'renewal') {
|
|
|
4851
5566
|
return result;
|
|
4852
5567
|
}
|
|
4853
5568
|
|
|
4854
|
-
async function clearHubHostTarget(reason = 'shutdown') {
|
|
5569
|
+
async function clearHubHostTarget(reason = 'shutdown') {
|
|
5570
|
+
const operationAuthEpoch = runtimeAuthEpoch;
|
|
5571
|
+
const operationUserId = currentBoundHubUserId();
|
|
5572
|
+
const operationWorkspaceId = currentBoundHubWorkspaceId();
|
|
4855
5573
|
if (hubHostTargetRenewTimer) {
|
|
4856
5574
|
clearInterval(hubHostTargetRenewTimer);
|
|
4857
5575
|
hubHostTargetRenewTimer = null;
|
|
@@ -4864,18 +5582,24 @@ async function clearHubHostTarget(reason = 'shutdown') {
|
|
|
4864
5582
|
try {
|
|
4865
5583
|
const accessToken = await getRuntimeAccessToken();
|
|
4866
5584
|
if (accessToken && targetNodeId && targetLeaseId && targetInstanceId) {
|
|
4867
|
-
await callHubSupabaseRpc('
|
|
4868
|
-
|
|
5585
|
+
await callHubSupabaseRpc('clear_livedesk_workspace_remote_host_target', {
|
|
5586
|
+
p_workspace_id: operationWorkspaceId,
|
|
5587
|
+
p_node_id: targetNodeId,
|
|
4869
5588
|
p_lease_id: targetLeaseId,
|
|
4870
5589
|
p_host_instance_id: targetInstanceId,
|
|
4871
5590
|
p_force: false
|
|
4872
5591
|
}, accessToken);
|
|
4873
5592
|
}
|
|
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
|
-
|
|
5593
|
+
} catch (cause) {
|
|
5594
|
+
error = cause instanceof Error ? cause.message : String(cause);
|
|
5595
|
+
console.error(`[VuvoDesk Hub] Host target ${reason} clear failed: ${error}`);
|
|
5596
|
+
}
|
|
5597
|
+
const currentRemoteStatus = remoteHub.getStatus({ includeSecrets: false });
|
|
5598
|
+
if (!runtimeAuthOwnerIsCurrent(operationAuthEpoch, operationUserId, operationWorkspaceId)
|
|
5599
|
+
|| (targetLeaseId && String(currentRemoteStatus.hostTargetLeaseId || '') !== targetLeaseId)) {
|
|
5600
|
+
return { ok: true, superseded: true, error, lease: getHubHostTargetLeaseStatus() };
|
|
5601
|
+
}
|
|
5602
|
+
try {
|
|
4879
5603
|
await remoteHub.setHostTarget({ enabled: false, nodeId: targetNodeId });
|
|
4880
5604
|
} catch (cause) {
|
|
4881
5605
|
error = error || (cause instanceof Error ? cause.message : String(cause));
|
|
@@ -4914,16 +5638,31 @@ function scheduleAuthenticatedHubHostTargetPublication(reason = 'session-receive
|
|
|
4914
5638
|
});
|
|
4915
5639
|
}
|
|
4916
5640
|
|
|
4917
|
-
app.delete('/api/auth/session', async (req, res) => {
|
|
4918
|
-
noStore(res);
|
|
5641
|
+
app.delete('/api/auth/session', async (req, res) => {
|
|
5642
|
+
noStore(res);
|
|
5643
|
+
const nativeOwnerLogout = isTrustedNativeLoopbackRequest(req)
|
|
5644
|
+
&& !String(req.headers['x-livedesk-console-proxy'] || '').trim();
|
|
5645
|
+
if (!nativeOwnerLogout) {
|
|
5646
|
+
const revoked = hubUiSessionAuthority.revoke(req);
|
|
5647
|
+
res.setHeader('Set-Cookie', clearHubUiSessionCookie({ secure: isSecureHubHttpRequest(req) }));
|
|
5648
|
+
res.json({
|
|
5649
|
+
ok: true,
|
|
5650
|
+
authenticated: runtimeManager.getSnapshot().authenticated,
|
|
5651
|
+
role: runtimeRole,
|
|
5652
|
+
runtimePreserved: true,
|
|
5653
|
+
signedOutUserId: revoked?.userId || null,
|
|
5654
|
+
workspaceId: revoked?.workspaceId || currentBoundHubWorkspaceId()
|
|
5655
|
+
});
|
|
5656
|
+
return;
|
|
5657
|
+
}
|
|
4919
5658
|
const hostTarget = runtimeRole === 'hub'
|
|
4920
5659
|
? await clearHubHostTarget('logout')
|
|
4921
5660
|
: { ok: true, active: false };
|
|
4922
5661
|
clearRuntimeSession();
|
|
4923
5662
|
hubConsoleDirect?.close();
|
|
4924
5663
|
res.setHeader('Set-Cookie', clearHubUiSessionCookie({ secure: isSecureHubHttpRequest(req) }));
|
|
4925
|
-
res.json({ ok: true, authenticated: false, role: runtimeRole, hostTarget });
|
|
4926
|
-
});
|
|
5664
|
+
res.json({ ok: true, authenticated: false, role: runtimeRole, runtimePreserved: false, hostTarget });
|
|
5665
|
+
});
|
|
4927
5666
|
|
|
4928
5667
|
app.get('/api/hub/status', (_req, res) => {
|
|
4929
5668
|
noStore(res);
|
|
@@ -5015,34 +5754,19 @@ app.post('/api/runtime/role', async (req, res) => {
|
|
|
5015
5754
|
res.status(400).json({ ok: false, error: 'hub-can-only-transition-to-client' });
|
|
5016
5755
|
return;
|
|
5017
5756
|
}
|
|
5018
|
-
const
|
|
5019
|
-
const accessToken = authorization.replace(/^Bearer\s+/i, '').trim();
|
|
5757
|
+
const accessToken = await getRuntimeAccessToken().catch(() => '');
|
|
5020
5758
|
if (!accessToken || !runtimeDeviceId) {
|
|
5021
5759
|
res.status(401).json({ ok: false, error: 'supabase-access-token-and-device-id-required' });
|
|
5022
5760
|
return;
|
|
5023
5761
|
}
|
|
5024
5762
|
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
|
-
}
|
|
5763
|
+
const result = await callHubSupabaseRpc('set_livedesk_workspace_device_role', {
|
|
5764
|
+
p_workspace_id: currentBoundHubWorkspaceId(),
|
|
5765
|
+
p_device_id: runtimeDeviceId,
|
|
5766
|
+
p_role: 'client',
|
|
5767
|
+
p_assigned_hub_id: String(req.body?.assignedHubId || '').trim() || null,
|
|
5768
|
+
p_expected_role_version: Number.isInteger(Number(req.body?.expectedRoleVersion)) ? Number(req.body.expectedRoleVersion) : null
|
|
5769
|
+
}, accessToken);
|
|
5046
5770
|
await clearHubHostTarget('role-transition');
|
|
5047
5771
|
res.json({ ok: true, restarting: true, role: 'client', roleVersion: result?.role_version || 0 });
|
|
5048
5772
|
setTimeout(() => process.exit(ROLE_TRANSITION_EXIT_CODE), 150);
|
|
@@ -5090,38 +5814,103 @@ app.get('/api/remote/registry-credentials', (_req, res) => {
|
|
|
5090
5814
|
});
|
|
5091
5815
|
});
|
|
5092
5816
|
|
|
5093
|
-
app.get('/api/remote/license', (_req, res) => {
|
|
5817
|
+
app.get('/api/remote/license', (_req, res) => {
|
|
5094
5818
|
noStore(res);
|
|
5095
|
-
res.json(licenseSnapshot());
|
|
5096
|
-
});
|
|
5097
|
-
|
|
5098
|
-
app.post('/api/remote/
|
|
5819
|
+
res.json(licenseSnapshot());
|
|
5820
|
+
});
|
|
5821
|
+
|
|
5822
|
+
app.post('/api/remote/workspace/members/:userId/revoke', async (req, res) => {
|
|
5823
|
+
noStore(res);
|
|
5824
|
+
const workspaceId = String(req.body?.workspaceId || '').trim();
|
|
5825
|
+
const memberUserId = String(req.params.userId || '').trim().slice(0, 160);
|
|
5826
|
+
if (authorizedWorkspaceRole(req) !== 'owner') {
|
|
5827
|
+
res.status(403).json({ ok: false, error: 'workspace-owner-required' });
|
|
5828
|
+
return;
|
|
5829
|
+
}
|
|
5830
|
+
if (!workspaceId || workspaceId !== currentBoundHubWorkspaceId() || !memberUserId) {
|
|
5831
|
+
res.status(400).json({ ok: false, error: 'workspace-and-member-required' });
|
|
5832
|
+
return;
|
|
5833
|
+
}
|
|
5834
|
+
const retiredPeers = hubConsoleDirect?.revokeWorkspaceMember(
|
|
5835
|
+
workspaceId,
|
|
5836
|
+
memberUserId,
|
|
5837
|
+
'workspace-member-revoked'
|
|
5838
|
+
) || 0;
|
|
5839
|
+
const revokedUiSessions = hubUiSessionAuthority.revokeWorkspaceMember(workspaceId, memberUserId);
|
|
5840
|
+
let wakeRevoked = false;
|
|
5841
|
+
let wakeError = '';
|
|
5842
|
+
try {
|
|
5843
|
+
const accessToken = await getRuntimeAccessToken();
|
|
5844
|
+
if (!accessToken) throw new Error('hub-session-required');
|
|
5845
|
+
const revokeUrl = new URL('/v2/rtc/revoke-member', hubWakeBaseUrl);
|
|
5846
|
+
const response = await fetchAuthResponse(fetch, revokeUrl.href, {
|
|
5847
|
+
method: 'POST',
|
|
5848
|
+
headers: {
|
|
5849
|
+
Authorization: `Bearer ${accessToken}`,
|
|
5850
|
+
'Content-Type': 'application/json',
|
|
5851
|
+
Accept: 'application/json'
|
|
5852
|
+
},
|
|
5853
|
+
body: JSON.stringify({ workspaceId, memberUserId })
|
|
5854
|
+
}, SUPABASE_AUTH_TIMEOUT_MS);
|
|
5855
|
+
wakeRevoked = response.ok;
|
|
5856
|
+
if (!response.ok) wakeError = `wake-member-revoke-failed:${response.status}`;
|
|
5857
|
+
} catch (error) {
|
|
5858
|
+
wakeError = error instanceof Error ? error.message : String(error);
|
|
5859
|
+
}
|
|
5860
|
+
res.json({
|
|
5861
|
+
ok: true,
|
|
5862
|
+
workspaceId,
|
|
5863
|
+
memberUserId,
|
|
5864
|
+
retiredPeers,
|
|
5865
|
+
revokedUiSessions,
|
|
5866
|
+
wakeRevoked,
|
|
5867
|
+
...(wakeError ? { wakeError } : {})
|
|
5868
|
+
});
|
|
5869
|
+
});
|
|
5870
|
+
|
|
5871
|
+
app.post('/api/remote/license/sync', async (req, res) => {
|
|
5099
5872
|
noStore(res);
|
|
5100
5873
|
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
|
-
|
|
5874
|
+
const authorization = String(req.headers.authorization || '');
|
|
5875
|
+
const accessToken = authorization.replace(/^Bearer\s+/i, '').trim();
|
|
5876
|
+
res.json(await syncVerifiedLicense(accessToken, req.body?.workspaceId ?? req.body?.workspace_id ?? ''));
|
|
5877
|
+
} catch (err) {
|
|
5878
|
+
const explicitAccessRejection = err instanceof WorkspaceAccessError && err.status < 500;
|
|
5879
|
+
if (explicitAccessRejection) {
|
|
5880
|
+
verifiedLicense = {
|
|
5881
|
+
userId: '',
|
|
5882
|
+
workspaceId: currentBoundHubWorkspaceId(),
|
|
5883
|
+
workspaceKind: runtimeWorkspaceAccess?.workspaceKind || 'personal',
|
|
5884
|
+
plan: 'free',
|
|
5885
|
+
status: 'inactive',
|
|
5886
|
+
expiresAt: '',
|
|
5887
|
+
memberLimit: 1,
|
|
5888
|
+
commercialUse: false,
|
|
5889
|
+
verifiedAt: Date.now()
|
|
5890
|
+
};
|
|
5891
|
+
}
|
|
5892
|
+
res.status(workspaceAccessHttpStatus(err)).json({
|
|
5893
|
+
ok: false,
|
|
5894
|
+
error: err instanceof Error ? err.message : String(err),
|
|
5895
|
+
preserved: !explicitAccessRejection && Date.now() - verifiedLicense.verifiedAt <= LICENSE_VERIFY_MAX_AGE_MS,
|
|
5896
|
+
license: licenseSnapshot()
|
|
5897
|
+
});
|
|
5898
|
+
}
|
|
5899
|
+
});
|
|
5115
5900
|
|
|
5116
5901
|
app.delete('/api/remote/license', (_req, res) => {
|
|
5117
5902
|
noStore(res);
|
|
5118
|
-
verifiedLicense = {
|
|
5119
|
-
userId: '',
|
|
5120
|
-
|
|
5121
|
-
|
|
5122
|
-
|
|
5123
|
-
|
|
5124
|
-
|
|
5903
|
+
verifiedLicense = {
|
|
5904
|
+
userId: '',
|
|
5905
|
+
workspaceId: currentBoundHubWorkspaceId(),
|
|
5906
|
+
workspaceKind: runtimeWorkspaceAccess?.workspaceKind || 'personal',
|
|
5907
|
+
plan: 'free',
|
|
5908
|
+
status: 'inactive',
|
|
5909
|
+
expiresAt: '',
|
|
5910
|
+
memberLimit: 1,
|
|
5911
|
+
commercialUse: false,
|
|
5912
|
+
verifiedAt: Date.now()
|
|
5913
|
+
};
|
|
5125
5914
|
res.json(licenseSnapshot());
|
|
5126
5915
|
});
|
|
5127
5916
|
|
|
@@ -5787,10 +6576,26 @@ httpServer.on('upgrade', (req, socket, head) => {
|
|
|
5787
6576
|
const label = authorization.status === 403 ? 'Forbidden' : 'Unauthorized';
|
|
5788
6577
|
socket.write(`HTTP/1.1 ${authorization.status} ${label}\r\nConnection: close\r\n\r\n`);
|
|
5789
6578
|
socket.destroy();
|
|
5790
|
-
return;
|
|
5791
|
-
}
|
|
5792
|
-
try {
|
|
5793
|
-
const parsed = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
|
|
6579
|
+
return;
|
|
6580
|
+
}
|
|
6581
|
+
try {
|
|
6582
|
+
const parsed = new URL(req.url || '/', `http://${req.headers.host || '127.0.0.1'}`);
|
|
6583
|
+
if (!workspaceEntitlementCanRequest(
|
|
6584
|
+
authorizationWorkspaceAccess(authorization),
|
|
6585
|
+
'GET',
|
|
6586
|
+
parsed.pathname
|
|
6587
|
+
)) {
|
|
6588
|
+
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
|
|
6589
|
+
socket.destroy();
|
|
6590
|
+
return;
|
|
6591
|
+
}
|
|
6592
|
+
if (authorization.role
|
|
6593
|
+
&& !workspaceRoleCanRequest(authorization.role, 'GET', parsed.pathname)) {
|
|
6594
|
+
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
|
|
6595
|
+
socket.destroy();
|
|
6596
|
+
return;
|
|
6597
|
+
}
|
|
6598
|
+
req.liveDeskAuthorization = authorization;
|
|
5794
6599
|
if (parsed.pathname === '/api/remote/frames/ws') {
|
|
5795
6600
|
frameWss.handleUpgrade(req, socket, head, ws => frameWss.emit('connection', ws, req));
|
|
5796
6601
|
return;
|
|
@@ -5815,7 +6620,8 @@ httpServer.on('upgrade', (req, socket, head) => {
|
|
|
5815
6620
|
}
|
|
5816
6621
|
});
|
|
5817
6622
|
|
|
5818
|
-
frameWss.on('connection', (ws, req) => {
|
|
6623
|
+
frameWss.on('connection', (ws, req) => {
|
|
6624
|
+
bindHubUiSessionSocket(ws, req);
|
|
5819
6625
|
frameClients.add(ws);
|
|
5820
6626
|
ws.liveDeskFrameClientId = `rfws-${++frameClientSeq}`;
|
|
5821
6627
|
try {
|
|
@@ -5834,6 +6640,7 @@ frameWss.on('connection', (ws, req) => {
|
|
|
5834
6640
|
updateFrameSubscription(ws, {});
|
|
5835
6641
|
}
|
|
5836
6642
|
ws.on('message', data => {
|
|
6643
|
+
if (ws.liveDeskUiSessionRevoked === true) return;
|
|
5837
6644
|
try {
|
|
5838
6645
|
const payload = JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data || ''));
|
|
5839
6646
|
if (payload?.type === 'subscribe') {
|
|
@@ -5857,7 +6664,8 @@ frameWss.on('connection', (ws, req) => {
|
|
|
5857
6664
|
});
|
|
5858
6665
|
});
|
|
5859
6666
|
|
|
5860
|
-
atlasWss.on('connection', ws => {
|
|
6667
|
+
atlasWss.on('connection', (ws, req) => {
|
|
6668
|
+
bindHubUiSessionSocket(ws, req);
|
|
5861
6669
|
ws.liveDeskFrameClientId = `atlas-${++frameClientSeq}`;
|
|
5862
6670
|
try { ws._socket?.setNoDelay?.(true); } catch {}
|
|
5863
6671
|
const cleanup = () => {
|
|
@@ -5871,7 +6679,8 @@ atlasWss.on('connection', ws => {
|
|
|
5871
6679
|
ws.liveDeskAtlasSession = null;
|
|
5872
6680
|
retireFrameClientSendLane(ws);
|
|
5873
6681
|
};
|
|
5874
|
-
ws.on('message', data => {
|
|
6682
|
+
ws.on('message', data => {
|
|
6683
|
+
if (ws.liveDeskUiSessionRevoked === true) return;
|
|
5875
6684
|
try {
|
|
5876
6685
|
const payload = JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data || ''));
|
|
5877
6686
|
if (payload?.type === 'subscribe' || payload?.type === 'configure') {
|
|
@@ -5891,7 +6700,8 @@ atlasWss.on('connection', ws => {
|
|
|
5891
6700
|
});
|
|
5892
6701
|
});
|
|
5893
6702
|
|
|
5894
|
-
audioWss.on('connection', (ws, req) => {
|
|
6703
|
+
audioWss.on('connection', (ws, req) => {
|
|
6704
|
+
bindHubUiSessionSocket(ws, req);
|
|
5895
6705
|
audioClients.add(ws);
|
|
5896
6706
|
ws.liveDeskAudioClientId = `raws-${++audioClientSeq}`;
|
|
5897
6707
|
ws.liveDeskAudioCleanupComplete = false;
|
|
@@ -5904,7 +6714,8 @@ audioWss.on('connection', (ws, req) => {
|
|
|
5904
6714
|
} catch {
|
|
5905
6715
|
updateAudioSubscription(ws, {});
|
|
5906
6716
|
}
|
|
5907
|
-
ws.on('message', data => {
|
|
6717
|
+
ws.on('message', data => {
|
|
6718
|
+
if (ws.liveDeskUiSessionRevoked === true) return;
|
|
5908
6719
|
try {
|
|
5909
6720
|
const payload = JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data || ''));
|
|
5910
6721
|
if (payload?.type === 'subscribe') {
|
|
@@ -5925,7 +6736,8 @@ audioWss.on('connection', (ws, req) => {
|
|
|
5925
6736
|
});
|
|
5926
6737
|
});
|
|
5927
6738
|
|
|
5928
|
-
inputWss.on('connection', ws => {
|
|
6739
|
+
inputWss.on('connection', (ws, req) => {
|
|
6740
|
+
bindHubUiSessionSocket(ws, req);
|
|
5929
6741
|
inputClients.add(ws);
|
|
5930
6742
|
ws.liveDeskInputClientId = `riws-${++inputClientSeq}`;
|
|
5931
6743
|
ws.liveDeskInputDeviceIds = new Set();
|
|
@@ -5934,7 +6746,8 @@ inputWss.on('connection', ws => {
|
|
|
5934
6746
|
} catch {
|
|
5935
6747
|
// Best-effort latency hint for browser input sockets.
|
|
5936
6748
|
}
|
|
5937
|
-
ws.on('message', data => {
|
|
6749
|
+
ws.on('message', data => {
|
|
6750
|
+
if (ws.liveDeskUiSessionRevoked === true) return;
|
|
5938
6751
|
let payload;
|
|
5939
6752
|
try {
|
|
5940
6753
|
payload = JSON.parse(Buffer.isBuffer(data) ? data.toString('utf8') : String(data || ''));
|