@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/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
- const hubUiSessionAuthority = createHubUiSessionAuthority();
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 === 'pro' ? 'pro' : verifiedLicense.plan === 'ltd' ? 'ltd' : 'free';
385
- }
386
-
387
- function activeDeviceLimit() {
388
- const plan = activeLicensePlan();
389
- return plan === 'pro' ? Number.POSITIVE_INFINITY : plan === 'ltd' ? PLUS_DEVICE_LIMIT : FREE_DEVICE_LIMIT;
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
- verifiedAt: verifiedLicense.verifiedAt ? new Date(verifiedLicense.verifiedAt).toISOString() : '',
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
- async function syncVerifiedLicense(accessToken) {
427
- const token = String(accessToken || '').trim();
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 headers = {
433
- apikey: supabasePublishableKey,
434
- Authorization: `Bearer ${token}`,
435
- Accept: 'application/json'
436
- };
437
- const userId = user.id;
438
- const query = new URLSearchParams({
439
- select: 'user_id,product_key,plan,status,expires_at,updated_at',
440
- user_id: `eq.${userId}`,
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
- return writePrivateRuntimeAuthState(state);
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
- runtimeAccessToken = '';
540
- runtimeRefreshToken = '';
541
- runtimeAccessTokenExpiresAt = 0;
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
- const response = await fetchAuthResponse(
569
- fetch,
570
- `${supabaseUrl}/auth/v1/token?grant_type=refresh_token`,
571
- {
572
- method: 'POST',
573
- headers: {
574
- apikey: supabasePublishableKey,
575
- 'Content-Type': 'application/json',
576
- Accept: 'application/json'
577
- },
578
- body: JSON.stringify({ refresh_token: refreshToken })
579
- },
580
- SUPABASE_AUTH_TIMEOUT_MS
581
- );
582
- if (!response.ok) {
583
- if (response.status === 400 || response.status === 401 || response.status === 403) {
584
- clearRuntimeSession();
585
- throw new Error(`hub-session-refresh-failed:${response.status}`);
586
- }
587
- throw new Error(`hub-session-refresh-provider-failed:${response.status}`);
588
- }
589
- const refreshed = await response.json().catch(() => null);
590
- const nextAccessToken = String(refreshed?.access_token || '').trim();
591
- if (!nextAccessToken) {
592
- throw new Error('hub-session-refresh-missing-access-token');
593
- }
594
- runtimeAccessToken = nextAccessToken;
595
- runtimeRefreshToken = String(refreshed?.refresh_token || refreshToken).trim();
596
- runtimeAccessTokenExpiresAt = normalizeSessionExpiryMs(refreshed?.expires_at)
597
- || (Number(refreshed?.expires_in) > 0 ? Date.now() + Number(refreshed.expires_in) * 1000 : 0);
598
- try {
599
- persistRuntimeSession({
600
- access_token: runtimeAccessToken,
601
- refresh_token: runtimeRefreshToken,
602
- expires_at: Math.floor(runtimeAccessTokenExpiresAt / 1000)
603
- });
604
- } catch (error) {
605
- console.warn(`[VuvoDesk Hub] Refreshed session persistence failed: ${error?.message || error}`);
606
- }
607
- return runtimeAccessToken;
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 queryAuthoritativeRuntimeRole() {
646
- const accessToken = await getRuntimeAccessToken();
647
- if (runtimeRole !== 'hub' || !accessToken || !runtimeDeviceId) {
648
- return '';
649
- }
650
- const query = new URLSearchParams({
651
- select: 'role,enabled,role_version,assigned_hub_id',
652
- device_id: `eq.${runtimeDeviceId}`,
653
- limit: '1'
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
- if (!desktopMainAuthConfig) clearRuntimeSession();
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 role === 'hub' || role === 'client' ? role : '';
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
- roleWatchInFlight = true;
688
- try {
689
- const role = await queryAuthoritativeRuntimeRole();
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
- } catch {
697
- // A temporary provider failure must not stop a working Hub. The next
698
- // interval retries the authoritative role check.
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
- httpServer.on('connection', socket => {
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 authorizeHubUiRequest(req) {
1534
- if (process.env.LIVEDESK_TEST_MODE === '1') {
1535
- return { ok: true, explicitTestMode: true };
1536
- }
1537
- if (isTrustedNativeLoopbackRequest(req)) {
1538
- return { ok: true, nativeLoopback: true };
1539
- }
1540
- return hubUiSessionAuthority.authorize(req, currentBoundHubUserId());
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
- next();
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 sendCaptureError(res, error) {
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 (_req, res) => {
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
- ...remoteHub.getStatus({ includeSecrets: false }),
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({ ok: true, authenticated: runtimeManager.getSnapshot().authenticated, userId: runtimeManager.getSnapshot().userId || null, role: runtimeRole });
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 normalized = normalizeRuntimeAuthSession(req.body);
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 boundUserId = currentBoundHubUserId();
4565
- if (boundUserId && boundUserId !== user.id) {
4566
- res.status(403).json({ ok: false, error: 'hub-account-mismatch' });
4567
- return;
4568
- }
4569
- if (!boundUserId && !isLoopbackAddress(req.socket?.remoteAddress)) {
4570
- res.status(403).json({ ok: false, error: 'hub-first-account-binding-must-be-local' });
4571
- return;
4572
- }
4573
- runtimeAccessToken = accessToken;
4574
- runtimeRefreshToken = refreshToken;
4575
- runtimeAccessTokenExpiresAt = normalizeSessionExpiryMs(expiresAt);
4576
- runtimeManager.setAuthenticated(true, user.id);
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 hostTarget = runtimeRole === 'hub'
4584
- ? {
4585
- ok: true,
4586
- pending: true,
4587
- active: remoteHub.getStatus({ includeSecrets: false }).hostTargetActive === true
4588
- }
4589
- : { ok: true, pending: false, active: false };
4590
- const uiSession = hubUiSessionAuthority.issue(user.id);
4591
- res.setHeader('Set-Cookie', serializeHubUiSessionCookie(uiSession.sessionId, uiSession.expiresAt, {
4592
- secure: isSecureHubHttpRequest(req)
4593
- }));
4594
- res.json({
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
- userId: user.id,
4599
- role: runtimeRole,
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 = authVerificationHttpStatus(message);
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
- deviceId: runtimeDeviceId,
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({ deviceId: runtimeDeviceId, eventId })
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
- if (!desktopMainAuthConfig) clearRuntimeSession();
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('set_livedesk_device_role', {
4752
- p_device_id: runtimeDeviceId,
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
- if (runtimeRole !== 'hub') {
4763
- throw new Error('hub-role-not-allowed');
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('set_livedesk_remote_host_target', {
4804
- p_node_id: runtimeDeviceId,
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
- await notifyHubOnline({ accessToken, target, remoteStatus, endpointCandidates });
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('clear_livedesk_remote_host_target', {
4868
- p_node_id: targetNodeId,
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
- try {
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 authorization = String(req.headers.authorization || '').trim();
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 response = await fetch(`${supabaseUrl}/rest/v1/rpc/set_livedesk_device_role`, {
5026
- method: 'POST',
5027
- headers: {
5028
- apikey: supabasePublishableKey,
5029
- Authorization: `Bearer ${accessToken}`,
5030
- 'Content-Type': 'application/json',
5031
- Accept: 'application/json'
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/license/sync', async (req, res) => {
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
- verifiedLicense = {
5106
- userId: '',
5107
- plan: 'free',
5108
- status: 'inactive',
5109
- expiresAt: '',
5110
- verifiedAt: Date.now()
5111
- };
5112
- res.status(401).json({ ok: false, error: err instanceof Error ? err.message : String(err), license: licenseSnapshot() });
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
- plan: 'free',
5121
- status: 'inactive',
5122
- expiresAt: '',
5123
- verifiedAt: Date.now()
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 || ''));